"""
Rendering operations for Blender terrain visualization.
This module contains functions for configuring Blender render settings
and executing scene rendering.
"""
import logging
import time
from pathlib import Path
import bpy
logger = logging.getLogger(__name__)
# =============================================================================
# RENDER PROGRESS TRACKING
# =============================================================================
class RenderProgressTracker:
"""Track and report render progress for tiled rendering.
Uses Blender's render handlers to provide progress updates during
long-running renders. Particularly useful with auto-tiling enabled.
"""
def __init__(self, log: logging.Logger = None):
self.log = log or logger
self.start_time = None
self.last_update = 0
self.update_interval = 5.0 # Seconds between progress updates
self._handlers_registered = False
def _on_render_init(self, scene, depsgraph=None):
"""Called when render initializes.
Note: Blender 4.0+ passes depsgraph as second argument.
"""
self.start_time = time.time()
self.last_update = 0
self.log.info("Render starting...")
def _on_render_pre(self, scene, depsgraph=None):
"""Called before each render pass."""
pass
def _on_render_post(self, scene, depsgraph=None):
"""Called after each render pass."""
pass
def _on_render_stats(self, scene, depsgraph=None):
"""Called periodically with render statistics.
Note: This is called frequently during render. We throttle updates
to avoid log spam. Blender 4.0+ passes depsgraph as second argument.
"""
if self.start_time is None:
return
elapsed = time.time() - self.start_time
# Only log every update_interval seconds
if elapsed - self.last_update >= self.update_interval:
self.last_update = elapsed
# Get render progress info from Blender
try:
# Try to get progress from render result
cycles = scene.cycles
# Build progress message
minutes = int(elapsed // 60)
seconds = int(elapsed % 60)
if cycles.use_auto_tile:
self.log.info(f" Rendering... [{minutes:02d}:{seconds:02d}] (tiled mode)")
else:
self.log.info(f" Rendering... [{minutes:02d}:{seconds:02d}]")
except Exception:
pass # Silently ignore errors in progress reporting
def _on_render_complete(self, scene, depsgraph=None):
"""Called when render completes successfully."""
if self.start_time:
elapsed = time.time() - self.start_time
minutes = int(elapsed // 60)
seconds = int(elapsed % 60)
self.log.info(f" Render completed in {minutes:02d}:{seconds:02d}")
def _on_render_cancel(self, scene, depsgraph=None):
"""Called when render is cancelled."""
self.log.warning(" Render cancelled")
def register(self):
"""Register render progress handlers."""
if self._handlers_registered:
return
bpy.app.handlers.render_init.append(self._on_render_init)
bpy.app.handlers.render_pre.append(self._on_render_pre)
bpy.app.handlers.render_post.append(self._on_render_post)
bpy.app.handlers.render_complete.append(self._on_render_complete)
bpy.app.handlers.render_cancel.append(self._on_render_cancel)
# render_stats may not exist in all Blender versions
if hasattr(bpy.app.handlers, 'render_stats'):
bpy.app.handlers.render_stats.append(self._on_render_stats)
self._handlers_registered = True
def unregister(self):
"""Unregister render progress handlers."""
if not self._handlers_registered:
return
handlers_to_check = [
(bpy.app.handlers.render_init, self._on_render_init),
(bpy.app.handlers.render_pre, self._on_render_pre),
(bpy.app.handlers.render_post, self._on_render_post),
(bpy.app.handlers.render_complete, self._on_render_complete),
(bpy.app.handlers.render_cancel, self._on_render_cancel),
]
if hasattr(bpy.app.handlers, 'render_stats'):
handlers_to_check.append(
(bpy.app.handlers.render_stats, self._on_render_stats)
)
for handler_list, handler_func in handlers_to_check:
if handler_func in handler_list:
handler_list.remove(handler_func)
self._handlers_registered = False
# Global progress tracker instance
_progress_tracker = None
[docs]
def setup_render_settings(
use_gpu: bool = True,
samples: int = 128,
preview_samples: int = 32,
use_denoising: bool = True,
denoiser: str = "OPTIX",
compute_device: str = "OPTIX",
use_ambient_occlusion: bool = True,
ao_distance: float = 1.0,
ao_factor: float = 1.0,
use_persistent_data: bool = False,
use_auto_tile: bool = False,
tile_size: int = 2048,
) -> None:
"""
Configure Blender render settings for high-quality terrain visualization.
Args:
use_gpu: Whether to use GPU acceleration
samples: Number of render samples
preview_samples: Number of viewport preview samples
use_denoising: Whether to enable denoising
denoiser: Type of denoiser to use ('OPTIX', 'OPENIMAGEDENOISE', 'NLM')
compute_device: Compute device type ('OPTIX', 'CUDA', 'HIP', 'METAL')
use_ambient_occlusion: Enable ambient occlusion (darkens crevices)
ao_distance: AO sampling distance (default: 1.0 Blender units)
ao_factor: AO strength multiplier (default: 1.0)
use_persistent_data: Keep scene data in memory between frames (default: False)
use_auto_tile: Enable automatic tiling for large renders (default: False).
Splits large images into smaller GPU-friendly tiles to reduce VRAM usage.
Essential for print-quality renders (3000x2400+ pixels).
tile_size: Tile size in pixels when auto_tile is enabled (default: 2048).
Smaller tiles = less VRAM but slower rendering. Try 512-1024 for limited VRAM.
"""
logger.info("Configuring render settings...")
scene = bpy.context.scene
scene.render.engine = "CYCLES"
# Configure color management for sRGB
logger.info("Setting up color management...")
scene.view_settings.view_transform = "Standard" # sRGB in Blender 4.3
scene.view_settings.look = "None"
scene.view_settings.exposure = 0
scene.view_settings.gamma = 1
scene.display_settings.display_device = "sRGB"
# Configure render output settings
scene.render.image_settings.color_mode = "RGBA"
scene.render.image_settings.file_format = "PNG"
scene.render.image_settings.color_depth = "16"
scene.render.film_transparent = False
logger.info("Configuring Cycles settings...")
# Configure Cycles render settings
cycles = scene.cycles
cycles.max_bounces = 32
cycles.transparent_max_bounces = 32
cycles.transmission_bounces = 32
cycles.volume_bounces = 2
cycles.volume_step_rate = 5.0
cycles.volume_max_steps = 32
cycles.caustics_reflective = False
cycles.caustics_refractive = False
cycles.sample_clamp_indirect = 0.0
# Configure sampling settings
cycles.samples = samples
cycles.preview_samples = preview_samples
cycles.use_denoising = use_denoising
cycles.denoiser = denoiser
cycles.use_adaptive_sampling = True
# Configure GPU settings if requested
if use_gpu:
logger.info(f"Setting up GPU rendering with {compute_device}...")
try:
cycles.device = "GPU"
prefs = bpy.context.preferences
cprefs = prefs.addons["cycles"].preferences
cprefs.compute_device_type = compute_device
# Enable GPU devices only — excluding CPU prevents Cycles from
# splitting tiles between GPU and CPU, which causes CPU to bottleneck
# the render while GPU sits idle waiting for slower CPU tiles.
device_count = 0
for device in cprefs.devices:
device.use = device.type != "CPU"
if device.use:
device_count += 1
logger.info(f"Enabled {device_count} GPU compute devices (CPU excluded)")
except Exception as e:
logger.error(f"Failed to configure GPU rendering: {str(e)}")
logger.warning("Falling back to CPU rendering")
cycles.device = "CPU"
# Configure memory-saving options for large renders
if use_persistent_data:
logger.info("Enabling persistent data (keeps scene in memory)")
scene.render.use_persistent_data = True
if use_auto_tile:
logger.info(f"Enabling auto-tiling (tile size: {tile_size}px)")
cycles.use_auto_tile = True
cycles.tile_size = tile_size
# Configure ambient occlusion
# In Cycles, AO is achieved through path tracing. Fast GI can add AO-like effects.
if use_ambient_occlusion:
try:
# Enable fast GI (includes AO-like effects via approximation)
cycles.use_fast_gi = True
cycles.fast_gi_method = "REPLACE" # Replace bounces with fast approximation
cycles.ao_bounces = 2 # Number of bounces to approximate with AO
cycles.ao_bounces_render = 2
logger.info("Fast GI with AO approximation enabled")
except AttributeError:
# Fallback for older Blender versions - AO is natural in Cycles path tracing
logger.info("Ambient occlusion: using natural Cycles path tracing")
logger.info("Render settings configured successfully")
def _is_gpu_memory_error(error: Exception) -> bool:
"""Check if an exception is related to GPU/CUDA memory exhaustion.
Args:
error: The exception to check
Returns:
True if the error appears to be a GPU memory error
"""
error_str = str(error).lower()
gpu_memory_patterns = [
"cuda out of memory",
"out of memory",
"gpu memory",
"vram",
"memory allocation failed",
"insufficient memory",
"ran out of memory",
"cuda error",
"optix", # OptiX denoiser can also fail with memory issues
]
return any(pattern in error_str for pattern in gpu_memory_patterns)
[docs]
def render_scene_to_file(
output_path,
width=1920,
height=1440,
file_format="PNG",
color_mode="RGBA",
compression=90,
save_blend_file=True,
show_progress=True,
max_retries=3,
retry_delay=5.0,
):
"""
Render the current Blender scene to file.
Includes automatic retry logic for GPU memory errors. If rendering fails
due to CUDA/GPU memory exhaustion, the function will wait and retry up to
max_retries times before giving up.
Args:
output_path (str or Path): Path where output file will be saved
width (int): Render width in pixels (default: 1920)
height (int): Render height in pixels (default: 1440)
file_format (str): Output format 'PNG', 'JPEG', etc. (default: 'PNG')
color_mode (str): 'RGBA' or 'RGB' (default: 'RGBA')
compression (int): PNG compression level 0-100 (default: 90)
save_blend_file (bool): Also save .blend project file (default: True)
show_progress (bool): Show render progress updates (default: True).
Logs elapsed time every 5 seconds during rendering.
max_retries (int): Maximum number of retry attempts for GPU memory
errors (default: 3). Set to 0 to disable retries.
retry_delay (float): Seconds to wait between retry attempts (default: 5.0).
Allows GPU memory to be freed by other processes.
Returns:
Path: Path to rendered file if successful, None otherwise
"""
global _progress_tracker
try:
import bpy
except ImportError:
logger.warning("Blender/bpy not available - skipping render")
return None
output_path = Path(output_path).resolve()
logger.info(f"Rendering scene to {output_path}")
# Set up progress tracking if requested
if show_progress:
if _progress_tracker is None:
_progress_tracker = RenderProgressTracker(logger)
_progress_tracker.register()
try:
# Configure render output
bpy.context.scene.render.filepath = str(output_path)
bpy.context.scene.render.image_settings.file_format = file_format
bpy.context.scene.render.image_settings.color_mode = color_mode
if file_format == "PNG":
bpy.context.scene.render.image_settings.compression = compression
bpy.context.scene.render.resolution_x = width
bpy.context.scene.render.resolution_y = height
bpy.context.scene.render.resolution_percentage = 100
# Log tile info if auto-tiling is enabled
cycles = bpy.context.scene.cycles
if cycles.use_auto_tile:
tile_size = cycles.tile_size
tiles_x = (width + tile_size - 1) // tile_size
tiles_y = (height + tile_size - 1) // tile_size
total_tiles = tiles_x * tiles_y
logger.info(f"Render: {width}×{height} {file_format} ({color_mode})")
logger.info(f" Tiles: {tiles_x}×{tiles_y} = {total_tiles} tiles @ {tile_size}px")
else:
logger.info(f"Render: {width}×{height} {file_format} ({color_mode})")
# Save Blender file BEFORE rendering (so it's available even if render fails)
blend_path = None
if save_blend_file:
blend_path = output_path.parent / output_path.stem
blend_path = blend_path.with_suffix(".blend")
try:
bpy.ops.wm.save_as_mainfile(filepath=str(blend_path))
logger.info(f"Saved Blender file: {blend_path.name}")
except Exception as e:
logger.warning(f"Could not save Blender file: {e}")
# Execute render with retry logic for GPU memory errors
last_error = None
for attempt in range(max_retries + 1):
try:
if attempt > 0:
logger.info(f"Render retry attempt {attempt}/{max_retries}")
bpy.ops.render.render(write_still=True)
# Verify output
if output_path.exists():
file_size_mb = output_path.stat().st_size / (1024 * 1024)
logger.info(f"Rendered successfully: {file_size_mb:.1f} MB")
return output_path
else:
logger.error("Render file was not created")
return None
except Exception as e:
last_error = e
if _is_gpu_memory_error(e) and attempt < max_retries:
logger.warning(
f"GPU memory error on attempt {attempt + 1}: {e}"
)
logger.info(
f"Waiting {retry_delay}s before retry "
f"({max_retries - attempt} attempts remaining)..."
)
time.sleep(retry_delay)
# Exponential backoff: double delay each retry
retry_delay = min(retry_delay * 2, 60.0)
else:
# Non-GPU error or out of retries
break
# All retries exhausted or non-retryable error
if last_error:
if _is_gpu_memory_error(last_error):
logger.error(
f"Render failed after {max_retries + 1} attempts "
f"due to GPU memory: {last_error}"
)
else:
logger.error(f"Render failed: {last_error}")
return None
finally:
# Clean up progress tracker
if show_progress and _progress_tracker is not None:
_progress_tracker.unregister()
def get_render_settings_report() -> dict:
"""
Query Blender for the actual render settings used.
Returns a dictionary of all render-relevant settings, useful for
debugging, reproducibility, and verification.
Returns:
dict: Dictionary containing all render settings from Blender
"""
scene = bpy.context.scene
render = scene.render
cycles = scene.cycles
view = scene.view_settings
# Get compute device info
device_info = "CPU"
device_list = []
try:
prefs = bpy.context.preferences
cprefs = prefs.addons["cycles"].preferences
device_info = f"{cycles.device} ({cprefs.compute_device_type})"
device_list = [d.name for d in cprefs.devices if d.use]
except Exception:
pass
# Get world settings
world_info = {}
if scene.world and scene.world.use_nodes:
nodes = scene.world.node_tree.nodes
world_info["has_nodes"] = True
world_info["node_types"] = [n.type for n in nodes]
# Check for sky texture
for node in nodes:
if node.type == "TEX_SKY":
world_info["sky_type"] = node.sky_type
if hasattr(node, "sun_elevation"):
from math import degrees
world_info["sun_elevation"] = f"{degrees(node.sun_elevation):.1f}°"
world_info["sun_rotation"] = f"{degrees(node.sun_rotation):.1f}°"
if hasattr(node, "sun_size"):
world_info["sun_size"] = f"{degrees(node.sun_size):.2f}°"
if node.type == "VOLUME_PRINCIPLED":
world_info["atmosphere_density"] = node.inputs["Density"].default_value
# Get Fast GI / AO settings
ao_info = {}
try:
ao_info["fast_gi"] = cycles.use_fast_gi
if cycles.use_fast_gi:
ao_info["fast_gi_method"] = cycles.fast_gi_method
ao_info["ao_bounces"] = cycles.ao_bounces
except AttributeError:
ao_info["fast_gi"] = "Not available"
# Get light info
lights = []
for obj in bpy.data.objects:
if obj.type == "LIGHT":
light_data = obj.data
lights.append({
"name": obj.name,
"type": light_data.type,
"energy": light_data.energy,
"angle": f"{light_data.angle:.2f} rad" if hasattr(light_data, "angle") else "N/A",
})
# Get camera info
camera_info = {}
if scene.camera:
cam = scene.camera
cam_data = cam.data
camera_info = {
"name": cam.name,
"type": cam_data.type,
"location": f"({cam.location.x:.2f}, {cam.location.y:.2f}, {cam.location.z:.2f})",
}
if cam_data.type == "ORTHO":
camera_info["ortho_scale"] = cam_data.ortho_scale
else:
camera_info["focal_length"] = f"{cam_data.lens}mm"
return {
"engine": render.engine,
"resolution": f"{render.resolution_x}×{render.resolution_y}",
"resolution_percentage": f"{render.resolution_percentage}%",
"samples": cycles.samples,
"preview_samples": cycles.preview_samples,
"device": device_info,
"active_devices": device_list,
"denoising": {
"enabled": cycles.use_denoising,
"denoiser": cycles.denoiser if cycles.use_denoising else "N/A",
},
"adaptive_sampling": cycles.use_adaptive_sampling,
"bounces": {
"max": cycles.max_bounces,
"transparent": cycles.transparent_max_bounces,
"transmission": cycles.transmission_bounces,
"volume": cycles.volume_bounces,
},
"ambient_occlusion": ao_info,
"color_management": {
"display_device": scene.display_settings.display_device,
"view_transform": view.view_transform,
"look": view.look,
"exposure": view.exposure,
"gamma": view.gamma,
},
"output": {
"format": render.image_settings.file_format,
"color_mode": render.image_settings.color_mode,
"color_depth": render.image_settings.color_depth,
"film_transparent": render.film_transparent,
},
"world": world_info,
"camera": camera_info,
"lights": lights,
}
def print_render_settings_report(log: logging.Logger = None) -> None:
"""
Print a formatted report of all Blender render settings.
Queries Blender for actual settings and prints them in a readable format.
Useful for debugging and ensuring settings are correctly applied.
Args:
log: Logger to use (defaults to module logger)
"""
if log is None:
log = logger
settings = get_render_settings_report()
log.info("")
log.info("=" * 70)
log.info("BLENDER RENDER SETTINGS REPORT")
log.info("=" * 70)
# Core render settings
log.info(f"Engine: {settings['engine']}")
log.info(f"Resolution: {settings['resolution']} @ {settings['resolution_percentage']}")
log.info(f"Samples: {settings['samples']} (preview: {settings['preview_samples']})")
log.info(f"Device: {settings['device']}")
if settings['active_devices']:
log.info(f" Active: {', '.join(settings['active_devices'])}")
# Denoising
denoise = settings['denoising']
if denoise['enabled']:
log.info(f"Denoising: {denoise['denoiser']}")
else:
log.info("Denoising: Disabled")
# Adaptive sampling
log.info(f"Adaptive Sampling: {'Enabled' if settings['adaptive_sampling'] else 'Disabled'}")
# Bounces
bounces = settings['bounces']
log.info(f"Bounces: max={bounces['max']}, transparent={bounces['transparent']}, "
f"transmission={bounces['transmission']}, volume={bounces['volume']}")
# AO
ao = settings['ambient_occlusion']
if ao.get('fast_gi'):
log.info(f"Fast GI: {ao.get('fast_gi_method', 'Unknown')} (AO bounces: {ao.get('ao_bounces', '?')})")
else:
log.info("Fast GI: Disabled")
# Color management
cm = settings['color_management']
log.info(f"Color Management: {cm['display_device']} / {cm['view_transform']} "
f"(exposure={cm['exposure']}, gamma={cm['gamma']})")
# Output
out = settings['output']
log.info(f"Output: {out['format']} {out['color_mode']} {out['color_depth']}-bit "
f"(transparent={out['film_transparent']})")
# World
world = settings['world']
if world:
log.info("World:")
if 'sky_type' in world:
log.info(f" Sky: {world['sky_type']}")
if 'sun_elevation' in world:
sun_info = f"elevation={world['sun_elevation']}, rotation={world['sun_rotation']}"
if 'sun_size' in world:
sun_info += f", size={world['sun_size']}"
log.info(f" Sun: {sun_info}")
if 'atmosphere_density' in world:
log.info(f" Atmosphere: density={world['atmosphere_density']:.4f}")
if 'node_types' in world:
log.info(f" Nodes: {', '.join(world['node_types'])}")
# Camera
cam = settings['camera']
if cam:
log.info(f"Camera: {cam['name']} ({cam['type']}) at {cam['location']}")
if 'ortho_scale' in cam:
log.info(f" Ortho scale: {cam['ortho_scale']}")
elif 'focal_length' in cam:
log.info(f" Focal length: {cam['focal_length']}")
# Lights
lights = settings['lights']
if lights:
log.info(f"Lights: {len(lights)}")
for light in lights:
log.info(f" {light['name']}: {light['type']} energy={light['energy']} angle={light['angle']}")
log.info("=" * 70)