perf: render pipeline optimizations — sample scaling, USD logging, persistent BVH

Task 1: Resolution-aware sample count
- Auto-scale samples for resolutions <= 1024: max(32, samples * max_dim / 2048)
- 512x512 thumbnails: 256 → 64 samples (75% GPU savings)
- Thumbnail tasks capped at 64 samples via context manager
- 2048x2048 HQ renders unchanged

Task 2: USD path preference audit + logging
- Verified USD master path is correctly preferred over GLB tessellation
- Added clear emit() messages: "Using USD master" vs "No USD master — GLB path"
- Dynamic render log label: "USD → Blender" vs "STEP → GLB → Blender"

Task 3: Persistent BVH for turntable animations
- Added scene.render.use_persistent_data = True before frame loop
- BVH acceleration structure cached between frames (not rebuilt per frame)
- Applies to both camera orbit and object rotation modes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-15 12:03:31 +01:00
parent ce15526a15
commit ffe3eebfca
4 changed files with 125 additions and 103 deletions
@@ -165,6 +165,11 @@ def render_order_line_task(self, order_line_id: str):
_usd_candidate.name, cad_file.id,
)
if usd_render_path:
emit(order_line_id, "Using USD master for render (skipping GLB tessellation)")
else:
emit(order_line_id, "No USD master available — using GLB tessellation path")
part_colors = {}
if cad_file and cad_file.parsed_objects:
parsed_names = cad_file.parsed_objects.get("objects", [])
@@ -312,6 +317,16 @@ def render_order_line_task(self, order_line_id: str):
denoising_quality = str(rs.get("denoising_quality", ""))
denoising_use_gpu = str(rs.get("denoising_use_gpu", ""))
# Auto-scale samples for lower resolutions (thumbnails/previews).
# Only applies when the output type provides both samples and dimensions.
if render_samples and render_width and render_height:
max_dim = max(render_width, render_height)
if max_dim <= 1024:
scaled = max(32, int(render_samples * max_dim / 2048))
if scaled < render_samples:
emit(order_line_id, f"Auto-scaled samples {render_samples} \u2192 {scaled} for {render_width}x{render_height}")
render_samples = scaled
transparent_bg = bool(line.output_type and line.output_type.transparent_bg)
cycles_device_val = (line.output_type.cycles_device or "auto") if line.output_type else "auto"
@@ -395,7 +410,8 @@ def render_order_line_task(self, order_line_id: str):
logger.error("Turntable render failed for %s: %s", order_line_id, exc)
else:
# ── Still image path ────────────────────────────────────────
emit(order_line_id, f"Calling renderer (STEP → GLB → Blender) {render_width or 'default'}x{render_height or 'default'}{' [transparent]' if transparent_bg else ''}{f' engine={render_engine}' if render_engine else ''}{f' samples={render_samples}' if render_samples else ''}{tmpl_info}")
_render_path_label = "USD → Blender" if usd_render_path else "STEP → GLB → Blender"
emit(order_line_id, f"Calling renderer ({_render_path_label}) {render_width or 'default'}x{render_height or 'default'}{' [transparent]' if transparent_bg else ''}{f' engine={render_engine}' if render_engine else ''}{f' samples={render_samples}' if render_samples else ''}{tmpl_info}")
pl.step_start("blender_still", {"width": render_width, "height": render_height})
from app.services.step_processor import render_to_file
@@ -14,6 +14,40 @@ from app.core.pipeline_logger import PipelineLogger
logger = logging.getLogger(__name__)
# Maximum samples for thumbnail renders (512x512).
# Full-resolution renders use 256+ samples; thumbnails don't need more than 64.
_THUMBNAIL_SAMPLE_CAP = 64
@contextmanager
def _capped_thumbnail_samples():
"""Temporarily cap render samples for thumbnail renders.
Thumbnails are 512x512 — using 256 Cycles samples is wasteful.
This patches _get_all_settings in step_processor to cap samples
at _THUMBNAIL_SAMPLE_CAP for the duration of the thumbnail render.
"""
import app.services.step_processor as _sp
_original = _sp._get_all_settings
def _patched() -> dict[str, str]:
settings = _original()
for key in ("blender_cycles_samples", "blender_eevee_samples"):
try:
val = int(settings.get(key, "256"))
if val > _THUMBNAIL_SAMPLE_CAP:
logger.info("Capping thumbnail %s: %d -> %d", key, val, _THUMBNAIL_SAMPLE_CAP)
settings[key] = str(_THUMBNAIL_SAMPLE_CAP)
except (ValueError, TypeError):
pass
return settings
_sp._get_all_settings = _patched
try:
yield
finally:
_sp._get_all_settings = _original
@contextmanager
def _pipeline_session(tenant_id: str | None = None):
@@ -67,11 +101,12 @@ def render_step_thumbnail(self, cad_file_id: str):
except Exception:
logger.warning(f"step_file_hash computation failed for {cad_file_id} (non-fatal)")
# ── Render thumbnail ──────────────────────────────────────────────────
# ── Render thumbnail (with capped samples for 512x512) ──────────────
try:
from app.services.step_processor import regenerate_cad_thumbnail
pl.info("render_step_thumbnail", "Calling regenerate_cad_thumbnail")
success = regenerate_cad_thumbnail(cad_file_id, part_colors={})
with _capped_thumbnail_samples():
success = regenerate_cad_thumbnail(cad_file_id, part_colors={})
if not success:
raise RuntimeError("regenerate_cad_thumbnail returned False")
except Exception as exc:
@@ -166,7 +201,8 @@ def regenerate_thumbnail(self, cad_file_id: str, part_colors: dict):
try:
from app.services.step_processor import regenerate_cad_thumbnail
success = regenerate_cad_thumbnail(cad_file_id, part_colors)
with _capped_thumbnail_samples():
success = regenerate_cad_thumbnail(cad_file_id, part_colors)
if not success:
raise RuntimeError("regenerate_cad_thumbnail returned False")
except Exception as exc: