mol-giga-render / scene.py
Johnny Osbournce
Fix configure_render CPU settings override + reduce quality for practical render times
1508f86
Raw
History Blame Contribute Delete
35.5 kB
#!/usr/bin/env python3
"""Molecular Gigafactory — Procedural Blender Scene Generator.
Usage:
# Test render single frame at 480p
blender --background --python mol_giga_scene.py -- --test --frame 500
# Render all phases at production resolution
blender --background --python mol_giga_scene.py -- --render-all --phase all
# Render specific phase
blender --background --python mol_giga_scene.py -- --render-all --phase A
# Render with custom resolution
blender --background --python mol_giga_scene.py -- --render-all --res 720 --phase all
"""
import bpy
import math
import mathutils
import os
import sys
import json
import time
import shutil
import argparse
from pathlib import Path
# ---------------------------------------------------------------------------
# CONFIGURATION
# ---------------------------------------------------------------------------
CONFIG = {
"output_dir": "/tmp/output",
"progress_file": "/tmp/output/progress.json",
"resolution_x": 1280, # 720p default for v0.01
"resolution_y": 720,
"fps": 24,
"samples": 64,
"rack_count": 5, # v0.01: 5 racks
"rack_rows": 13,
"rack_cols": 11,
"rack_height": 2.2,
"rack_width": 1.0,
"rack_depth": 0.8,
"room_width": 12.0,
"room_depth": 10.0,
"room_height": 3.5,
}
# Animation phase definitions (frames for each phase, 24fps, 45s total = 1080 frames)
PHASES = {
"A": {"name": "Exterior fly-in", "start": 1, "end": 240, "desc": "0-10s approach"},
"B": {"name": "Roof cutaway", "start": 241, "end": 480, "desc": "10-20s descend"},
"C": {"name": "Rack reveal", "start": 481, "end": 960, "desc": "20-40s interior"},
"D": {"name": "Detail annotations", "start": 961, "end": 1320, "desc": "40-55s close-up"},
"E": {"name": "Final hold", "start": 1321, "end": 1440, "desc": "55-60s settle"},
}
# ---------------------------------------------------------------------------
# CLI ARGUMENTS
# ---------------------------------------------------------------------------
def parse_args():
"""Parse command-line arguments after '--'."""
argv = sys.argv
if "--" in argv:
argv = argv[argv.index("--") + 1:]
else:
argv = []
parser = argparse.ArgumentParser(description="Molecular Gigafactory Scene Generator")
parser.add_argument("--test", action="store_true", help="Test render single frame")
parser.add_argument("--frame", type=int, default=500, help="Frame to test-render")
parser.add_argument("--render-all", action="store_true", help="Render full animation")
parser.add_argument("--phase", type=str, default="all", help="Phase to render (A/B/C/D/E/all)")
parser.add_argument("--res", type=int, default=None, help="Resolution (720 or 1080)")
parser.add_argument("--samples", type=int, default=None, help="Override sample count")
parser.add_argument("--output", type=str, default=None, help="Override output directory")
parser.add_argument("--cpu", action="store_true", help="Use CPU rendering (avoids GPU kernel hangs)")
parser.add_argument("--smoke", action="store_true", help="Minimal 64x64 1-sample render test")
return parser.parse_args(argv)
# ---------------------------------------------------------------------------
# UTILITIES
# ---------------------------------------------------------------------------
def make_material(name, color_hex, metallic=0.0, roughness=0.5, emit_strength=0.0, emit_hex=None, alpha=1.0):
"""Create a PBR material from hex colors matching the apex theme palette."""
def hex_to_rgb(h):
h = h.lstrip("#")
return (int(h[0:2], 16) / 255, int(h[2:4], 16) / 255, int(h[4:6], 16) / 255)
mat = bpy.data.materials.new(name)
mat.use_nodes = True
nodes = mat.node_tree.nodes
nodes.clear()
bsdf = nodes.new("ShaderNodeBsdfPrincipled")
bsdf.inputs["Base Color"].default_value = (*hex_to_rgb(color_hex), 1.0)
bsdf.inputs["Metallic"].default_value = metallic
bsdf.inputs["Roughness"].default_value = roughness
bsdf.inputs["Emission Strength"].default_value = emit_strength
if emit_hex:
# Blender 3.6 uses "Emission", Blender 4.0 uses "Emission Color"
emit_input = "Emission" if bpy.app.version[0] < 4 else "Emission Color"
bsdf.inputs[emit_input].default_value = (*hex_to_rgb(emit_hex), 1.0)
if alpha < 1.0:
mat.blend_method = 'BLEND'
bsdf.inputs["Alpha"].default_value = alpha
out = nodes.new("ShaderNodeOutputMaterial")
mat.node_tree.links.new(bsdf.outputs["BSDF"], out.inputs["Surface"])
return mat
def _detach_from_collections(obj):
"""Unlink from all collections so caller can link where needed."""
for coll in list(obj.users_collection):
coll.objects.unlink(obj)
def add_box(name, half_extents, location=(0, 0, 0)):
"""Add a box primitive and return it (not linked to any collection)."""
bpy.ops.mesh.primitive_cube_add(size=1)
obj = bpy.context.object
obj.name = name
obj.scale = half_extents
obj.location = location
_detach_from_collections(obj)
return obj
def add_cylinder(name, radius, depth, location=(0, 0, 0), rotation=(0, 0, 0)):
"""Add a cylinder primitive (not linked to any collection)."""
bpy.ops.mesh.primitive_cylinder_add(radius=radius, depth=depth, location=location)
obj = bpy.context.object
obj.name = name
obj.rotation_euler = rotation
_detach_from_collections(obj)
return obj
def add_sphere(name, radius, location=(0, 0, 0)):
"""Add a UV sphere (not linked to any collection)."""
bpy.ops.mesh.primitive_uv_sphere_add(radius=radius, location=location)
obj = bpy.context.object
obj.name = name
_detach_from_collections(obj)
return obj
def save_progress(data):
"""Write progress JSON for crash recovery."""
os.makedirs(os.path.dirname(CONFIG["progress_file"]), exist_ok=True)
with open(CONFIG["progress_file"], "w") as f:
json.dump(data, f)
def load_progress():
"""Read progress JSON, return None if not found."""
try:
with open(CONFIG["progress_file"]) as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return None
# ---------------------------------------------------------------------------
# 1. CLEAR SCENE
# ---------------------------------------------------------------------------
def clear_scene():
"""Remove all objects from the default scene."""
bpy.ops.wm.read_factory_settings(use_empty=True)
# Set render settings
scene = bpy.context.scene
scene.render.resolution_x = CONFIG["resolution_x"]
scene.render.resolution_y = CONFIG["resolution_y"]
scene.render.resolution_percentage = 100
scene.render.fps = CONFIG["fps"]
scene.render.image_settings.file_format = 'PNG'
scene.render.image_settings.color_mode = 'RGBA'
scene.frame_start = 1
scene.frame_end = 1440
# ---------------------------------------------------------------------------
# 2. MATERIALS
# ---------------------------------------------------------------------------
def create_all_materials():
"""Create all shared materials, return a dict for easy lookup."""
mats = {}
mats["pcb"] = make_material("PCB", "#1a5c1a", roughness=0.6)
mats["case"] = make_material("Case", "#2a2a2a", metallic=0.2, roughness=0.7)
mats["chip"] = make_material("Chip", "#222222", metallic=0.5, roughness=0.3)
mats["silver"] = make_material("Silver", "#c0c0c0", metallic=0.9, roughness=0.15)
mats["copper"] = make_material("Copper", "#b87333", metallic=0.8, roughness=0.2)
mats["battery"] = make_material("Battery", "#335599", metallic=0.3, roughness=0.4)
mats["led"] = make_material("LED", "#22c55e", emit_strength=5.0, emit_hex="#22c55e")
mats["flow"] = make_material("FlowCell", "#ccddff", metallic=0.0, roughness=0.1, alpha=0.7)
mats["rack"] = make_material("Rack", "#333333", metallic=0.7, roughness=0.4)
mats["floor"] = make_material("Floor", "#0d0d0d", roughness=0.8)
mats["wall"] = make_material("Wall", "#1a1a1a", roughness=0.9)
mats["ceiling"] = make_material("Ceiling", "#222222", roughness=0.8)
mats["cable"] = make_material("Cable", "#111111", roughness=0.6)
mats["trace"] = make_material("Trace", "#4ade80", emit_strength=2.0, emit_hex="#4ade80")
mats["wireframe"]= make_material("Wireframe","#4ade80", emit_strength=1.5, emit_hex="#4ade80")
mats["text_anno"]= make_material("Annotation","#4ade80", emit_strength=3.0, emit_hex="#4ade80")
return mats
# ---------------------------------------------------------------------------
# 3. MR1 DEVICE MODELS (3 LODs)
# ---------------------------------------------------------------------------
def build_mr1_lod0():
"""High-detail MR1 device (~8k tris). Returns a Collection."""
col = bpy.data.collections.new("MR1_LOD0")
bpy.context.scene.collection.children.link(col)
mats = create_all_materials()
# PCB base
pcb = add_box("PCB", (0.070, 0.035, 0.0008), (0, 0, 0.0008))
pcb.data.materials.append(mats["pcb"])
col.objects.link(pcb)
# ESP32-S3
esp = add_box("ESP32", (0.009, 0.009, 0.0015), (0.050, 0.015, 0.0023))
esp.data.materials.append(mats["chip"])
col.objects.link(esp)
# LMP7721 preamp
lmp = add_box("LMP7721", (0.0025, 0.0025, 0.00075), (-0.010, 0.000, 0.0018))
lmp.data.materials.append(mats["silver"])
col.objects.link(lmp)
# ADC
adc = add_box("MAX11169", (0.003, 0.003, 0.00075), (0.010, -0.010, 0.0018))
adc.data.materials.append(mats["chip"])
col.objects.link(adc)
# DAC
dac = add_box("MCP4822", (0.003, 0.003, 0.00075), (0.020, -0.010, 0.0018))
dac.data.materials.append(mats["chip"])
col.objects.link(dac)
# 6 batteries
for i in range(6):
b = add_cylinder(f"Batt{i+1}", 0.009, 0.065,
(-0.045 + i * 0.018, -0.025, 0.034),
(0, math.radians(90), 0))
b.data.materials.append(mats["battery"])
col.objects.link(b)
# Flow cell
flow = add_box("FlowCell", (0.010, 0.005, 0.0025), (-0.060, 0.0, 0.003))
flow.data.materials.append(mats["flow"])
col.objects.link(flow)
# Copper shield can (wireframe box over preamp)
shield = add_box("ShieldCan", (0.012, 0.010, 0.005), (-0.010, 0.0, 0.005))
shield.data.materials.append(mats["copper"])
mod = shield.modifiers.new("Wireframe", 'WIREFRAME')
mod.thickness = 0.0003
col.objects.link(shield)
# LED indicator
led = add_sphere("LED", 0.0015, (0.060, 0.025, 0.003))
led.data.materials.append(mats["led"])
col.objects.link(led)
# Ag/AgCl electrodes
for j, xo in enumerate([-0.002, 0.002]):
e = add_cylinder(f"Elec{j+1}", 0.00025, 0.006, (-0.060 + xo, 0.0, 0.006))
e.data.materials.append(mats["silver"])
col.objects.link(e)
# Case shell (wireframe display for now, solid in render)
case = add_box("CaseShell", (0.075, 0.039, 0.009), (0, 0, 0.001))
case.data.materials.append(mats["case"])
mod = case.modifiers.new("Solidify", 'SOLIDIFY')
mod.thickness = 0.001
mod.offset = 1
col.objects.link(case)
return col
def build_mr1_lod1():
"""Medium-detail MR1 (~1.2k tris). Simplified: case + PCB block + batteries."""
col = bpy.data.collections.new("MR1_LOD1")
bpy.context.scene.collection.children.link(col)
mats = create_all_materials()
# Main body block (PCB + case combined)
body = add_box("Body", (0.075, 0.039, 0.009), (0, 0, 0.001))
body.data.materials.append(mats["case"])
col.objects.link(body)
# Simplified PCB surface on top
pcb_top = add_box("PCB_Top", (0.070, 0.035, 0.0003), (0, 0, 0.010))
pcb_top.data.materials.append(mats["pcb"])
col.objects.link(pcb_top)
# Battery cylinders (6)
for i in range(6):
b = add_cylinder(f"B{i}", 0.009, 0.065,
(-0.045 + i * 0.018, -0.025, 0.034),
(0, math.radians(90), 0))
b.data.materials.append(mats["battery"])
col.objects.link(b)
# LED
led = add_sphere("LED", 0.002, (0.060, 0.025, 0.003))
led.data.materials.append(mats["led"])
col.objects.link(led)
return col
def build_mr1_lod2():
"""Low-detail MR1 (~200 tris). Single box with emissive LED point."""
col = bpy.data.collections.new("MR1_LOD2")
bpy.context.scene.collection.children.link(col)
mats = create_all_materials()
body = add_box("Body", (0.075, 0.039, 0.009))
body.data.materials.append(mats["case"])
col.objects.link(body)
led = add_sphere("LED", 0.003, (0.060, 0.025, 0.003))
led.data.materials.append(mats["led"])
col.objects.link(led)
return col
# ---------------------------------------------------------------------------
# 4. RACK
# ---------------------------------------------------------------------------
def build_rack(x, z, lod0_col, lod1_col, lod2_col):
"""Build a double-sided rack at position (x, 0, z) with LOD instances."""
mats = create_all_materials()
rack = bpy.data.collections.new(f"Rack_{int(x)}_{int(z)}")
bpy.context.scene.collection.children.link(rack)
# Frame
hw = CONFIG["rack_width"] / 2
hd = CONFIG["rack_depth"] / 2
hh = CONFIG["rack_height"] / 2
th = 0.02 # extrusion thickness
# Vertical posts (4 corners)
for dx in [-hw + th, hw - th]:
for dz in [-hd + th, hd - th]:
post = add_box("Post", (th/2, th/2, hh),
(x + dx, hh, z + dz))
post.data.materials.append(mats["rack"])
rack.objects.link(post)
# Horizontal rails (front and back, at each row position)
row_spacing = CONFIG["rack_height"] / CONFIG["rack_rows"]
for r in range(CONFIG["rack_rows"] + 1):
ry = r * row_spacing
for face_z in [-hd, hd]:
rail = add_box("Rail", (hw/2, 0.005, 0.005),
(x + hw/2, ry, z + face_z))
rail.data.materials.append(mats["rack"])
rack.objects.link(rail)
# Device instances — front and back face
col_spacing_w = (CONFIG["rack_width"] - 0.1) / CONFIG["rack_cols"]
col_spacing_h = (CONFIG["rack_height"] - 0.15) / CONFIG["rack_rows"]
start_w = -CONFIG["rack_width"]/2 + 0.05 + col_spacing_w/2
start_h = 0.08 + col_spacing_h/2
for face_z in [-hd + 0.09, hd - 0.09]: # offset for rack depth
for r in range(CONFIG["rack_rows"]):
for c in range(CONFIG["rack_cols"]):
dx = start_w + c * col_spacing_w
dy = start_h + r * col_spacing_h
dz = face_z
# Device lies flat: thickness (Z) points up (rack Y),
# long axis (X) goes into rack depth (rack Z)
empty = bpy.data.objects.new(f"Dev_{r}_{c}", None)
empty.empty_display_type = 'PLAIN_AXES'
empty.location = (x + dx, dy, z + dz)
empty.rotation_euler = (math.radians(90), 0, 0)
empty.instance_type = 'COLLECTION'
empty.instance_collection = lod0_col
rack.objects.link(empty)
return rack
# ---------------------------------------------------------------------------
# 5. FACILITY
# ---------------------------------------------------------------------------
def build_facility(mr1_lod0, mr1_lod1, mr1_lod2):
"""Build room, floor, walls, ceiling, lighting, racks."""
mats = create_all_materials()
scene_col = bpy.context.scene.collection
rw = CONFIG["room_width"]
rd = CONFIG["room_depth"]
rh = CONFIG["room_height"]
# Floor — large dark platform
floor = add_box("Floor", (rw/2 + 2, rd/2 + 2, 0.01), (rw/2, 0, rd/2))
floor.data.materials.append(mats["floor"])
scene_col.objects.link(floor)
# Walls — skip for v0.01; facility is an open platform
# Ceiling — skip; we want to see inside from above in exterior shots
# Racks — arrange in a grid
rack_count = CONFIG["rack_count"]
racks_per_row = 3 # for 5 racks: 2 rows of 2+3
rack_spacing_x = 2.5
rack_spacing_z = 2.5
start_x = rw/2 - (racks_per_row - 1) * rack_spacing_x / 2
start_z = 2.0
hero_index = 1 # rack the camera approaches in phases D-E
for i in range(rack_count):
rx = start_x + (i % racks_per_row) * rack_spacing_x
rz = start_z + (i // racks_per_row) * rack_spacing_z
lod = mr1_lod0 if i == hero_index else mr1_lod1
build_rack(rx, rz, lod, mr1_lod1, mr1_lod2)
# Overhead cable trays
for i in range(rack_count + 1):
cx = start_x + (i % racks_per_row) * rack_spacing_x - rack_spacing_x/2
tray = add_box("CableTray", (0.15, 0.03, rd/2), (cx, rh - 0.5, rd/2))
tray.data.materials.append(mats["cable"])
scene_col.objects.link(tray)
# Lighting — overhead area lamps + key sun
for lx in [rw * 0.25, rw * 0.5, rw * 0.75]:
for lz in [rd * 0.25, rd * 0.5, rd * 0.75]:
bpy.ops.object.light_add(type='AREA', location=(lx, rh - 0.1, lz))
light = bpy.context.object
light.data.energy = 150.0
light.data.size = 0.8
light.data.color = (0.9, 0.95, 1.0)
# Key sun for ambient fill (needed for EEVEE visibility)
bpy.ops.object.light_add(type='SUN', location=(rw * 0.3, rh + 5, rd * 0.3))
sun = bpy.context.object
sun.data.energy = 3.0
sun.data.angle = math.radians(15)
# ---------------------------------------------------------------------------
# 6. CAMERA ANIMATION (NURBS PATH)
# ---------------------------------------------------------------------------
def setup_camera_animation():
"""Create NURBS path + Follow Path constraint on camera."""
rw = CONFIG["room_width"]
rd = CONFIG["room_depth"]
rh = CONFIG["room_height"]
# Compute rack positions (matching build_facility layout)
racks_per_row = 3
rack_spacing_x = 2.5
rack_spacing_z = 2.5
start_x = rw/2 - (racks_per_row - 1) * rack_spacing_x / 2
start_z = 2.0
# Target rack for close-up phases D-E: second rack in first row
target_rack_x = start_x + rack_spacing_x
target_rack_z = start_z
# Waypoints for the 5-phase animation (x, y, z, weight)
waypoints = [
# Phase A: exterior approach (high above, looking down)
(rw/2, 50.0, rd/2 - 10, 1.0),
(rw/2, 30.0, rd/2 - 5, 1.0),
(rw/2, 15.0, rd/2 - 2, 1.0),
(rw/2, rh + 2, rd/2, 1.0), # just above roof, frame 240
# Phase B: roof cutaway (descend through roof)
(rw/2, rh - 0.5, rd/2, 1.0), # just below roof
(rw/2, rh - 1.5, rd/2, 1.0), # entering room
# Phase C: rack reveal (pan across room at ceiling height)
(rw * 0.8, rh - 2, rd * 0.7, 1.0),
(rw * 0.5, rh - 2.5, rd * 0.5, 1.0),
(rw * 0.3, 1.7, rd * 0.4, 1.0), # eye level, looking at racks
# Phase D: detail close-up (dolly toward target rack)
(target_rack_x, 1.5, target_rack_z, 1.0),
(target_rack_x + 0.3, 1.3, target_rack_z + 0.3, 1.0),
# Phase E: final hold
(target_rack_x + 0.5, 1.2, target_rack_z + 0.5, 1.0),
]
total_frames = 1440
# Camera — placed at first waypoint
first_wp = waypoints[0][:3]
bpy.ops.object.camera_add(location=first_wp)
cam = bpy.context.object
cam.name = "MainCamera"
cam.data.lens = 28
bpy.context.scene.camera = cam
# LookAt target — starts at room center
bpy.ops.object.empty_add(type='PLAIN_AXES', location=(rw/2, 1.0, rd/2))
target = bpy.context.object
target.name = "LookAtTarget"
# Animate target position
target.location = (rw/2, 1.0, rd/2)
target.keyframe_insert('location', frame=1)
target.location = (rw/2, rh - 3, rd/2)
target.keyframe_insert('location', frame=60)
target.location = (rw * 0.7, rh - 1.5, rd * 0.7)
target.keyframe_insert('location', frame=240)
target.location = (rw * 0.5, 1.0, rd * 0.5)
target.keyframe_insert('location', frame=480)
target.location = (target_rack_x, 1.0, target_rack_z)
target.keyframe_insert('location', frame=960)
target.location = (target_rack_x + 0.5, 1.2, target_rack_z + 0.5)
target.keyframe_insert('location', frame=1440)
# Direct keyframe camera position at phase boundaries (no constraint conflicts)
camera_keys = {
1: (rw/2, 50.0, rd/2 - 10), # Phase A start: high above, south
120: (rw/2, 30.0, rd/2 - 3),
240: (rw/2, rh + 2, rd/2), # Phase A→B: just above roof
360: (rw/2, rh - 1, rd/2), # Phase B: inside room, ceiling height
480: (rw * 0.7, rh - 2, rd * 0.7),# Phase B→C: panning across
720: (rw * 0.5, rh - 2.5, rd * 0.5),
960: (target_rack_x - 0.5, 1.5, target_rack_z), # Phase C→D: approach target rack
1200: (target_rack_x, 1.3, target_rack_z + 0.2), # Phase D: close-up
1440: (target_rack_x + 0.5, 1.2, target_rack_z + 0.5), # Phase E: final hold
}
for frame, pos in camera_keys.items():
cam.location = pos
cam.keyframe_insert('location', frame=frame)
# Set Track To constraint LAST so it isn't overridden
track = cam.constraints.new('TRACK_TO')
track.target = target
track.track_axis = 'TRACK_NEGATIVE_Z'
track.up_axis = 'UP_Y'
# Smooth interpolation for all fcurves
if cam.animation_data and cam.animation_data.action:
for fcu in cam.animation_data.action.fcurves:
for kf in fcu.keyframe_points:
kf.interpolation = 'BEZIER'
if target.animation_data and target.animation_data.action:
for fcu in target.animation_data.action.fcurves:
for kf in fcu.keyframe_points:
kf.interpolation = 'BEZIER'
return cam, target, None
# ---------------------------------------------------------------------------
# 7. ANNOTATIONS
# ---------------------------------------------------------------------------
def create_annotations():
"""Create 3D text annotation objects with keyframed visibility."""
mats = create_all_materials()
annot_col = bpy.data.collections.new("Annotations")
bpy.context.scene.collection.children.link(annot_col)
# Find a good position near the central rack
rack_x = CONFIG["room_width"] / 2
rack_z = 2.0
annotations = [
("MR1 Molecular Streamer", (rack_x - 0.5, 1.8, rack_z + 0.5), 961),
("10,000 devices / 35 racks", (rack_x - 0.5, 1.5, rack_z + 0.5), 1000),
("ESP32-S3 · LMP7721 · MAX11169", (rack_x - 0.5, 1.2, rack_z + 0.5), 1040),
("Flow Cell: Silicon Micropore", (rack_x - 0.5, 0.9, rack_z + 0.5), 1080),
("286 devices per rack", (rack_x - 0.5, 0.6, rack_z + 0.5), 1120),
]
for text, loc, appear_frame in annotations:
bpy.ops.object.text_add(location=loc)
txt_obj = bpy.context.object
txt_obj.name = f"Anno_{text[:20]}"
txt_obj.data.body = text
txt_obj.data.size = 0.08
txt_obj.data.extrude = 0.005
txt_obj.data.align_x = 'LEFT'
txt_obj.data.font = bpy.data.fonts.load(
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf"
) if os.path.exists("/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf") else None
txt_obj.data.materials.append(mats["text_anno"])
# Visibility keyframes
txt_obj.hide_viewport = True
txt_obj.hide_render = True
txt_obj.keyframe_insert('hide_render', frame=appear_frame - 1)
txt_obj.keyframe_insert('hide_viewport', frame=appear_frame - 1)
txt_obj.hide_render = False
txt_obj.hide_viewport = False
txt_obj.keyframe_insert('hide_render', frame=appear_frame)
txt_obj.keyframe_insert('hide_viewport', frame=appear_frame)
annot_col.objects.link(txt_obj)
# ---------------------------------------------------------------------------
# 8. RENDER SETTINGS
# ---------------------------------------------------------------------------
def configure_render(engine='CYCLES', use_cpu=False):
"""Set render engine and quality settings."""
scene = bpy.context.scene
if engine == 'CYCLES':
scene.render.engine = 'CYCLES'
if use_cpu:
scene.cycles.device = 'CPU'
print("Cycles: using CPU (--cpu flag)")
scene.cycles.denoiser = 'OPENIMAGEDENOISE'
# CPU is 20-50x slower than GPU — use lighter settings
if CONFIG["samples"] > 32:
CONFIG["samples"] = 32
scene.cycles.samples = CONFIG["samples"]
scene.cycles.use_denoising = True
scene.cycles.use_adaptive_sampling = False
scene.cycles.max_bounces = 4
scene.cycles.diffuse_bounces = 2
scene.cycles.glossy_bounces = 2
scene.cycles.transmission_bounces = 4
scene.cycles.volume_bounces = 0
scene.cycles.texture_limit = '512'
scene.cycles.use_square_samples = True
else:
prefs = bpy.context.preferences.addons['cycles'].preferences
# Prefer CUDA over OptiX — CUDA kernels pre-compile in warmup and
# don't trigger the A100 JIT hang that OptiX does.
for device_type in ['CUDA', 'OPTIX']:
try:
prefs.compute_device_type = device_type
prefs.get_devices()
gpu_found = any(d.type == device_type for d in prefs.devices)
if gpu_found:
print(f"Cycles: using {device_type}")
break
except Exception:
continue
# Enable all GPU devices (matching device types)
for device in prefs.devices:
device.use = device.type in ('OPTIX', 'CUDA')
print(f" Device: {device.name} ({device.type}) — {'enabled' if device.use else 'skipped'}")
scene.cycles.device = 'GPU'
scene.cycles.samples = CONFIG["samples"]
scene.cycles.use_denoising = True
scene.cycles.use_adaptive_sampling = True
scene.cycles.adaptive_threshold = 0.01
scene.cycles.max_bounces = 8
scene.cycles.diffuse_bounces = 4
scene.cycles.glossy_bounces = 4
scene.cycles.transmission_bounces = 8
scene.cycles.volume_bounces = 0
scene.cycles.texture_limit = '1024'
scene.cycles.use_square_samples = True
# Match denoiser to detected compute device (OptiX needs RT cores)
if prefs.compute_device_type == 'OPTIX':
scene.cycles.denoiser = 'OPTIX'
else:
scene.cycles.denoiser = 'OPENIMAGEDENOISE'
# Blender 4.0+ features — guard for 3.6 LTS compatibility
if hasattr(scene.cycles, 'use_compact_bvh'):
scene.cycles.use_compact_bvh = True
if hasattr(scene.cycles, 'bvh_type'):
scene.cycles.bvh_type = 'STATIC'
else:
scene.render.engine = 'BLENDER_EEVEE'
scene.eevee.taa_render_samples = 32
scene.eevee.use_bloom = True
scene.eevee.bloom_intensity = 0.3
scene.eevee.use_gtao = True
# Simplify for performance
scene.render.use_simplify = True
scene.render.simplify_subdivision = 0
scene.render.simplify_child_particles = 0.0
# Color management
scene.view_settings.view_transform = 'Standard'
scene.view_settings.look = 'None'
# ---------------------------------------------------------------------------
# 9. WORLD / ENVIRONMENT
# ---------------------------------------------------------------------------
def setup_world():
"""Dark world matching apex theme — dim ambient fill."""
world = bpy.data.worlds.new("ApexDark")
bpy.context.scene.world = world
world.use_nodes = True
bg = world.node_tree.nodes["Background"]
bg.inputs["Color"].default_value = (0.01, 0.02, 0.01, 1.0)
bg.inputs["Strength"].default_value = 1.0
# ---------------------------------------------------------------------------
# 10. RENDER LOOP WITH CHECKPOINTING
# ---------------------------------------------------------------------------
def render_phase(phase_key, engine='CYCLES'):
"""Render all frames for a given phase, with checkpoint/resume."""
phase = PHASES[phase_key]
scene = bpy.context.scene
out_dir = os.path.join(CONFIG["output_dir"], f"phase_{phase_key}")
os.makedirs(out_dir, exist_ok=True)
preview_dir = os.path.join(CONFIG["output_dir"], "preview")
os.makedirs(preview_dir, exist_ok=True)
scene.frame_start = phase["start"]
scene.frame_end = phase["end"]
scene.render.filepath = os.path.join(out_dir, "frame_####.png")
# Check progress
progress = load_progress() or {}
phase_progress = progress.get(f"phase_{phase_key}", {})
last_frame = phase_progress.get("last_frame", phase["start"] - 1)
if last_frame >= phase["end"]:
print(f"Phase {phase_key} already complete, skipping.")
return
start_from = last_frame + 1
# Register a render-write handler to copy each completed frame as live preview
live_preview_frame = [0] # mutable closure
def on_frame_written(scene):
live_preview_frame[0] += 1
frame_file = scene.render.filepath
# Blender replaces #### with frame number in the path
if "####" in frame_file:
frame_file = frame_file.replace("####", f"{scene.frame_current:04d}")
preview_path = os.path.join(preview_dir, "latest_frame.png")
if os.path.exists(frame_file):
shutil.copy2(frame_file, preview_path)
if live_preview_frame[0] % 10 == 0:
progress[f"phase_{phase_key}"] = {"last_frame": scene.frame_current, "timestamp": time.time()}
save_progress(progress)
print(f" [{phase_key}] Frame {scene.frame_current}/{phase['end']} ({100*scene.frame_current/phase['end']:.0f}%)")
bpy.app.handlers.render_write.append(on_frame_written)
try:
print(f"Rendering phase {phase_key} ({phase['name']}): frames {start_from}-{phase['end']}")
# Always use per-frame write_still=True — animation=True triggers
# a GPU kernel compilation path that hangs on A100 (both 4.0.2 and 3.6.5).
# write_still=True uses a simpler kernel path that works.
for frame in range(start_from, phase["end"] + 1):
scene.frame_set(frame)
scene.render.filepath = os.path.join(out_dir, f"frame_{frame:04d}.png")
bpy.ops.render.render(write_still=True)
finally:
bpy.app.handlers.render_write.remove(on_frame_written)
# Mark phase complete
progress[f"phase_{phase_key}"] = {"last_frame": phase["end"], "timestamp": time.time(), "complete": True}
save_progress(progress)
print(f"Phase {phase_key} complete.")
def render_test_frame(frame_num, engine='EEVEE'):
"""Render a single test frame at low resolution."""
scene = bpy.context.scene
scene.render.resolution_x = 640
scene.render.resolution_y = 360
test_path = os.path.join(CONFIG["output_dir"], f"test_frame_{frame_num:04d}.png")
scene.render.filepath = test_path
scene.frame_set(frame_num)
# Debug: camera position
cam = bpy.data.objects.get('MainCamera')
if cam:
loc = cam.matrix_world.translation
direction = cam.matrix_world.to_quaternion() @ mathutils.Vector((0, 0, -1))
print(f" Camera pos: {loc}, looking: {direction}")
if engine == 'EEVEE':
scene.render.engine = 'BLENDER_EEVEE'
scene.eevee.taa_render_samples = 32
else:
scene.render.engine = 'CYCLES'
scene.cycles.device = 'CPU'
scene.cycles.samples = 8
scene.cycles.use_denoising = False
bpy.ops.render.render(write_still=True)
print(f"Test frame rendered to {test_path}")
# ---------------------------------------------------------------------------
# MAIN
# ---------------------------------------------------------------------------
def main():
args = parse_args()
# Apply overrides
if args.res:
CONFIG["resolution_x"] = 1920 if args.res == 1080 else 1280
CONFIG["resolution_y"] = 1080 if args.res == 1080 else 720
if args.samples:
CONFIG["samples"] = args.samples
if args.output:
CONFIG["output_dir"] = args.output
CONFIG["progress_file"] = os.path.join(args.output, "progress.json")
print(f"=== Molecular Gigafactory Scene Generator ===")
print(f"Resolution: {CONFIG['resolution_x']}x{CONFIG['resolution_y']}")
print(f"Samples: {CONFIG['samples']}")
print(f"Output: {CONFIG['output_dir']}")
# Build scene
print("Clearing scene...")
clear_scene()
print("Setting up world...")
setup_world()
print("Building MR1 device models...")
mr1_lod0 = build_mr1_lod0()
mr1_lod1 = build_mr1_lod1()
mr1_lod2 = build_mr1_lod2()
print("Building facility...")
build_facility(mr1_lod0, mr1_lod1, mr1_lod2)
print("Setting up camera animation...")
setup_camera_animation()
print("Creating annotations...")
create_annotations()
if args.smoke:
print("=== SMOKE TEST: 64x64, 1 sample, CPU Cycles ===")
scene = bpy.context.scene
scene.render.resolution_x = 64
scene.render.resolution_y = 64
scene.render.engine = 'CYCLES'
scene.cycles.device = 'CPU'
scene.cycles.samples = 1
scene.cycles.use_denoising = False
scene.cycles.use_adaptive_sampling = False
scene.cycles.max_bounces = 1
scene.cycles.diffuse_bounces = 1
scene.cycles.glossy_bounces = 1
scene.cycles.transmission_bounces = 1
scene.cycles.volume_bounces = 0
scene.cycles.texture_limit = '128'
scene.frame_set(1)
smoke_path = os.path.join(CONFIG["output_dir"], "smoke_test.png")
scene.render.filepath = smoke_path
print(f"Rendering smoke test to {smoke_path}...")
bpy.ops.render.render(write_still=True)
if os.path.exists(smoke_path):
print(f"SMOKE TEST PASSED: {os.path.getsize(smoke_path)} bytes")
else:
print(f"SMOKE TEST FAILED: no output file")
elif args.test:
engine = 'CYCLES' if args.cpu else 'EEVEE'
print(f"Test render at frame {args.frame} (engine={engine})...")
if engine == 'CYCLES':
configure_render('CYCLES', use_cpu=True)
render_test_frame(args.frame, engine)
elif args.render_all:
engine = 'CYCLES'
print(f"Configuring {engine} render engine...")
configure_render(engine, use_cpu=args.cpu)
if args.phase == "all":
for phase_key in ["A", "B", "C", "D", "E"]:
print(f"\n--- Phase {phase_key}: {PHASES[phase_key]['name']} ---")
render_phase(phase_key, engine)
else:
render_phase(args.phase, engine)
print("\n=== All rendering complete ===")
else:
print("No action specified. Use --test or --render-all.")
print("Scene built successfully — ready for rendering.")
if __name__ == "__main__":
main()