diff --git a/design/channels/blender/build_3d_ident.py b/design/channels/blender/build_3d_ident.py index 7c1c7b5..080d337 100644 --- a/design/channels/blender/build_3d_ident.py +++ b/design/channels/blender/build_3d_ident.py @@ -670,6 +670,120 @@ def cinema(logo): data = bpy.data.lights.new(f'spot{x}', 'SPOT'); data.energy = 20000; data.spot_size = math.radians(60); data.spot_blend = .8; data.color = hexrgb('#FFD8C0')[:3] spot = bpy.data.objects.new(f'spot{x}', data); bpy.context.collection.objects.link(spot); spot.location = (x, -26, 15); spot.rotation_euler = (Vector((x * .6, 0, 6)) - Vector(spot.location)).to_track_quat('-Z', 'Y').to_euler() +def stone(name, colour, rough=.8, metal=0): + m, n, l = material(name); bsdf = n['Principled BSDF']; bsdf.inputs['Base Color'].default_value = hexrgb(colour); bsdf.inputs['Roughness'].default_value = rough; bsdf.inputs['Metallic'].default_value = metal + return m + +def spire(name, x, y, z, width, height, mat): + bpy.ops.mesh.primitive_cone_add(vertices=4, radius1=width * .71, radius2=0, depth=height, location=(x, y, z + height / 2), rotation=(0, 0, math.pi / 4)) + obj = bpy.context.active_object; obj.name = name; obj.data.materials.append(mat) + return obj + +def glowing(name, colour, strength): + m, n, l = material(name); out = n['Material Output']; n.remove(n['Principled BSDF']) + glow = n.new('ShaderNodeEmission'); glow.inputs['Color'].default_value = hexrgb(colour); glow.inputs['Strength'].default_value = strength; l.new(glow.outputs[0], out.inputs['Surface']) + return m + +def sky_world(stops): + """A plain sky graded from the horizon up, no stars.""" + scene.world = bpy.data.worlds.new('sky'); scene.world.use_nodes = True + bg = scene.world.node_tree.nodes['Background']; w = scene.world.node_tree; tex = w.nodes.new('ShaderNodeTexCoord'); sep = w.nodes.new('ShaderNodeSeparateXYZ'); w.links.new(tex.outputs['Generated'], sep.inputs[0]) + sky = ramp(w.nodes, stops); w.links.new(sep.outputs['Z'], sky.inputs['Fac']); w.links.new(sky.outputs['Color'], bg.inputs['Color']) + +def tower_bridge(y, mat, dark, steel): + """Tower Bridge: two towers with their turrets and roofs, the high walkway and the road, and the chains out to each bank.""" + window = glowing('window', '#FFC873', 3) + for side in (-1, 1): + x = side * 17 + box(f'pier{side}', x, y, 13, 11, 4, dark, -1) + box(f'tower{side}', x, y, 9.5, 8, 38, mat, 3) + for cx, cy in ((-4.4, -3.6), (4.4, -3.6), (-4.4, 3.6), (4.4, 3.6)): + box(f'turret{side}{cx}{cy}', x + cx, y + cy, 2.2, 2.2, 42, mat, 3) + spire(f'turretcap{side}{cx}{cy}', x + cx, y + cy, 45, 2.8, 6, dark) + spire(f'roof{side}', x, y, 41, 9, 14, dark) + for z in range(10, 38, 8): + box(f'towerwindow{side}{z}', x, y - 4.05, 2, .1, 3.5, window, z) + # The chains, swooping down to each bank. + curve = bpy.data.curves.new(f'chain{side}', 'CURVE'); curve.dimensions = '3D'; curve.bevel_depth = .35 + spline = curve.splines.new('POLY'); spline.points.add(14) + for i in range(15): + u = i / 14; spline.points[i].co = (x + side * 58 * u, y, 34 - 28 * math.sin(u * math.pi / 2) ** .8, 1) + chain = bpy.data.objects.new(f'chain{side}', curve); bpy.context.collection.objects.link(chain); curve.materials.append(steel) + box('walkway', 0, y, 26, 4.5, 3.4, steel, 30) + box('road', 0, y, 150, 9, 1.4, dark, 8) + box('roadlights', 0, y - 4.6, 150, .2, .3, glowing('roadglow', '#FFD27A', 4), 9.5) + +def big_ben(x, y, mat, dark, scale=1.0): + """The Elizabeth Tower, with its lit clock.""" + w = 11 * scale + box('bigbenshaft', x, y, w, w, 62 * scale, mat) + box('bigbenbelfry', x, y, w * 1.12, w * 1.12, 17 * scale, mat, 62 * scale) + box('bigbencap', x, y, w * 1.2, w * 1.2, 2 * scale, dark, 79 * scale) + spire('bigbenroof', x, y, 81 * scale, w * 1.1, 22 * scale, dark) + spire('bigbenspire', x, y, 94 * scale, w * .32, 22 * scale, mat) + face = glowing('clock', '#FFE9B8', 3.2); black = glowing('hands', '#090705', .02) + centre = (x, y - w * .561 - .02, 70.5 * scale) + r = w * .43 + bpy.ops.mesh.primitive_cylinder_add(vertices=64, radius=r, depth=.1, location=centre, rotation=(math.pi / 2, 0, 0)); bpy.context.active_object.data.materials.append(face) + for length, angle, thick in ((.56, 2.3, .07), (.86, -.5, .04)): + bpy.ops.mesh.primitive_cube_add(size=1, location=(centre[0] + math.sin(angle) * r * length / 2, centre[1] - .1, centre[2] + math.cos(angle) * r * length / 2), rotation=(0, angle, 0)) + hand = bpy.context.active_object; hand.scale = (r * thick * 2, .05, r * length); hand.data.materials.append(black) + +def gas_bands(n, l): + """Texture space squashed along the axis, so noise reads as a gas giant's bands.""" + squash = n.new('ShaderNodeVectorMath'); squash.operation = 'MULTIPLY'; squash.inputs[1].default_value = (.25, .25, 3.2) + l.new(n.new('ShaderNodeTexCoord').outputs['Object'], squash.inputs[0]) + return squash.outputs[0] + +def rock(name, seed, radius, mat): + """An asteroid, an icosphere knocked out of shape.""" + rng = random.Random(seed) + bpy.ops.mesh.primitive_ico_sphere_add(subdivisions=4, radius=radius); obj = bpy.context.active_object; obj.name = name + waves = [(Vector((rng.uniform(-1, 1), rng.uniform(-1, 1), rng.uniform(-1, 1))).normalized() * rng.uniform(1.5, 6), rng.uniform(0, 6), rng.uniform(.05, .17)) for _ in range(9)] + for v in obj.data.vertices: + d = v.co.normalized() + v.co *= 1 + sum(math.sin(d.dot(k) + p) * a for k, p, a in waves) + bpy.ops.object.shade_smooth(); obj.data.materials.append(mat) + return obj + +def case_board(): + """A detective's wall: photos and notes pinned up, joined by red string.""" + rng = random.Random(44) + box('wall', 0, .6, 220, 1, 90, stone('wall', '#3A3026', .9), -30) + card, shade, photo = stone('card', '#D8D0BC', .7), stone('faces', '#1A1816', .8), stone('photo', '#6E6A62', .6) + note = stone('note', '#E8C860', .8); pin = glowing('pin', '#FF2028', 3) + points = [] + for i, (x, z) in enumerate(((-52, 16), (-40, -4), (-26, 19), (-14, 1), (-1, 17), (9, -8), (15, 9), (27, 19), (31, -4), (41, 11), (-34, 8), (-6, -11), (22, -14), (49, -9), (-52, -12))): + w, h = rng.uniform(5.4, 7.6), rng.uniform(6.4, 8.4) + kind = card if i % 4 != 3 else note + tilt = rng.uniform(-.08, .08) + obj = box(f'card{i}', 0, 0, w, .1, h, kind, 0); obj.data.transform(__import__('mathutils').Matrix.Translation((0, 0, -h / 2))); obj.location = (x, 0, z); obj.rotation_euler = (0, tilt, 0) + if kind == card: + r = w * .17 + head = [(r * math.cos(a / 16 * math.tau), h * .1 + r * math.sin(a / 16 * math.tau)) for a in range(16)] + body = [(w * .36 * math.cos(a / 24 * math.pi), -h * .36 + h * .3 * math.sin(a / 24 * math.pi)) for a in range(25)] + flat(f'photo{i}', [[(-w * .42, -h * .36), (w * .42, -h * .36), (w * .42, h * .34), (-w * .42, h * .34)]], (x, -.07, z), (x, -50, z), photo).rotation_euler = (0, tilt, 0) + flat(f'face{i}', [head, body], (x, -.1, z), (x, -50, z), shade).rotation_euler = (0, tilt, 0) + ball = sphere(f'pin{i}', .42, 16); ball.location = (x, -.5, z + h * .42); ball.data.materials.append(pin) + points.append(ball.location.copy()) + curve = bpy.data.curves.new('strings', 'CURVE'); curve.dimensions = '3D'; curve.bevel_depth = .07 + for a, b in ((0, 2), (2, 4), (4, 7), (7, 9), (2, 3), (3, 5), (5, 6), (6, 7), (1, 10), (10, 2), (5, 11), (6, 8), (8, 12), (9, 13), (0, 14), (14, 1), (3, 11)): + spline = curve.splines.new('POLY'); spline.points.add(1) + spline.points[0].co = (*points[a], 1); spline.points[1].co = (*points[b], 1) + strings = bpy.data.objects.new('strings', curve); bpy.context.collection.objects.link(strings); curve.materials.append(stone('string', '#C81018', .6)) + # The lamps above, swinging a little, and low light through blinds from the side. + for i, x in enumerate((-30, 30)): + data = bpy.data.lights.new(f'lamp{i}', 'SPOT'); data.energy = 42000; data.spot_size = math.radians(85); data.spot_blend = .55; data.color = hexrgb('#FFD8A0')[:3]; data.shadow_soft_size = .4 + lamp = bpy.data.objects.new(f'lamp{i}', data); bpy.context.collection.objects.link(lamp); lamp.location = (x, -30, 30) + for frame, sway in ((1, -16), (88, 16), (175, -12), (262, 11), (350, -8)): + lamp.rotation_euler = (Vector((x + sway, 0, 2)) - Vector((x, -30, 30))).to_track_quat('-Z', 'Y').to_euler(); lamp.keyframe_insert('rotation_euler', frame=frame) + data = bpy.data.lights.new('blinds', 'SPOT'); data.energy = 500000; data.spot_size = math.radians(34); data.spot_blend = .1; data.color = hexrgb('#9FD8E8')[:3]; data.shadow_soft_size = .1 + light = bpy.data.objects.new('blinds', data); bpy.context.collection.objects.link(light); light.location = (-90, -60, 40) + light.rotation_euler = (Vector((30, 0, 6)) - Vector(light.location)).to_track_quat('-Z', 'Y').to_euler() + slat = stone('slat', '#000000') + for i in range(30): + box(f'slat{i}', -70, -50, 40, 1, .6, slat, 16 + i * 1.5).visible_camera = False + if SCENE == 'orbit-1': world_tint = ('#061A3C', '#0E3A6E') stars_world() @@ -764,6 +878,76 @@ elif SCENE == 'drive-in': cinema(str(__import__('pathlib').Path(__file__).resolve().parents[2] / 'drive-in' / 'drive_in_marquee.png')) # Settles in from the back of the house, then glides down the aisle towards the screen as it opens. camera_path([(1, (0, -40, 10), (0, 0, 7)), (120, (0, -36, 9.6), (0, 0, 7)), (350, (0, -16, 7.5), (0, 0, 7))], lens=30) +elif SCENE == 'orbit-2': + world_tint = ('#14082E', '#2C1460') + stars_world() + sun((.7, .62, -.2), 4.4, '#FFF0F4') + body, ring = planet([(.36, '#1E0A54'), (.46, '#4A22A8'), (.54, '#8A5AE0'), (.62, '#D2B8F8'), (.69, '#6A36C8'), (.78, '#28106A')], ('#C69CFF', '#6A3CC8')) + # A second ring for the two, wider and on its own tilt. + outer = ring.copy(); outer.data = ring.data.copy(); bpy.context.collection.objects.link(outer); outer.scale = (1.42, 1.42, 1); outer.rotation_euler = (math.radians(-24), math.radians(22), 0) + camera_path([(1, (34, -36, 12), (0, 0, 0)), (260, (-33, -88, -8), (-30, 0, 0)), (350, (-33, -88, -8), (-30, 0, 0))]) +elif SCENE == 'orbit-sci-fi': + world_tint = ('#04142C', '#0A3050') + stars_world() + sun((-.7, .5, -.35), 7, '#E8F6FF') + world = sphere('gas', 60); world.location = (70, 330, 10) + m, n, l = material('gas'); bsdf = n['Principled BSDF']; bsdf.inputs['Roughness'].default_value = .7 + tone = noise_ramp(n, l, gas_bands(n, l), .05, 9, [(.34, '#03202E'), (.48, '#0E7088'), (.6, '#58D6E0'), (.7, '#0A4A60')], .4) + l.new(tone.outputs['Color'], bsdf.inputs['Base Color']); world.data.materials.append(m) + glow_shell('gasglow', 61.5, '#6BE8FF', 2.4).location = world.location + grey, n, l = material('rock'); bsdf = n['Principled BSDF']; bsdf.inputs['Roughness'].default_value = .95 + coord = n.new('ShaderNodeTexCoord').outputs['Object'] + l.new(noise_ramp(n, l, coord, .9, 8, [(.3, '#2E2A28'), (.62, '#8A8278')]).outputs['Color'], bsdf.inputs['Base Color']) + crags = n.new('ShaderNodeTexNoise'); crags.inputs['Scale'].default_value = 1.6; crags.inputs['Detail'].default_value = 12; crags.inputs['Roughness'].default_value = .68; l.new(coord, crags.inputs['Vector']) + bump = n.new('ShaderNodeBump'); bump.inputs['Strength'].default_value = 1; bump.inputs['Distance'].default_value = 1.2; l.new(crags.outputs['Fac'], bump.inputs['Height']); l.new(bump.outputs[0], bsdf.inputs['Normal']) + rng = random.Random(9) + for i in range(70): + x, y, z = rng.uniform(-60, 75), rng.uniform(-40, 260), rng.uniform(-30, 30) + if abs(x) < 9 and abs(z) < 8: x += 18 if x > 0 else -18 + lump = rock(f'rock{i}', i, rng.uniform(1.5, 8), grey); lump.location = (x, y, z); lump.rotation_euler = (rng.random() * 6, rng.random() * 6, 0) + spin(lump, 'z', rng.uniform(.03, .1) * rng.choice((-1, 1))) + camera_path([(1, (0, -70, 0), (4, 40, 0)), (280, (-16, 96, 4), (-4, 330, 8)), (350, (-17, 102, 4), (-4, 330, 8))], lens=30) +elif SCENE == 'orbit-gold': + world_tint = ('#120C04', '#2A1C08') + stars_world(.5, '#FFE2A0') + sun((.72, .62, -.2), 8, '#FFE6B4') + body, ring = planet([(.36, '#7A4E10'), (.46, '#C88A24'), (.54, '#F0C050'), (.62, '#FFE6A0'), (.69, '#C08020'), (.78, '#6A4210')], ('#FFD060', '#A87420')) + gilt = body.data.materials[0].node_tree.nodes['Principled BSDF']; gilt.inputs['Metallic'].default_value = .55; gilt.inputs['Roughness'].default_value = .42 + # Gold dust drifting through it all. + rng = random.Random(21) + bpy.ops.mesh.primitive_ico_sphere_add(subdivisions=1, radius=.09); seed = bpy.context.active_object; seed.data.materials.append(glowing('dust', '#FFCB5A', 5)) + for i in range(420): + speck = seed.copy(); bpy.context.collection.objects.link(speck); speck.location = (rng.uniform(-70, 40), rng.uniform(-40, 60), rng.uniform(-30, 30)); speck.scale = (rng.uniform(.5, 2.2),) * 3 + speck.keyframe_insert('location', frame=1); speck.location.z += rng.uniform(2, 7); speck.location.x += rng.uniform(-3, 3); speck.keyframe_insert('location', frame=350) + camera_path([(1, (34, -46, -14), (6, 0, 0)), (260, (-27, -78, 8), (-24, 0, 1)), (350, (-27, -78, 8), (-24, 0, 1))]) +elif SCENE == 'evidence': + scene.world = bpy.data.worlds.new('dark'); scene.world.use_nodes = True; scene.world.node_tree.nodes['Background'].inputs['Color'].default_value = (.002, .002, .003, 1) + case_board() + # Slides along the wall, close in on the photos, and backs off to the end of the trail. + camera_path([(1, (-46, -30, 8), (-42, 0, 6)), (150, (-12, -44, 6), (-6, 0, 5)), (280, (18, -58, 3), (18, 0, 3)), (350, (18, -60, 3), (18, 0, 3))], lens=32) +elif SCENE == 'orbit-britx': + sky_world([(0, '#E89850'), (.1, '#B05A58'), (.24, '#3C3268'), (.5, '#0C1230')]) + sun((.42, .82, -.38), 3.2, '#FFC890', 2) + water, n, l = material('thames'); bsdf = n['Principled BSDF']; bsdf.inputs['Base Color'].default_value = hexrgb('#0A1420'); bsdf.inputs['Roughness'].default_value = .16; bsdf.inputs['Metallic'].default_value = .3 + ripple = n.new('ShaderNodeTexNoise'); ripple.inputs['Scale'].default_value = .5; ripple.inputs['Detail'].default_value = 6 + tiny = n.new('ShaderNodeBump'); tiny.inputs['Strength'].default_value = .16; l.new(ripple.outputs['Fac'], tiny.inputs['Height']); l.new(tiny.outputs[0], bsdf.inputs['Normal']) + box('river', 0, 300, 900, 1200, .1, water, -.1) + lime, slate, steel = stone('portland', '#C8B08A', .7), stone('slate', '#10141C', .6), stone('steel', '#22384E', .35, .8) + tower_bridge(70, lime, slate, steel) + big_ben(30, 262, lime, slate) + # Parliament running back along the bank from the clock, and the city's lights on the far side. + box('parliament', 42, 345, 26, 150, 24, window_wall('#C8B08A', 2, ('#FFD27A', '#FFC873', '#FFE0A0'), .55, (2.2, 4)), 0) + for y in range(275, 420, 12): + spire(f'pinnacle{y}', 30, y, 24, 2.4, 9, slate) + wall = window_wall('#1A2030', 1.4, ('#FFC873', '#FFD99A', '#9AC8FF'), .2, (1.3, 2)) + rng = random.Random(5) + for y in range(-60, 520, 26): + h = rng.uniform(8, 34) + box(f'leftbank{y}', -96 - rng.uniform(0, 26), y, rng.uniform(22, 40), 22, h, wall) + if y < 200: box(f'rightbank{y}', 96 + rng.uniform(0, 26), y, rng.uniform(22, 40), 22, h * .9, wall) + box('southbank', -160, 300, 170, 800, 3, slate); box('northbank', 180, 300, 230, 800, 3, slate) + # Skims the water, climbs over the walkway of the bridge, and comes down the river to Big Ben. + camera_path([(1, (-6, -60, 7), (0, 70, 24)), (130, (-2, 62, 44), (10, 200, 42)), (285, (-32, 112, 30), (-8, 262, 58)), (350, (-32, 116, 30), (-8, 262, 58))], lens=30) else: raise SystemExit('unknown scene') diff --git a/design/drive-in/build-fanfare.py b/design/drive-in/build-fanfare.py new file mode 100644 index 0000000..2a80b8a --- /dev/null +++ b/design/drive-in/build-fanfare.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""An original curtain-up fanfare for the Drive-In ident, written and synthesised here, so there's no licence to clear. +The room hushes, the drapes swish as they part, and brass builds to a full chord as the logo fills the screen. + build-fanfare.py +""" +import math, random, struct, sys, wave +RATE, LENGTH = 32000, 7.0 +SIZE = int(RATE * LENGTH) +OPEN, HIT = 2.36, 5.2 +rng = random.Random(7) +left, right = [0.0] * SIZE, [0.0] * SIZE + +def add(i, value, pan=0.0): + left[i] += value * (1 - pan) * .5 + right[i] += value * (1 + pan) * .5 + +def smooth(x): return 0.0 if x <= 0 else 1.0 if x >= 1 else x * x * (3 - 2 * x) + +def freq(note): return 440 * 2 ** ((note - 69) / 12) + +# Brass: detuned saws, entering from the bottom up, opening out as they swell. +CHORD = (39, 46, 51, 55, 58, 63, 67, 70, 75, 79) +for order, note in enumerate(CHORD): + enter = 1.5 + order * .3 + for detune in (-.09, 0, .08): + f, pan = freq(note + detune), rng.uniform(-.7, .7) + phase, low = rng.random(), 0.0 + for i in range(int(enter * RATE), SIZE): + t = i / RATE + phase = (phase + f / RATE) % 1 + # Quiet on entry, then building to the hit. + level = smooth((t - enter) / 1.4) * (.14 + .86 * smooth((t - 3.2) / (HIT - 3.2))) * (1 - smooth((t - 6.2) / .8)) + cutoff = .03 + .32 * smooth((t - 2.6) / (HIT - 2.6)) + low += ((2 * phase - 1) - low) * cutoff + add(i, low * level * .075, pan) + +# The drapes: noise, shaped like heavy cloth pulled across a stage. +low, band = 0.0, 0.0 +for i in range(int(OPEN * RATE), int((OPEN + 3.2) * RATE)): + t = i / RATE - OPEN + noise = rng.uniform(-1, 1) + low += (noise - low) * .09 + band += (low - band) * .012 + level = smooth(t / .9) * (1 - smooth((t - 1.8) / 1.4)) * (.72 + .28 * math.sin(t * 7.1) * math.sin(t * 2.3)) + add(i, (low - band) * level * .85, math.sin(t * 1.1) * .5) + +# Timpani rolling up to the hit, then the hit itself. +def drum(start, loud): + for i in range(int(start * RATE), min(SIZE, int((start + 1.4) * RATE))): + t = i / RATE - start + add(i, math.sin(math.tau * (62 + 22 * math.exp(-t * 26)) * t) * math.exp(-t * 3.6) * loud) +t = 3.5 +while t < HIT - .02: + drum(t, .05 + .2 * smooth((t - 3.5) / (HIT - 3.5))) + t += .085 +drum(HIT, .5) + +# A cymbal swell that breaks on the hit. +high = 0.0 +for i in range(int(3.7 * RATE), SIZE): + t = i / RATE + noise = rng.uniform(-1, 1); high += (noise - high) * .4 + swell = smooth((t - 3.7) / (HIT - 3.7)) ** 2 if t < HIT else math.exp(-(t - HIT) * 1.7) + add(i, (noise - high) * swell * .1, .2) + +# The size of the room. +for channel, delays in ((left, (1499, 2111, 2789, 3253)), (right, (1613, 2239, 2903, 3389))): + wet = [0.0] * SIZE + for delay in delays: + line = [0.0] * delay + for i in range(SIZE): + out = line[i % delay] + line[i % delay] = channel[i] + out * .72 + wet[i] += out * .17 + for i in range(SIZE): channel[i] += wet[i] + +peak = max(max(abs(a), abs(b)) for a, b in zip(left, right)) +gain = .89 / peak +with wave.open(sys.argv[1], 'wb') as out: + out.setnchannels(2); out.setsampwidth(2); out.setframerate(RATE) + frames = bytearray() + for i in range(SIZE): + fade = 1 - smooth((i / RATE - 6.7) / .3) + frames += struct.pack('