import math import os import sys import bpy from mathutils import Vector ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) OUT = os.path.join("/tmp", "dsh-director-toolkit-case-renders") WIDTH = 480 FRAMES = 36 def reset_scene(): bpy.ops.object.select_all(action="SELECT") bpy.ops.object.delete(use_global=False) for datablocks in (bpy.data.meshes, bpy.data.curves, bpy.data.materials, bpy.data.cameras, bpy.data.lights): for datablock in list(datablocks): if datablock.users == 0: datablocks.remove(datablock) scene = bpy.context.scene scene.render.engine = "BLENDER_EEVEE" scene.render.resolution_x = WIDTH scene.render.resolution_y = WIDTH scene.render.resolution_percentage = 100 scene.render.fps = 12 scene.frame_start = 1 scene.frame_end = FRAMES scene.render.image_settings.file_format = "PNG" scene.render.film_transparent = False try: scene.view_settings.look = "AgX - Medium High Contrast" except Exception: pass return scene def material(name, color, metallic=0.0, roughness=0.45, emission=None, emission_strength=0.0): mat = bpy.data.materials.new(name) mat.diffuse_color = (*color, 1) mat.use_nodes = True shader = mat.node_tree.nodes.get("Principled BSDF") shader.inputs["Base Color"].default_value = (*color, 1) shader.inputs["Metallic"].default_value = metallic shader.inputs["Roughness"].default_value = roughness if emission and "Emission Color" in shader.inputs: shader.inputs["Emission Color"].default_value = (*emission, 1) shader.inputs["Emission Strength"].default_value = emission_strength return mat def cube(name, location, scale, mat, bevel=0.04): bpy.ops.mesh.primitive_cube_add(location=location) obj = bpy.context.object obj.name = name obj.scale = scale bpy.ops.object.transform_apply(location=False, rotation=False, scale=True) obj.data.materials.append(mat) if bevel: modifier = obj.modifiers.new("soft edge", "BEVEL") modifier.width = bevel modifier.segments = 3 return obj def sphere(name, location, scale, mat, segments=32): bpy.ops.mesh.primitive_uv_sphere_add(segments=segments, ring_count=16, location=location) obj = bpy.context.object obj.name = name obj.scale = scale bpy.ops.object.transform_apply(location=False, rotation=False, scale=True) obj.data.materials.append(mat) bpy.ops.object.shade_smooth() return obj def cylinder(name, location, radius, depth, mat, bevel=0.03, vertices=48): bpy.ops.mesh.primitive_cylinder_add(vertices=vertices, radius=radius, depth=depth, location=location) obj = bpy.context.object obj.name = name obj.data.materials.append(mat) if bevel: modifier = obj.modifiers.new("soft edge", "BEVEL") modifier.width = bevel modifier.segments = 3 bpy.ops.object.shade_smooth() return obj def torus(name, location, major, minor, mat, rotation=(0, 0, 0)): bpy.ops.mesh.primitive_torus_add(major_radius=major, minor_radius=minor, major_segments=64, minor_segments=12, location=location, rotation=rotation) obj = bpy.context.object obj.name = name obj.data.materials.append(mat) bpy.ops.object.shade_smooth() return obj def signal_relic(location, shell_mat, signal_mat, scale=1.0): """The same small signal object appears in every chapter of the story.""" core = sphere("Story signal relic", location, (0.30 * scale, 0.30 * scale, 0.30 * scale), shell_mat, 32) iris = sphere("Relic signal core", (location[0], location[1] - 0.27 * scale, location[2]), (0.12 * scale, 0.035 * scale, 0.12 * scale), signal_mat, 24) ring = torus("Relic orbit ring", location, 0.48 * scale, 0.018 * scale, signal_mat, rotation=(math.radians(68), math.radians(12), 0)) ring_b = torus("Relic orbit ring 2", location, 0.54 * scale, 0.009 * scale, shell_mat, rotation=(math.radians(-24), math.radians(22), 0)) animate_rotation(ring, 1, FRAMES, "z", 0, math.tau) animate_rotation(ring_b, 1, FRAMES, "x", math.radians(-20), math.radians(35)) core.keyframe_insert(data_path="rotation_euler", frame=1) core.rotation_euler[2] = math.radians(16) core.keyframe_insert(data_path="rotation_euler", frame=FRAMES) iris.keyframe_insert(data_path="scale", frame=1) iris.scale.x *= 1.25 iris.scale.z *= 0.82 iris.keyframe_insert(data_path="scale", frame=FRAMES) return core def shard(name, location, scale, rotation, mat): obj = cube(name, location, scale, mat, 0.025) obj.rotation_euler = rotation obj.keyframe_insert(data_path="rotation_euler", frame=1) obj.rotation_euler[2] += math.radians(24) obj.keyframe_insert(data_path="rotation_euler", frame=FRAMES) return obj def light(name, kind, location, energy, color, size=3.0): bpy.ops.object.light_add(type=kind, location=location) obj = bpy.context.object obj.name = name obj.data.energy = energy obj.data.color = color if kind == "AREA": obj.data.shape = "DISK" obj.data.size = size return obj def point_at(obj, target): obj.rotation_euler = (Vector(target) - obj.location).to_track_quat("-Z", "Y").to_euler() def camera(scene, location, target, lens=52): bpy.ops.object.camera_add(location=location) cam = bpy.context.object cam.data.lens = lens cam.data.sensor_width = 36 point_at(cam, target) scene.camera = cam return cam def animate_camera(cam, positions, target): for frame, position in positions: cam.location = position point_at(cam, target) cam.keyframe_insert(data_path="location", frame=frame) cam.keyframe_insert(data_path="rotation_euler", frame=frame) def animate_rotation(obj, frame_a, frame_b, axis, start, end): setattr(obj.rotation_euler, axis, start) obj.keyframe_insert(data_path="rotation_euler", frame=frame_a) setattr(obj.rotation_euler, axis, end) obj.keyframe_insert(data_path="rotation_euler", frame=frame_b) def setup_world(scene, color): scene.world.color = color world = scene.world world.use_nodes = True background = world.node_tree.nodes.get("Background") background.inputs["Color"].default_value = (*color, 1) background.inputs["Strength"].default_value = 0.18 def chrome_orbit(scene): setup_world(scene, (0.008, 0.012, 0.022)) chrome = material("Mirror chrome", (0.42, 0.48, 0.56), metallic=0.95, roughness=0.12) amber = material("Amber reflection", (0.8, 0.22, 0.05), metallic=0.25, roughness=0.28, emission=(0.8, 0.08, 0.02), emission_strength=0.3) black = material("Black plinth", (0.008, 0.012, 0.018), roughness=0.32) floor = cube("Floor", (0, 0, -0.08), (3.5, 3.5, 0.08), black, 0.02) bottle = cylinder("Chrome perfume bottle", (0, 0, 0.72), 0.64, 1.3, chrome, 0.12) bottle.scale.x = 0.82 bottle.scale.y = 0.58 neck = cylinder("Bottle neck", (0, 0, 1.48), 0.22, 0.28, chrome, 0.04) cap = cylinder("Bottle cap", (0, 0, 1.7), 0.27, 0.24, amber, 0.05) ring = torus("Orbital rig", (0, 0, 0.9), 1.12, 0.025, amber, rotation=(math.radians(60), math.radians(12), 0)) ring_b = torus("Secondary orbital rig", (0, 0, 0.9), 1.15, 0.012, chrome, rotation=(math.radians(-20), math.radians(22), 0)) animate_rotation(ring, 1, FRAMES, "z", 0, math.tau) animate_rotation(ring_b, 1, FRAMES, "x", math.radians(-20), math.radians(35)) animate_rotation(bottle, 1, FRAMES, "z", math.radians(-3), math.radians(6)) signal_relic((-1.35, 0.1, 1.1), chrome, amber, 0.52) shard("Chrome shard A", (1.1, 0.25, 0.28), (0.08, 0.28, 0.32), (math.radians(20), 0, math.radians(24)), amber) shard("Chrome shard B", (-0.95, 0.55, 0.22), (0.08, 0.2, 0.24), (math.radians(-16), math.radians(18), math.radians(-20)), chrome) light("White key", "AREA", (-2.5, -3.0, 4.0), 900, (1.0, 0.92, 0.82), 3.0) light("Blue rim", "AREA", (2.8, 1.0, 2.8), 1100, (0.12, 0.4, 1.0), 2.0) light("Amber strip", "AREA", (-2.0, 1.4, 1.4), 500, (1.0, 0.18, 0.03), 0.7) cam = camera(scene, (3.2, -4.3, 2.15), (0, 0, 0.9), 58) animate_camera(cam, [(1, (3.2, -4.3, 2.15)), (FRAMES, (-2.4, -4.8, 1.95))], (0, 0, 0.9)) def memory_garden(scene): setup_world(scene, (0.002, 0.004, 0.008)) lime = material("Acid lime", (0.08, 0.48, 0.14), roughness=0.28, emission=(0.36, 1.0, 0.12), emission_strength=1.6) glass = material("Garden glass", (0.05, 0.28, 0.32), metallic=0.15, roughness=0.17, emission=(0.02, 0.12, 0.1), emission_strength=0.45) dark = material("Garden floor", (0.004, 0.007, 0.01), roughness=0.7) cube("Garden floor", (0, 0, -0.1), (3.5, 3.5, 0.1), dark, 0.01) signal_relic((0.0, -0.65, 1.28), glass, lime, 0.6) for index, (x, y, height) in enumerate([(-1.1, 0.1, 1.45), (-0.55, 0.35, 1.15), (0.0, 0.0, 1.8), (0.6, 0.3, 1.35), (1.1, -0.05, 1.55)]): stem = cylinder("Coral stem", (x, y, height / 2), 0.1, height, glass, 0.025, 24) stem.rotation_euler[1] = math.radians(-8 + index * 4) stem.keyframe_insert(data_path="rotation_euler", frame=1) stem.rotation_euler[2] = math.radians(8 * math.sin(index)) stem.keyframe_insert(data_path="rotation_euler", frame=FRAMES) for branch in range(3): z = height * (0.48 + branch * 0.18) bx = x + (branch - 1) * 0.16 bud = sphere("Lime coral bud", (bx, y, z), (0.14, 0.14, 0.22), lime, 20) bud.rotation_euler[1] = math.radians(branch * 24 - 20) bud.keyframe_insert(data_path="rotation_euler", frame=1) bud.rotation_euler[2] += math.radians(18) bud.keyframe_insert(data_path="rotation_euler", frame=FRAMES) for index, (x, y, z) in enumerate([(-0.9, -0.35, 1.8), (0.5, -0.25, 2.25), (1.0, 0.2, 1.25), (-0.25, 0.5, 1.35)]): seed = sphere("Floating glass seed", (x, y, z), (0.12, 0.12, 0.2), lime, 24) seed.rotation_euler[1] = math.radians(index * 25) seed.keyframe_insert(data_path="location", frame=1) seed.location.z += 0.22 seed.rotation_euler[2] += math.radians(90) seed.keyframe_insert(data_path="location", frame=FRAMES) seed.keyframe_insert(data_path="rotation_euler", frame=FRAMES) shard("Glass leaf A", (-1.55, -0.4, 0.48), (0.05, 0.34, 0.15), (math.radians(18), math.radians(-14), math.radians(28)), glass) shard("Glass leaf B", (1.48, -0.25, 0.55), (0.05, 0.32, 0.13), (math.radians(-20), math.radians(12), math.radians(-24)), glass) light("Garden key", "AREA", (-2.2, -2.5, 3.5), 700, (0.25, 0.95, 0.7), 3.5) light("Garden edge", "AREA", (2.2, 1.2, 2.5), 500, (0.3, 0.9, 1.0), 2.0) cam = camera(scene, (3.5, -5.5, 2.35), (0, 0.1, 0.95), 54) animate_camera(cam, [(1, (3.5, -5.5, 2.35)), (FRAMES, (2.6, -5.8, 2.0))], (0, 0.1, 0.95)) def brutalist_archive(scene): setup_world(scene, (0.04, 0.045, 0.055)) concrete = material("Rain concrete", (0.28, 0.31, 0.34), roughness=0.86) inside = material("Archive interior", (0.015, 0.022, 0.03), roughness=0.7) cold = material("Cold strip", (0.3, 0.7, 0.9), roughness=0.22, emission=(0.2, 0.7, 1.0), emission_strength=2.0) floor = cube("Wet archive ground", (0, 0, -0.12), (3.7, 3.7, 0.12), inside, 0.02) signal_relic((0, -0.92, 1.17), concrete, cold, 0.58) cube("Archive left tower", (-1.2, 0.1, 1.25), (0.38, 0.92, 1.35), concrete, 0.06) cube("Archive right tower", (1.2, 0.15, 1.05), (0.42, 0.84, 1.1), concrete, 0.06) cube("Archive lintel", (0, 0.08, 2.22), (1.55, 0.86, 0.25), concrete, 0.05) cube("Impossible cantilever", (0.58, -0.08, 1.58), (1.08, 0.55, 0.14), concrete, 0.04) cube("Interior strip", (0, -0.86, 1.15), (0.06, 0.03, 0.7), cold, 0.01) strip = cube("Moving strip", (0.0, -0.89, 1.15), (0.06, 0.02, 0.7), cold, 0.01) strip.keyframe_insert(data_path="location", frame=1) strip.location.x = 0.75 strip.keyframe_insert(data_path="location", frame=FRAMES) for x, y in [(-2.0, -0.4), (-1.55, -0.6), (1.8, -0.5), (1.3, 0.65)]: cube("Archive debris", (x, y, 0.12), (0.14, 0.14, 0.12), concrete, 0.02) shard("Archive slab A", (-1.9, -0.7, 0.32), (0.28, 0.08, 0.18), (math.radians(15), 0, math.radians(-26)), concrete) shard("Archive slab B", (1.8, -0.7, 0.42), (0.22, 0.09, 0.12), (math.radians(-12), math.radians(8), math.radians(31)), concrete) light("Archive sky", "AREA", (-3.0, -3.0, 4.5), 1100, (0.55, 0.7, 1.0), 4.0) light("Archive strip glow", "AREA", (0, -2.2, 1.3), 500, (0.2, 0.65, 1.0), 1.2) cam = camera(scene, (3.8, -5.4, 2.25), (0, 0.05, 1.1), 52) animate_camera(cam, [(1, (3.8, -5.4, 2.25)), (FRAMES, (2.9, -5.9, 1.85))], (0, 0.05, 1.1)) def cloth_robot(scene): setup_world(scene, (0.025, 0.02, 0.035)) cloth = material("Indigo cloth", (0.12, 0.18, 0.36), roughness=0.72) seam = material("Coral seam", (0.75, 0.22, 0.16), roughness=0.4, emission=(0.5, 0.05, 0.02), emission_strength=0.35) metal = material("Robot metal", (0.28, 0.34, 0.42), metallic=0.7, roughness=0.28) white = material("Eye light", (0.75, 0.9, 1.0), emission=(0.2, 0.7, 1.0), emission_strength=2.4) cube("Robot floor", (0, 0, -0.1), (3.4, 3.4, 0.1), material("Robot floor mat", (0.012, 0.014, 0.025), roughness=0.6), 0.02) signal_relic((0, -0.88, 1.38), metal, white, 0.55) body = cube("Cloth robot body", (0, 0, 1.05), (0.68, 0.48, 0.68), cloth, 0.14) head = cube("Robot head", (0, -0.02, 2.0), (0.55, 0.42, 0.42), cloth, 0.12) eye_l = sphere("Left eye", (-0.2, -0.43, 2.05), (0.08, 0.04, 0.08), white, 20) eye_r = sphere("Right eye", (0.2, -0.43, 2.05), (0.08, 0.04, 0.08), white, 20) seam_obj = torus("Head seam", (0, -0.02, 2.02), 0.48, 0.018, seam, rotation=(math.radians(90), 0, 0)) left_arm = cube("Robot left arm", (-0.95, 0, 1.12), (0.17, 0.17, 0.55), metal, 0.08) right_arm = cube("Robot tool arm", (0.95, 0, 1.12), (0.17, 0.17, 0.55), metal, 0.08) left_arm.rotation_euler[1] = math.radians(-12) right_arm.rotation_euler[1] = math.radians(18) left_arm.keyframe_insert(data_path="rotation_euler", frame=1) right_arm.keyframe_insert(data_path="rotation_euler", frame=1) left_arm.rotation_euler[1] = math.radians(10) right_arm.rotation_euler[1] = math.radians(-24) left_arm.keyframe_insert(data_path="rotation_euler", frame=FRAMES) right_arm.keyframe_insert(data_path="rotation_euler", frame=FRAMES) for x in (-0.35, 0.35): cube("Robot foot", (x, 0, 0.28), (0.22, 0.34, 0.2), metal, 0.06) cloth_flag = cube("Loose shoulder cloth", (0.7, -0.08, 1.55), (0.28, 0.04, 0.5), seam, 0.03) cloth_flag.rotation_euler[1] = math.radians(-18) cloth_flag.keyframe_insert(data_path="rotation_euler", frame=1) cloth_flag.rotation_euler[1] = math.radians(18) cloth_flag.keyframe_insert(data_path="rotation_euler", frame=FRAMES) shard("Loose cloth swatch A", (-1.45, -0.45, 0.78), (0.06, 0.32, 0.28), (math.radians(18), math.radians(12), math.radians(-30)), cloth) shard("Loose cloth swatch B", (1.45, -0.48, 0.65), (0.06, 0.25, 0.22), (math.radians(-22), math.radians(-12), math.radians(24)), seam) light("Robot key", "AREA", (-3.0, -3.5, 4.5), 1000, (1.0, 0.35, 0.22), 3.2) light("Robot rim", "AREA", (2.8, 1.5, 3.2), 900, (0.2, 0.5, 1.0), 2.4) cam = camera(scene, (3.1, -5.0, 2.2), (0, 0, 1.1), 55) animate_camera(cam, [(1, (3.1, -5.0, 2.2)), (FRAMES, (2.4, -5.3, 2.0))], (0, 0, 1.1)) SCENES = { "chrome-orbit": chrome_orbit, "memory-garden": memory_garden, "brutalist-archive": brutalist_archive, "cloth-robot": cloth_robot, } def render_case(case_id): scene = reset_scene() SCENES[case_id](scene) output_dir = os.path.join(OUT, case_id) os.makedirs(output_dir, exist_ok=True) scene.render.filepath = os.path.join(output_dir, "frame_") scene.render.image_settings.file_format = "PNG" scene.frame_set(1) bpy.ops.render.render(animation=True) print(f"Rendered {case_id} to {output_dir}") requested = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [] case_ids = [value for value in requested if value in SCENES] or list(SCENES) for case_id in case_ids: render_case(case_id)