#!/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()