# -*- coding: utf-8 -*- """DOMINO - a 5 mm tile, each one 1.5 times bigger than the previous. Real part: 13 tiles (5 mm to 65 cm) simulated in Godot (Jolt), at real scale. Imagined part: tiles 14 to 36 standing, compared with a person, the Obelisco, the Empire State and the Aconcagua. The heights are exact: h(n) = 5 mm * 1.5^(n-1) MODE=sim blender -b -P blender/domino.py # exports and runs Godot TEST=1 (with MODE=sim) simulates without a timeline, to tune the spacing blender -b -P blender/domino.py # renders """ import math, os, sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import bpy from base import * NAME_KEY = "domino" H0 = 0.005 # the first tile R = 1.5 # each one 1.5 times the previous N_REAL = 13 N_TOTAL = 36 WIDTH, THICKNESS = 0.50, float(env("T_THICK", None, 0.10)) # tile proportions relative to its height SEP = float(os.environ.get("T_SEP", 0.70)) # gap between tiles, in heights of the previous one FRICTION = float(env("T_FRIC", None, 0.7)) FPS_SIM = 240 # recorded at 240 fps: allows x8 slow motion def alto(n): return H0 * R ** (n - 1) def positions(n_max=N_TOTAL): """Centre x of each tile (1-based: xs[1] is the first).""" xs = [0.0, 0.0] for n in range(1, n_max): h, h2 = alto(n), alto(n + 1) xs.append(xs[-1] + THICKNESS * h / 2 + SEP * h + THICKNESS * h2 / 2) return xs def build_scene(): scene_setup(samples=int(env("SAMPLES", "MUESTRAS", 24))) studio_world(force=0.6) xs = positions() M = { "ficha": material("ficha", "#15171C", rough=0.18), "punto": material("punto", "#F2F2F2", rough=0.3), "piso": madera("piso", light_c="#8C6239", dark="#4A2E18", scale_to=3.0, rough=0.75, grain=(0.5, 1, 1)), "inv": material("inv", "gris"), } tile_list = {} for n in range(1, N_TOTAL + 1): h = alto(n) obj_name = f"block_d{n:02d}" if n <= N_REAL else f"ficha_{n:02d}" ob = box_obj(obj_name, (THICKNESS * h, WIDTH * h, h), (xs[n], 0, h / 2), M["ficha"]) # the domino pips, on the face looking at the camera (-X) and the opposite one for side in (-1, 1): for (py, pz) in pips(n): p = sphere(f"pip_{n}_{side}_{py}_{pz}", 0.055 * h, M["punto"], seg_m=12, rings=6) p.scale = (0.35, 1, 1) p.location = (side * THICKNESS * h / 2, py * h, pz * h) p.parent = ob stripe = box_obj(f"raya_{n}", (THICKNESS * h * 1.02, WIDTH * h * 0.8, 0.012 * h), (0, 0, 0), M["punto"]) stripe.parent = ob tile_list[n] = ob tilt(tile_list[1], xs[1], INCL) floor_obj = box_obj("box_piso", (4.0, 3.0, 0.2), (1.0, 0, -0.1), M["inv"]) floor_obj.hide_render = True return dict(tile_list=tile_list, xs=xs, M=M, floor_obj=floor_obj) INCL = math.radians(12.0) # the first one starts past its tipping angle (9 degrees) def tilt(ob, x, ang): """Rotates the tile about its lower front edge (the one facing +X).""" h = ob.dimensions.z t = ob.dimensions.x c, s_ = math.cos(ang), math.sin(ang) ob.rotation_euler = (0, ang, 0) ob.location = (x + t / 2 - c * t / 2 + s_ * h / 2, 0, s_ * t / 2 + c * h / 2) def pips(n): """Pips (y, z relative to the height) of a domino: two halves with 0..6.""" pat = {0: [], 1: [(0, 0)], 2: [(-1, 1), (1, -1)], 3: [(-1, 1), (0, 0), (1, -1)], 4: [(-1, 1), (1, 1), (-1, -1), (1, -1)], 5: [(-1, 1), (1, 1), (0, 0), (-1, -1), (1, -1)], 6: [(-1, 1), (1, 1), (-1, 0), (1, 0), (-1, -1), (1, -1)]} a, b = (n * 5) % 7, (n * 3 + 2) % 7 out = [] for (cy, cz), val in (((0, 0.25), a), ((0, -0.25), b)): for (dy, dz) in pat[val]: out.append((cy + dy * 0.13, cz + dz * 0.13)) return out def frame_release(T): """The pencil pushes it at the end of beat 3 ("le doy un toquecito...").""" a, z = T.span(3) return a + int(0.78 * (z - a)) def simulate(t_release, duration, obj): real_ones = [obj["tile_list"][n] for n in range(1, N_REAL + 1)] cfg = { "duration": duration, "gravity": 9.81, "scale": 40.0, "hz": 7680, "fps": FPS_SIM, "frozen": ["block_"], "rules": {"block_": {"friction": FRICTION, "bounce": 0.0, "density": 1100}, "box_": {"friction": FRICTION, "bounce": 0.0}}, "events": [{"t": t_release, "action": "release", "prefix": "block_"}], } run_godot(export_physics(NAME_KEY, real_ones + [obj["floor_obj"]], cfg)) # --- visual scenery (takes no part in the physics) ----------------------------- def gris(obj_name, col, rough=0.6): return material(obj_name, col, rough=rough) def persona(x, y): m = gris("persona", "#C9D1DC") g = bpy.data.objects.new("persona", None) bpy.context.collection.objects.link(g) parts = [] for sx in (-0.1, 0.1): c = cylinder(f"pierna{sx}", 0.075, 0.85, m); c.location = (0, sx, 0.425); parts.append(c) t = cylinder("torso", 0.19, 0.62, m); t.location = (0, 0, 1.17); parts.append(t) for sy in (-0.26, 0.26): b = cylinder(f"brazo{sy}", 0.055, 0.62, m); b.location = (0, sy, 1.15); parts.append(b) header = sphere("cabeza", 0.12, m); header.location = (0, 0, 1.62); parts.append(header) for p_ in parts: p_.parent = g g.location = (x, y, 0) return g def obelisk(x, y): m = gris("obelisco", "#EDEFF2", 0.5) b0, b1, h = 6.8 / 2, 3.5 / 2, 63.5 v = [(sx * b0, sy * b0, 0) for sx, sy in ((-1, -1), (1, -1), (1, 1), (-1, 1))] v += [(sx * b1, sy * b1, h) for sx, sy in ((-1, -1), (1, -1), (1, 1), (-1, 1))] v += [(0, 0, 67.5)] f = [(0, 1, 5, 4), (1, 2, 6, 5), (2, 3, 7, 6), (3, 0, 4, 7), (4, 5, 8), (5, 6, 8), (6, 7, 8), (7, 4, 8), (3, 2, 1, 0)] ob = make_object("obelisco", mesh_from("obelisco", v, f, suave=False), m) ob.location = (x, y, 0) return ob def empire(x, y): m = gris("empire", "#AEB6C2", 0.45) g = bpy.data.objects.new("empire", None) bpy.context.collection.objects.link(g) for (w, d, z0, z1) in ((130, 58, 0, 25), (95, 50, 25, 250), (70, 42, 250, 290), (48, 34, 290, 330), (26, 26, 330, 381)): c = box_obj(f"es{z0}", (w, d, z1 - z0), (0, 0, (z0 + z1) / 2), m) c.parent = g a = cylinder("antena", 2.5, 62, m); a.location = (0, 0, 381 + 31); a.parent = g g.location = (x, y, 0) return g def montana(x, y, alto_m=6961.0, radio=9000.0): """Cone with noise; snow on top through a height-dependent material.""" import random rnd = random.Random(5) n_r, n_a = 40, 96 v, f = [(0, 0, alto_m)], [] phase = [rnd.uniform(0, 6.28) for _ in range(4)] for i in range(1, n_r + 1): r = radio * i / n_r for j in range(n_a): a = 2 * math.pi * j / n_a noise = sum(0.10 / (k + 1) * math.sin((k + 2) * a + phase[k] + 0.0007 * r * (k + 1)) for k in range(4)) z = alto_m * max(0.0, 1 - r / radio) ** 1.25 * (1 + noise * (i / n_r)) v.append((r * math.cos(a), r * math.sin(a), max(0.0, z))) for j in range(n_a): f.append((0, 1 + j, 1 + (j + 1) % n_a)) for i in range(n_r - 1): for j in range(n_a): a0 = 1 + i * n_a + j a1 = 1 + i * n_a + (j + 1) % n_a f.append((a0, a0 + n_a, a1 + n_a, a1)) m = bpy.data.materials.new("roca") m.use_nodes = True nt = m.node_tree b = nt.nodes.get("Principled BSDF") tc = nt.nodes.new("ShaderNodeTexCoord") sep = nt.nodes.new("ShaderNodeSeparateXYZ") mr = nt.nodes.new("ShaderNodeMapRange") mr.inputs[1].default_value = alto_m * 0.62 mr.inputs[2].default_value = alto_m * 0.72 ramp = nt.nodes.new("ShaderNodeValToRGB") ramp.color_ramp.elements[0].color = (*srgb("#5A5048"), 1) ramp.color_ramp.elements[1].color = (*srgb("#F4F6FA"), 1) nt.links.new(tc.outputs["Object"], sep.inputs[0]) nt.links.new(sep.outputs["Z"], mr.inputs[0]) nt.links.new(mr.outputs[0], ramp.inputs[0]) nt.links.new(ramp.outputs["Color"], b.inputs["Base Color"]) put(b, "Roughness", 0.8) ob = make_object("montana", mesh_from("montana", v, f), m) ob.location = (x, y, 0) return ob def home(x, y): m = gris("casa", "#D8C7A8", 0.7) mt = gris("techo", "#8C3B2E", 0.6) g = bpy.data.objects.new("casa", None) bpy.context.collection.objects.link(g) c = box_obj("casa_c", (9, 7, 5), (0, 0, 2.5), m); c.parent = g v = [(-4.8, -3.8, 5), (4.8, -3.8, 5), (4.8, 3.8, 5), (-4.8, 3.8, 5), (-4.8, 0, 8), (4.8, 0, 8)] f = [(0, 1, 5, 4), (2, 3, 4, 5), (0, 4, 3), (1, 2, 5), (3, 2, 1, 0)] t = make_object("techo", mesh_from("techo", v, f, suave=False), mt); t.parent = g g.location = (x, y, 0) return g def coin(x, y): m = metal("moneda", "#D8B45A", rough=0.25) c = cylinder("moneda", 0.0115, 0.0022, m, sides=48) c.location = (x, y, 0.0011) return c def pencil(): g = bpy.data.objects.new("lapiz", None) bpy.context.collection.objects.link(g) cu = cylinder("lapiz_cuerpo", 0.0035, 0.12, material("amarillo", "#F2B61B", rough=0.4), sides=6) cu.location = (0, 0, 0.06 + 0.012) cu.parent = g # tip: wooden cone with graphite v = [(0.0035 * math.cos(2 * math.pi * k / 12), 0.0035 * math.sin(2 * math.pi * k / 12), 0.012) for k in range(12)] + [(0, 0, 0)] f = [(k, (k + 1) % 12, 12) for k in range(12)] pt = make_object("lapiz_punta", mesh_from("lapiz_punta", v, f), material("madera_l", "#E8C79A")) pt.parent = g return g def main(): obj = build_scene() if env("MODE", "MODO") == "sim" and env("TEST", "PRUEBA"): simulate(0.2, 7.0, obj) return T = Timeline(NAME_KEY) if env("MODE", "MODO") == "sim": simulate((frame_release(T) - 1) / FPS, T.n_frames / FPS + 0.5, obj) return render(T, obj) def render(T, obj): import numpy as np idx, D = load_sim(NAME_KEY) tile_list, xs = obj["tile_list"], obj["xs"] r = T.span fr = frame_release(T) t_release = (fr - 1) / FPS i0 = int(round(t_release * FPS_SIM)) # when each tile falls (tilt > 30 degrees), in seconds since the release fall = {} for n in range(1, N_REAL + 1): qw = np.abs(D[:, idx[f"block_d{n:02d}"], 3]) ang = 2 * np.degrees(np.arccos(np.clip(qw, 0, 1))) k = int(np.argmax(ang > 30)) if (ang > 30).any() else len(D) - 1 fall[n] = (k - i0) / FPS_SIM t_chain = fall[N_REAL] + 0.6 print(f"[{NAME_KEY}] caidas: " + " ".join(f"{n}:{fall[n]:.2f}" for n in fall)) # the imaginary ones start past where tile 13 ended up lying x_img = {N_REAL + 1: xs[N_REAL] + 1.25 * alto(N_REAL) + THICKNESS * alto(N_REAL + 1)} for n in range(N_REAL + 1, N_TOTAL): h, h2 = alto(n), alto(n + 1) x_img[n + 1] = x_img[n] + THICKNESS * h / 2 + SEP * h + THICKNESS * h2 / 2 for n in range(N_REAL + 1, N_TOTAL + 1): tile_list[n].location.x = x_img[n] X = dict(xs=xs) X.update({n: xs[n] for n in range(1, N_REAL + 1)}) X.update(x_img) # scenery table = box_obj("mesa", (6.0, 3.0, 0.02), (1.2, 0.0, -0.01), obj["M"]["piso"]) table.visible_shadow = False # otherwise its shadow falls on the ground 2 mm below # the ground is scaled every frame by the camera distance: a fixed 200 km # plane has triangles so large that depth loses precision and hides the # table (which is 2 mm higher) ground = box_obj("suelo", (1.0, 1.0, 0.02), (0, 0, -0.012), material("suelo", "#2B2622", rough=0.9)) lap = pencil() # comparisons: the camera looks along the VH direction (from the side of the # face with pips) and each reference stands next to its tile, perpendicular to # the view: that way both are at the same distance from the camera and # perspective enlarges neither. VH = (0.857, 0.514) RIGHT_X = (0.514, -0.857) # "to the right" on screen L = {} for n, gap, wl, hl in ((16, 0.30, 0.25, 1.75), (25, 12.0, 3.4, 67.5), (29, 60.0, 65.0, 443.0), (36, 600.0, 4500.0, 6961.0), (20, 2.5, 4.8, 8.0)): dd = 0.3 * alto(n) + gap + wl L[n] = (X[n] + RIGHT_X[0] * dd, RIGHT_X[1] * dd, dd, wl, hl) persp = persona(L[16][0], L[16][1]) obel = obelisk(L[25][0], L[25][1]) tie = empire(L[29][0], L[29][1]) mon_t = montana(L[36][0], L[36][1], radio=4500.0) house = home(L[20][0], L[20][1]) for ob_ in (persp, tie, house): ob_.rotation_euler = (0, 0, math.atan2(VH[1], VH[0])) # facing the camera def label(txt, col, size_u, loc, giro=-0.5): t = txt_m(txt, size_u=size_u, color=col) t.location = loc t.rotation_euler = (math.pi / 2, 0, giro) t.visible_shadow = False return t E = { "5mm": label("5 mm", "ambar", 0.0035, (xs[1], -0.006, alto(1) + 0.004)), "x15": label("×1,5", "ambar", 0.005, ((xs[1] + xs[2]) / 2, -0.01, alto(2) + 0.006)), "65": label("65 cm", "ambar", 0.08, (xs[13], -0.1, alto(13) + 0.09)), "16": label("16", "ambar", 0.40, (X[16], 0, 1.07 * alto(16) + 0.40 * 0.5), -1.03), "vos": label("vos", "blanco", 0.26, (L[16][0], L[16][1], 2.0), -1.03), "25": label("25", "ambar", 11, (X[25], 0, 1.07 * alto(25) + 11 * 0.5), -1.03), "obe": label("Obelisco", "blanco", 7, (L[25][0], L[25][1], 75), -1.03), "29": label("29", "ambar", 55, (X[29], 0, 1.07 * alto(29) + 55 * 0.5), -1.03), "emp": label("Empire State", "blanco", 38, (L[29][0], L[29][1], 490), -1.03), "36": label("36", "ambar", 900, (X[36], 0, 1.07 * alto(36) + 900 * 0.5), -1.03), "aco": label("Aconcagua", "blanco", 700, (L[36][0], L[36][1], 7700), -1.03), "casa": label("casa: 8 m", "blanco", 1.1, (L[20][0], L[20][1], 9.3), -1.03), } xs15 = [label("×1,5", "ambar", 0.35 * alto(n), (xs[n], -0.1 * alto(n), 0.22 * alto(n) + 0.02)) for n in range(2, N_REAL + 1)] lens = 50.0 cam = camera_obj((0, -1, 0.3), (0, 0, 0), lens=lens) cam.data.sensor_fit = 'VERTICAL' cam.data.sensor_height = 36.0 sun = bpy.data.lights.new("sol", 'SUN') sun.energy = 3.2 sun.angle = math.radians(3) so = bpy.data.objects.new("sol", sun) bpy.context.collection.objects.link(so) so.rotation_euler = (math.radians(50), math.radians(-18), math.radians(-35)) def front_side(t): """Continuous index of the tile falling t s after the release.""" if t <= 0: return 1.0 for n in range(1, N_REAL): if t < fall[n + 1]: a, b = fall[n], fall[n + 1] return n + max(0.0, min(1.0, (t - a) / max(1e-6, b - a))) return float(N_REAL) def cam_front(t): u = front_side(t) n = int(u) w = u - n n2 = min(N_REAL, n + 1) x = X[n] + (X[n2] - X[n]) * w + 0.9 * alto(n) * (1 + 0.5 * w) hh = alto(n) * R ** w return x, 0.45 * hh, 5.5 * hh VISTA13 = ((xs[1] + xs[13]) / 2 + 0.25, 0.32, 2.3) def take_ref(n): """Framing of tile n with its reference to the right: (x, z, height, y).""" h = alto(n) _, _, dd, wl, hl = L[n] left_part = -(1.0 if n == 20 else 0.7) * h # a bit of the previous tiles right_part = dd + wl c = (left_part + right_part) / 2 alto_v = max(max(h, hl) * 1.75, (right_part - left_part) / 0.5625 * 1.3) return (X[n] + RIGHT_X[0] * c, max(h, hl) * 0.50, alto_v, RIGHT_X[1] * c) tomas = [ (1, (xs[1] + 0.003, 0.004, 0.032)), (r(0)[1], (xs[1] + 0.004, 0.004, 0.034)), (r(1)[0] + 20, ((xs[1] + xs[2]) / 2, 0.0055, 0.05)), (r(1)[1], ((xs[1] + xs[2]) / 2 + 0.002, 0.006, 0.055)), (r(2)[1] - 10, VISTA13), (r(3)[0] + int(0.45 * (r(3)[1] - r(3)[0])), VISTA13), (fr - 6, (xs[1] + 0.003, 0.004, 0.032)), ] f_rep0 = r(5)[0] f_rep1 = r(6)[1] tomas2 = [ (f_rep1 + 1, VISTA13), (r(8)[1], (VISTA13[0] + 0.1, 0.3, 2.0)), (r(9)[1], (X[15] - 1.0, 1.0, 6.0)), (r(10)[0] + 10, take_ref(16)), (r(10)[1], take_ref(16)), (r(11)[0] + 12, take_ref(25)), (r(11)[1], take_ref(25)), (r(12)[0] + 12, take_ref(29)), (r(12)[1], take_ref(29)), (r(13)[0] + 14, take_ref(36)), (r(13)[1], take_ref(36)), (r(14)[1] - 5, (xs[1] + 0.003, 0.004, 0.032)), (r(15)[1], VISTA13), (r(16)[1], (VISTA13[0], 0.3, 1.9)), (r(17)[0] + 15, take_ref(20)), (T.n_frames, take_ref(20)), ] def interp(item_list, f): for (f0, a), (f1, b) in zip(item_list, item_list[1:]): if f0 <= f <= f1: u = suave((f - f0) / max(1, f1 - f0)) ha, hb = a[2], b[2] if max(ha, hb) / min(ha, hb) > 3: # big zoom: height on a log scale, the target moves with the height h = math.exp(math.log(ha) + (math.log(hb) - math.log(ha)) * u) w = (h - ha) / (hb - ha) else: h = ha + (hb - ha) * u w = u ya = a[3] if len(a) > 3 else 0.0 yb = b[3] if len(b) > 3 else 0.0 return (a[0] + (b[0] - a[0]) * w, a[1] + (b[1] - a[1]) * w, h, ya + (yb - ya) * w) last_pt = item_list[-1][1] if f > item_list[-1][0] else item_list[0][1] return tuple(last_pt) + ((0.0,) if len(last_pt) == 3 else ()) def t_sim(f): """Seconds of simulation since the release shown in frame f.""" if f < fr: return 0.0 if f < f_rep0: return (f - fr) / FPS if f <= f_rep1: u = (f - f_rep0) / max(1, f_rep1 - f_rep0) return t_chain * u * u # ramp: slow at first return t_chain + 2.0 def refresh(f): ts = t_sim(f) i = max(0, min(len(D) - 1, i0 + int(round(ts * FPS_SIM)))) for n in range(1, N_REAL + 1): set_pose(tile_list[n], D[i, idx[f"block_d{n:02d}"]]) # before the release, the first tile stands upright and the pencil tilts it f_tie = fr - 22 if f < fr: u = suave((f - f_tie) / 22.0) if f >= f_tie else 0.0 tile_list[1].rotation_mode = 'XYZ' tilt(tile_list[1], xs[1], INCL * u) h1 = alto(1) pencil_vis = (fr - 60) <= f <= fr + 25 lap.hide_render = not pencil_vis for c in lap.children: c.hide_render = not pencil_vis if pencil_vis: arrives = suave((f - (fr - 60)) / 38.0) returns = suave((f - fr) / 20.0) u = suave((f - f_tie) / 22.0) if f >= f_tie else 0.0 tip_x = xs[1] - THICKNESS * h1 / 2 + h1 * math.sin(INCL * u) - 0.02 * (1 - arrives) - 0.01 * returns lap.location = (tip_x, 0.0, 0.9 * h1 + 0.02 * (1 - arrives) + 0.01 * returns) lap.rotation_euler = (0, math.radians(-62), 0) # imaginary ones # up to which tile is visible: it grows with the narration if f < r(9)[0]: n_visible = N_REAL elif f < r(10)[0]: n_visible = N_REAL + 3 * T.p(f, 9) elif f < r(11)[0]: n_visible = 16 elif f < r(12)[0]: n_visible = 16 + 9 * suave(T.p(f, 11) / 0.2) elif f < r(13)[0]: n_visible = 25 + 4 * suave(T.p(f, 12) / 0.2) elif f < r(17)[0]: n_visible = 29 + 7 * suave(T.p(f, 13) / 0.2) else: n_visible = 20 for n in range(N_REAL + 1, N_TOTAL + 1): tile_list[n].hide_render = n > n_visible + 1e-6 for c in tile_list[n].children: c.hide_render = tile_list[n].hide_render for ob_, beat in ((persp, 10), (obel, 11), (tie, 12), (mon_t, 13), (house, 17)): visible = f >= r(beat)[0] - 6 if beat != 17 else f >= r(17)[0] - 10 ob_.hide_render = not visible for c in ob_.children: c.hide_render = not visible visible_lab = { "5mm": r(0)[0] <= f < r(2)[0] + 10, "x15": r(1)[0] + 15 <= f < r(2)[0] + 10, "65": r(3)[0] <= f < fr - 30, "16": r(10)[0] <= f < r(11)[0], "vos": r(10)[0] + 8 <= f < r(11)[0], "25": r(11)[0] <= f < r(12)[0], "obe": r(11)[0] + 8 <= f < r(12)[0], "29": r(12)[0] <= f < r(13)[0], "emp": r(12)[0] + 8 <= f < r(13)[0], "36": r(13)[0] <= f < r(14)[0], "aco": r(13)[0] + 8 <= f < r(14)[0], "casa": f >= r(17)[0] + 10, } for k, t in E.items(): t.hide_render = not visible_lab[k] for j, t in enumerate(xs15): t.hide_render = not (r(7)[0] + j * 4 <= f < r(9)[0]) # camera y = 0.0 if fr - 6 <= f < f_rep0 or f_rep0 <= f <= f_rep1: x, z, h = cam_front(ts) if f < fr + 4: a = interp(tomas, f) w = suave((f - (fr - 6)) / 10.0) x, z, h = (a[0] + (x - a[0]) * w, a[1] + (z - a[1]) * w, a[2] + (h - a[2]) * w) elif f < fr - 6: x, z, h, y = interp(tomas, f) else: x, z, h, y = interp(tomas2, f) dist = h * lens / 36.0 # angle: high and crossed for the small tiles, more frontal for the comparisons k = suave((f - r(9)[0]) / 20.0) * (1 - suave(T.p(f, 14))) + suave((f - r(17)[0]) / 15.0) k = max(0.0, min(1.0, k)) da, db = (-0.447, -0.894, 0.32), (-VH[0], -VH[1], 0.10) d = tuple(da[j] + (db[j] - da[j]) * k for j in range(3)) nd = math.sqrt(sum(c * c for c in d)) zc = z - 0.08 * h # leaves room for the subtitles pos = (x + d[0] / nd * dist, y + d[1] / nd * dist, zc + d[2] / nd * dist) aim_at(cam, pos, (x, y, zc)) side = max(12.0, dist * 60.0) ground.scale = (side, side, 1.0) ground.location = (x, y, -0.012) cam.data.clip_start = max(1e-5, dist * 0.002) cam.data.clip_end = dist * 400 render_sequence(NAME_KEY, T, refresh) main()