From 83f83590e935933875f4475341af5a4debf0cd41 Mon Sep 17 00:00:00 2001 From: Pauljd Date: Tue, 3 Nov 2026 07:51:52 +0000 Subject: [PATCH] Blender scenes for Ring X, Wonder, PixelZ and Junior, CPU test renders, and resuming a stopped render Co-Authored-By: Claude Opus 5.5 (1M context) --- design/channels/blender/build_3d_ident.py | 193 +++++++++++++++++++++- 1 file changed, 191 insertions(+), 2 deletions(-) diff --git a/design/channels/blender/build_3d_ident.py b/design/channels/blender/build_3d_ident.py index 1d47265..bb7c9a5 100644 --- a/design/channels/blender/build_3d_ident.py +++ b/design/channels/blender/build_3d_ident.py @@ -5,7 +5,7 @@ With frames, renders just those as stills to look at. Without, renders the whole 7 seconds as images. The logo goes on afterwards, in compose_ident.sh, so it stays perfectly sharp. """ -import bpy, math, random, sys +import bpy, math, os, random, sys from mathutils import Vector args = sys.argv[sys.argv.index('--') + 1:] @@ -19,7 +19,8 @@ prefs = bpy.context.preferences.addons['cycles'].preferences prefs.compute_device_type = 'OPTIX' prefs.get_devices() for device in prefs.devices: device.use = device.type == 'OPTIX' -scene.cycles.device = 'GPU' +# CPU=1 renders on the processor, for test stills while the graphics card is busy with a film. +scene.cycles.device = 'CPU' if os.environ.get('CPU') else 'GPU' scene.cycles.samples = 48 scene.cycles.use_denoising = True scene.cycles.denoiser = 'OPTIX' @@ -946,6 +947,184 @@ def petals(count, seed, area): one.location = (x, y, z); one.rotation_euler = (rng.random() * 6, rng.random() * 6, rng.random() * 6); one.keyframe_insert('location', frame=1); one.keyframe_insert('rotation_euler', frame=1) one.location = (x + rng.uniform(1, 6), y + rng.uniform(-2, 2), z - rng.uniform(10, 22)); one.rotation_euler = (rng.random() * 12, rng.random() * 12, rng.random() * 12); one.keyframe_insert('location', frame=350); one.keyframe_insert('rotation_euler', frame=350) +def aim(obj, target, frame): + """Points a beam's tip at [target], keyed at [frame].""" + obj.rotation_euler = (Vector(target) - obj.location).to_track_quat('Z', 'Y').to_euler(); obj.keyframe_insert('rotation_euler', frame=frame) + +def fall_in(obj, frame, drop, settle=14): + """Drops from [drop] above into place, landing at [frame] with a small bounce.""" + rest = obj.location.copy() + obj.location = rest + Vector((0, 0, drop)); obj.keyframe_insert('location', frame=1); obj.keyframe_insert('location', frame=max(2, frame - 24)) + obj.location = rest; obj.keyframe_insert('location', frame=frame) + obj.location = rest + Vector((0, 0, drop * .04)); obj.keyframe_insert('location', frame=frame + settle // 2) + obj.location = rest; obj.keyframe_insert('location', frame=frame + settle) + +def wrestling_ring(at, mat_canvas, mat_apron, mat_post, mat_rope, mat_pad): + x, y = at + box('apron', x, y, 12.8, 12.8, 1.55, mat_apron); box('canvas', x, y, 12, 12, 1.6, mat_canvas) + corners = [(x + a * 6, y + b * 6) for a, b in ((-1, -1), (1, -1), (1, 1), (-1, 1))] + for i, (cx, cy) in enumerate(corners): + box(f'post{i}', cx, cy, .36, .36, 4.6, mat_post) + for j, z in enumerate((2.6, 3.4, 4.2)): box(f'pad{i}{j}', cx, cy, .6, .6, .32, mat_pad, z - .16) + for i in range(4): + (ax, ay), (bx, by) = corners[i], corners[(i + 1) % 4] + for j, z in enumerate((2.6, 3.4, 4.2)): rope(f'rope{i}{j}', (ax, ay, z), (bx, by, z), mat_rope, sag=.06) + +def stands(centre, inner, rows, mat, seed): + """Tiered seating all round, dark, with the odd phone light in the crowd.""" + rng = random.Random(seed); x0, y0 = centre + lights = glowing('phones', '#DDE6FF', 6) + for r in range(rows): + reach = inner + r * 2.4 + for side, (dx, dy, w, d) in enumerate(((0, -1, 2, 0), (-1, 0, 0, 2), (1, 0, 0, 2))): + box(f'tier{r}{side}', x0 + dx * reach, y0 + dy * reach, (reach * 2 if w else 2.4), (reach * 2 if d else 2.4), 1.2 + r * 1.1, mat) + bpy.ops.mesh.primitive_ico_sphere_add(subdivisions=1, radius=.045); first = bpy.context.active_object; first.data.materials.append(lights) + for i in range(260): + one = first if i == 0 else clone(first) + r = rng.randrange(rows); reach = inner + r * 2.4 + rng.uniform(-.8, .8); side = rng.choice((0, 2, 3)); along = rng.uniform(-reach, reach) + px, py = (x0 + along, y0 + (reach if side else -reach)) if side < 2 else (x0 + (reach if side == 3 else -reach), y0 + along) + one.location = (px, py, 1.4 + r * 1.1 + rng.uniform(.2, 1.4)) + +def ring_x(): + """A wrestling arena: the ring under the lights, the crowd in the dark, and the entrance stage with its red X.""" + scene.world = bpy.data.worlds.new('arena'); scene.world.use_nodes = True; scene.world.node_tree.nodes['Background'].inputs['Color'].default_value = (.002, .002, .004, 1) + floor = stone('floor', '#08080A', .5) + box('floor', 0, 40, 400, 400, .1, floor, -.1) + wrestling_ring((14, 40), stone('canvas', '#D4D6DC', .7), stone('apron', '#6A0810', .6), stone('steel', '#9EA2AA', .25, .9), stone('rope', '#E01428', .35), stone('pad', '#1A1A20', .5)) + stands((14, 40), 15, 9, stone('seats', '#0C0C12', .9), 3) + # The entrance: a stage and ramp, a red X of light behind it, and sparks going up as the camera passes. + box('stage', 44, 92, 30, 8, 1.2, stone('stage', '#121218', .4, .3)); box('ramp', 44, 72, 4, 32, .9, stone('ramp', '#16161E', .4, .3)) + red = glowing('xlight', '#FF1A30', 9) + # The entrance glows for the walk in, then dims so the ring has the light. + level = red.node_tree.nodes['Emission'].inputs['Strength'] + for frame, value in ((1, 9), (150, 9), (230, .2), (350, .2)): level.default_value = value; level.keyframe_insert('default_value', frame=frame) + for i, lean in enumerate((1, -1)): + bar = flat(f'xbar{i}', [[(-1.4, -13), (1.4, -13), (1.4, 13), (-1.4, 13)]], (44, 97, 15), (14, -50, 15), red); bar.rotation_euler.y = math.radians(36 * lean) + # Gone once the lights are down, so it never sits behind the logo. + bar.hide_render = False; bar.keyframe_insert('hide_render', frame=1); bar.hide_render = True; bar.keyframe_insert('hide_render', frame=232) + for i, x in enumerate((32, 56)): + sparks((x, 93, 1.2), 30 + i * 10, 90, 10 + i, 16); sparks((x, 93, 1.2), 190 + i * 10, 90, 20 + i, 16) + # The rig over the ring, and its spotlights hunting round the canvas. + rig = stone('rig', '#2A2C32', .4, .8) + for i, (dx, dy, w, d) in enumerate(((0, -9, 18, .5), (0, 9, 18, .5), (-9, 0, .5, 18), (9, 0, .5, 18))): + box(f'truss{i}', 14 + dx, 40 + dy, w, d, .5, rig, 26) + for i, (dx, dy) in enumerate(((-9, -9), (9, -9), (9, 9), (-9, 9))): + shaft = beam(f'spot{i}', (14 + dx, 40 + dy, 26), 30, 5, '#FFF2E0', .5, narrow=.12) + shaft.rotation_mode = 'XYZ' + for frame, (tx, ty) in ((1, (dx * .2, dy * -.6)), (120, (dx * -.5, dy * .3)), (240, (dx * .4, dy * .4)), (350, (dx * -.1, dy * -.1))): + aim(shaft, (14 + tx, 40 + ty, -30), frame) + data = bpy.data.lights.new('ringlight', 'SPOT'); data.energy = 260000; data.spot_size = math.radians(40); data.spot_blend = .5; data.color = hexrgb('#FFF4E8')[:3] + top = bpy.data.objects.new('ringlight', data); bpy.context.collection.objects.link(top); top.location = (14, 40, 40); top.rotation_euler = (0, 0, 0) + for i, x in enumerate((-60, 90)): + data = bpy.data.lights.new(f'wash{i}', 'SPOT'); data.energy = 160000; data.spot_size = math.radians(30); data.color = hexrgb('#FF2438')[:3] + wash = bpy.data.objects.new(f'wash{i}', data); bpy.context.collection.objects.link(wash); wash.location = (x, 60, 30); wash.rotation_euler = (Vector((14, 40, 0)) - wash.location).to_track_quat('-Z', 'Y').to_euler() + camera_path([(1, (18, 112, 5), (14, 40, 3)), (120, (2, 70, 7), (12, 40, 3)), (285, (-7, 22, 7), (3, 43, 4)), (350, (-7, 21, 7), (3, 43, 4))], lens=28) + +def fireflies(count, seed, area): + rng = random.Random(seed) + bpy.ops.mesh.primitive_ico_sphere_add(subdivisions=2, radius=.1); first = bpy.context.active_object; first.data.materials.append(glowing('firefly', '#FFC24A', 22)) + for i in range(count): + one = first if i == 0 else clone(first) + x, y, z = rng.uniform(*area[0]), rng.uniform(*area[1]), rng.uniform(*area[2]) + for frame in (1, 120, 240, 350): + one.location = (x + rng.uniform(-1.4, 1.4), y + rng.uniform(-1.4, 1.4), z + rng.uniform(-.8, .8)); one.keyframe_insert('location', frame=frame) + +def wonder(): + """A storybook night: hills, fireflies, and a castle with an observatory dome, its windows lit, under a big soft moon.""" + global world_tint + world_tint = ('#101640', '#26306A') + stars_world(.8) + sun((.5, .6, -.5), 1.6, '#9AB4FF') + hills = stone('hills', '#0E1C30', 1) + rng = random.Random(7) + for i in range(14): + bump = sphere(f'hill{i}', 1, 32); bump.data.materials.append(hills); bump.location = (-300 + i * 50, 240 + rng.uniform(-40, 40), -4); bump.scale = (rng.uniform(50, 90), 50, rng.uniform(14, 36)) + near = sphere('rise', 1, 64); near.data.materials.append(stone('grass', '#16301E', 1)); near.location = (60, 150, -30); near.scale = (70, 60, 52) + ground('meadow', '#0C1A14', '#14261C', y=200) + wall, roof, lit = stone('castle', '#B8B4D4', .8), stone('roof', '#3A3A8A', .5), glowing('window', '#FFC870', 7) + top = 22 + for i, (x, y, r, h) in enumerate(((46, 138, 4.6, 34), (74, 138, 4.6, 30), (60, 150, 6.2, 44), (50, 160, 3.6, 24), (70, 162, 3.6, 26))): + bpy.ops.mesh.primitive_cylinder_add(vertices=40, radius=r, depth=h, location=(x, y, top + h / 2)); bpy.context.active_object.data.materials.append(wall) + bpy.ops.mesh.primitive_cone_add(vertices=40, radius1=r * 1.25, radius2=0, depth=r * 2.6, location=(x, y, top + h + r * 1.3)); bpy.context.active_object.data.materials.append(roof) + for k in range(int(h // 5)): + box(f'win{i}{k}', x, y - r - .02, 1.3, .2, 2.2, lit, top + 3 + k * 5) + box('keep', 60, 146, 30, 12, 20, wall, top) + for k in range(6): box(f'keepwin{k}', 49 + k * 4.4, 139.9, 1.4, .2, 2.4, lit, top + 8) + dome = sphere('dome', 7, 64); dome.location = (60, 150, top + 44 + 2); dome.data.materials.append(stone('gold', '#E8B850', .25, 1)) + bpy.ops.mesh.primitive_cylinder_add(vertices=24, radius=.6, depth=9, location=(56, 150, top + 50), rotation=(0, math.radians(-50), 0)); bpy.context.active_object.data.materials.append(stone('brass', '#C89A40', .3, 1)) + flare((96, 300, 120), 30, '#F4F6FF', 4) + # A path of little lights winding up the hill to the gate. + path_light = glowing('pathlight', '#FFD890', 5) + for k in range(40): + u = k / 39; bpy.ops.mesh.primitive_ico_sphere_add(subdivisions=1, radius=.22, location=(10 + 50 * u + 8 * math.sin(u * 7), 40 + 100 * u, .3 + 21 * u ** 1.6)); bpy.context.active_object.data.materials.append(path_light) + fireflies(220, 5, ((-30, 60), (-30, 80), (.5, 12))) + camera_path([(1, (-6, -40, 2), (20, 120, 8)), (285, (-16, 20, 10), (10, 150, 34)), (350, (-16, 22, 10), (10, 150, 34))], lens=30) + +def pixel_world(): + """A block world that drops into place, column by column, with gems spinning over it at sunset.""" + sky_world([(0, '#FF9AD0'), (.1, '#B55AE8'), (.3, '#4A2AA8'), (.55, '#120E40')]) + sun((.3, .7, -.6), 4.5, '#FFE0F0') + flare((330, 400, 60), 30, '#FFB0E0', 2.6) + ground('void', '#120E30', '#1A1440') + m, n, l = material('column'); bsdf = n['Principled BSDF']; bsdf.inputs['Roughness'].default_value = .7 + up = n.new('ShaderNodeTexCoord'); part = n.new('ShaderNodeSeparateXYZ'); l.new(up.outputs['Generated'], part.inputs[0]) + layers = ramp(n, [(0, '#7A5038'), (.8, '#9A6644'), (.86, '#9AF04A'), (1, '#C8FF6A')]); layers.color_ramp.interpolation = 'CONSTANT'; l.new(part.outputs['Z'], layers.inputs['Fac']); l.new(layers.outputs['Color'], bsdf.inputs['Base Color']) + water = stone('water', '#24C8FF', .1); water.node_tree.nodes['Principled BSDF'].inputs['Emission Color'].default_value = hexrgb('#24C8FF'); water.node_tree.nodes['Principled BSDF'].inputs['Emission Strength'].default_value = .5 + rng = random.Random(9) + size = 2.0 + for gx in range(-6, 30): + for gy in range(0, 30): + x, y = gx * size, 20 + gy * size + h = 1 + int(3.2 + 2.6 * math.sin(gx * .45) + 2.2 * math.cos(gy * .38 + gx * .12) + rng.random()) + if h < 3: + box(f'w{gx}_{gy}', x, y, size, size, 2.4, water); continue + column = box(f'c{gx}_{gy}', x, y, size, size, h * size * .5, m) + land = 30 + int(math.hypot(gx - 12, gy - 2) * 7) + rng.randint(0, 8) + for frame, height in ((1, .001), (land - 14, .001), (land, 1.12), (land + 8, 1)): column.scale.z = height; column.keyframe_insert('scale', index=2, frame=frame) + for i, (x, y, z, colour) in enumerate(((22, 40, 16, '#FF4AE0'), (36, 56, 20, '#4AE8FF'), (12, 66, 18, '#B8FF4A'), (48, 34, 14, '#FFB84A'))): + gem = sphere(f'gem{i}', 1.2, 4); gem.location = (x, y, z); gem.rotation_euler = (.6, .6, 0); gem.data.materials.append(glowing(f'gem{i}', colour, 5)); spin(gem, 'z', 1.5) + gem.scale = (0, 0, 0); gem.keyframe_insert('scale', frame=150 + i * 12); gem.scale = (1, 1, 1); gem.keyframe_insert('scale', frame=170 + i * 12) + camera_path([(1, (-26, -24, 30), (14, 50, 0)), (285, (-22, -4, 18), (4, 56, 6)), (350, (-22, -2, 18), (4, 56, 6))], lens=30) + +def toy_blocks(at, seed): + """A tower of chunky toy blocks, dropped in one at a time.""" + rng = random.Random(seed); x0, y0 = at + colours = ('#FF4A4A', '#FFC93A', '#3AB8FF', '#5AD46A', '#FF8A3D', '#B47DFF') + mats = [stone(f'block{i}', c, .35) for i, c in enumerate(colours)] + count = 0 + for level, (n_blocks, spread) in enumerate(((3, 3.2), (2, 1.6), (1, 0))): + for k in range(n_blocks): + x = x0 + (k - (n_blocks - 1) / 2) * 4 + rng.uniform(-.2, .2) + block = box(f'toy{level}{k}', x, y0 + rng.uniform(-.3, .3), 3, 3, 3, mats[count % 6], level * 3) + block.scale = (1.25, 1.25, 1.25); block.location.z *= 1.25 + bevel = block.modifiers.new('round', 'BEVEL'); bevel.width = .35; bevel.segments = 4 + block.rotation_euler.z = rng.uniform(-.2, .2) + fall_in(block, 60 + count * 22, 30) + count += 1 + +def junior(): + """A sunny toy land: green hills, a rainbow, fluffy clouds, and a tower of building blocks stacking itself.""" + sky_world([(0, '#EAF8FF'), (.12, '#9AD8FF'), (.4, '#3A9AF0')]) + sun((-.4, .6, -.6), 3.4, '#FFF2D8') + flare((150, 420, 110), 26, '#FFF0B0', 3) + ground('lawn', '#5CC04C', '#72D45A') + rng = random.Random(11) + for i in range(12): + bump = sphere(f'hill{i}', 1, 48); bump.data.materials.append(stone(f'hillc{i}', ('#5CC04C', '#4CAE44', '#6CCB5A')[i % 3], .9)); bump.location = (-200 + i * 40, 180 + rng.uniform(-30, 40), -6); bump.scale = (rng.uniform(34, 60), 34, rng.uniform(14, 30)) + for i, colour in enumerate(('#FF4A4A', '#FF8A3D', '#FFD93A', '#5AD46A', '#3AB8FF', '#9A6AFF')): + bpy.ops.mesh.primitive_torus_add(major_radius=48 - i * 1.6, minor_radius=.85, major_segments=160, minor_segments=12, location=(56, 150, 0), rotation=(math.pi / 2, 0, 0)) + arc = bpy.context.active_object; arc.data.materials.append(glowing(f'rainbow{i}', colour, 1.4)); arc.visible_shadow = False + cloud = stone('cloud', '#FFFFFF', 1) + for c, (x, y, z) in enumerate(((-30, 120, 40), (20, 140, 52), (70, 110, 46), (110, 160, 36))): + for k in range(6): + puff = sphere(f'puff{c}{k}', 1, 32); puff.data.materials.append(cloud); r = rng.uniform(4, 7) + puff.scale = (r, r * .8, r * .8); puff.location = (x + k * 4 - 10, y, z + rng.uniform(-1.5, 1.5) - abs(k - 2.5)) + puff.keyframe_insert('location', frame=1); puff.location.x += 8; puff.keyframe_insert('location', frame=350) + toy_blocks((13, 26), 2) + for i, (x, y) in enumerate(((10, 24), (34, 22), (16, 40))): + ball = sphere(f'ball{i}', 1.3, 48); ball.location = (x, y, 1.3); ball.data.materials.append(stone(f'ballc{i}', ('#FF4A4A', '#3AB8FF', '#FFC93A')[i], .25)) + camera_path([(1, (-6, -40, 3), (20, 60, 8)), (285, (-16, -12, 6), (8, 60, 10)), (350, (-16, -10, 6), (8, 60, 10))], lens=30) + def archive(): """A long, dark records room: filing cabinets each side, strip lights failing overhead.""" steel = stone('cabinet', '#3A4048', .45, .7); front = stone('drawer', '#4A5058', .5, .6); handle = stone('handle', '#B8BCC0', .3, 1); label = stone('label', '#D8D0B8', .8); red = glowing('stamp', '#C81828', 1.4) @@ -1249,6 +1428,14 @@ elif SCENE == 'orbit-detour': desert_road() flare((170, 980, 26), 30, '#FFD27A', 3) camera_path([(1, (0, -60, 2.2), (0, 60, 3)), (285, (-3, 116, 3.2), (6, 240, 15)), (350, (-3, 120, 3.2), (6, 240, 15))], lens=30) +elif SCENE == 'orbit-ring-x': + ring_x() +elif SCENE == 'wonder': + wonder() +elif SCENE == 'orbit-pixelz': + pixel_world() +elif SCENE == 'orbit-junior': + junior() else: raise SystemExit('unknown scene') @@ -1259,4 +1446,6 @@ if FRAMES: bpy.ops.render.render(write_still=True) else: scene.render.filepath = f'{OUT}/frame_' + # START_FRAME picks up a render that stopped part way, keeping the frames already made. + scene.frame_start = int(os.environ.get('START_FRAME', scene.frame_start)) bpy.ops.render.render(animation=True)