Johnny Osbournce
Fix configure_render CPU settings override + reduce quality for practical render times
1508f86 | #!/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() | |