diff --git a/.trinity/seals/t27f_pbr_sphere.json b/.trinity/seals/t27f_pbr_sphere.json new file mode 100644 index 0000000000..9c9e346aca --- /dev/null +++ b/.trinity/seals/t27f_pbr_sphere.json @@ -0,0 +1,28 @@ +{ + "built_by": "t27c-bootstrap@0.5.2+9448ab4be", + "closure": [], + "config": "gen=c,rust,verilog,zig;tests=zig", + "gen_hash_c": "sha256:2f31d565d2c71e2d4d92894255a9bdef3222ac82ef1c7e49bd7245e1d203abb9", + "gen_hash_rust": "sha256:e945517759c860b4ab1c45030cccc81125b900fe852ee9f9f386872af106be99", + "gen_hash_verilog": "sha256:9196306f8144111fba605ad93b133846b49bc3130ca9e55dda246fc6581f04aa", + "gen_hash_zig": "sha256:a8856def731456b38f7486e526568c75f93c961eddd33185716419218458b58d", + "module": "pbr_sphere", + "ring": 12, + "seal_schema": 2, + "sealed_at": "2026-10-18T12:14:32Z", + "sealed_by": "t27c-bootstrap@0.5.2", + "spec_hash": "sha256:f3ca999dd227cac19751b0893e7c28bdff22990a115dedf1f039ca1752c2d1ad", + "spec_path": "specs/t27f/pbr_sphere.t27", + "tests": { + "failed": 0, + "failing": [], + "forced": false, + "passed": 13, + "total": 13, + "vacuous": 0 + }, + "toolchain": { + "test_runner": "sha256:1982ae948e24d00c5d221ccec12dfce1eb01204b53e37b0f857f93b16d64629d", + "zig": "0.16.0" + } +} \ No newline at end of file diff --git a/.trinity/seals/t27f_token_floor.json b/.trinity/seals/t27f_token_floor.json index 386e5f045f..0ed69745c3 100644 --- a/.trinity/seals/t27f_token_floor.json +++ b/.trinity/seals/t27f_token_floor.json @@ -13,29 +13,37 @@ "spec": "specs/t27f/particle_engine.t27", "spec_hash": "sha256:1b99d151382c41e99a4e928842ee0abcb984d2a0ee43abf5fbd602097d6abadd" }, + { + "spec": "specs/t27f/pbr_sphere.t27", + "spec_hash": "sha256:f3ca999dd227cac19751b0893e7c28bdff22990a115dedf1f039ca1752c2d1ad" + }, { "spec": "specs/t27f/reel_particles.t27", "spec_hash": "sha256:d84714fc328bdc56596cda456b0e8efdd4233cfdb593dcc014a06b92e5feefbe" + }, + { + "spec": "specs/t27f/token_floor_scenes.t27", + "spec_hash": "sha256:0cdfbe9ca10442fe1f29d54a75d433ea60dafb399e45caae678838bc660d552e" } ], "config": "gen=c,rust,verilog,zig;tests=zig", - "gen_hash_c": "sha256:4f16c015b460b005332d006930f66dd64bba91477c3c33b7fc7040cc1a6cea34", - "gen_hash_rust": "sha256:b1f0266f0bd7d407b6f3ae20fb7d79b054774604b359c8c4308eec30eeebcce7", - "gen_hash_verilog": "sha256:5f23d216ff552e61ae387df63bbab05d616894d32665ef3232316da20905c0d3", - "gen_hash_zig": "sha256:ba680b8e5c6d54e34d2ce7f4c2ba073a7bc2376b53e7c90d0eed0327fdf7809d", + "gen_hash_c": "sha256:d5aea31005f78eb0d5d5b37c8fd9eb374a4eaa40869c79c116308ad2cd133c51", + "gen_hash_rust": "sha256:6bafe70fc1a9eb0c8a08047d4898b6b0cf75dd3035c862ba9439e0016a34770f", + "gen_hash_verilog": "sha256:4b19d1b78744c6a770c82b778f42265f6d6c3650d8ec6eeabf690ea66153cdd5", + "gen_hash_zig": "sha256:872a9851daa393c95e1b290fcc6c7f85b602c5493f5ddccf4e265637bc7977c6", "module": "token_floor", "ring": 12, "seal_schema": 2, - "sealed_at": "2026-10-18T11:13:39Z", + "sealed_at": "2026-10-18T12:16:20Z", "sealed_by": "t27c-bootstrap@0.5.2", - "spec_hash": "sha256:2ac0e9d3046d86cf3d2f10974b1ebb9c474cc4e2d44fa3226a4ec0de3d7bedf3", + "spec_hash": "sha256:af7ecb226097febe8c528424ccf5eb30fd30ef3d7350e485baac7cbd34521322", "spec_path": "specs/t27f/token_floor.t27", "tests": { "failed": 0, "failing": [], "forced": false, - "passed": 28, - "total": 28, + "passed": 34, + "total": 34, "vacuous": 0 }, "toolchain": { diff --git a/.trinity/seals/t27f_token_floor_mux.json b/.trinity/seals/t27f_token_floor_mux.json index 58e7de1e1c..8ccce69d51 100644 --- a/.trinity/seals/t27f_token_floor_mux.json +++ b/.trinity/seals/t27f_token_floor_mux.json @@ -5,33 +5,41 @@ "spec": "specs/automation/owner_voice_reel.t27", "spec_hash": "sha256:76c0df8e57686a90b3bce85bcb0148cbed7fd05da724b217ef53fa468664f151" }, + { + "spec": "specs/t27f/particle_engine.t27", + "spec_hash": "sha256:1b99d151382c41e99a4e928842ee0abcb984d2a0ee43abf5fbd602097d6abadd" + }, { "spec": "specs/t27f/reel_particles_mux.t27", "spec_hash": "sha256:1f5486ae5a6dabe9c059733f1f45d1646ca98b555e07103e00e2dc0be53f0747" }, { "spec": "specs/t27f/token_floor.t27", - "spec_hash": "sha256:1fd3b3b3d50b2d163cfe2809587ae1b859ac0f79fddf666813602a53d5adf780" + "spec_hash": "sha256:84c176ce3324b3f924f5be142130ebb15f26a32e63cd93d7c7f1235b0022798b" + }, + { + "spec": "specs/t27f/token_floor_scenes.t27", + "spec_hash": "sha256:0cdfbe9ca10442fe1f29d54a75d433ea60dafb399e45caae678838bc660d552e" } ], "config": "gen=c,rust,verilog,zig;tests=zig", - "gen_hash_c": "sha256:e8bd1f62065c8ee00dd0f8e84fdc9a0fa61bfdaf96b729a4c182b2a3449e450f", - "gen_hash_rust": "sha256:fd99dc4cffc96c4ebd7503050f31520103d084b15c5b30296319300f6353835e", - "gen_hash_verilog": "sha256:2ae59b987cd2c0478cddd53792ec071716a6a77a106a56bb37b6bc333795ef15", - "gen_hash_zig": "sha256:1ce803fd58f0f90b0700052b2145a431d84c4efe788f7a0b884cb4d83eddb167", + "gen_hash_c": "sha256:11a39577b1ce474e322a0a17f1a6658c977fd607958b724025435ae59569f7f8", + "gen_hash_rust": "sha256:04208153e7b89c2bf88f43a5617126236ecb23bbb2e3ba2f8e1e3b5d79b8951c", + "gen_hash_verilog": "sha256:76dbeb916a6d231f5488baaf56bec937f8697aae56907e615bb916380c2797a2", + "gen_hash_zig": "sha256:3d88cdb8db2b955868ecce0e8d3921ce30e4e428b61569b0347d10767ea08d29", "module": "token_floor_mux", "ring": 12, "seal_schema": 2, - "sealed_at": "2026-10-18T10:08:27Z", + "sealed_at": "2026-10-18T12:14:37Z", "sealed_by": "t27c-bootstrap@0.5.2", - "spec_hash": "sha256:98fac62ffbec00868232d5a09b82c754951d1819a2f7c2e8f4317371a8039162", + "spec_hash": "sha256:d36ea49889e010b155bec7b823f1103963bf2471bbe4646f3f56ef610474fd05", "spec_path": "specs/t27f/token_floor_mux.t27", "tests": { "failed": 0, "failing": [], "forced": false, - "passed": 10, - "total": 10, + "passed": 14, + "total": 14, "vacuous": 0 }, "toolchain": { diff --git a/.trinity/seals/t27f_token_floor_scenes.json b/.trinity/seals/t27f_token_floor_scenes.json new file mode 100644 index 0000000000..f75f84de91 --- /dev/null +++ b/.trinity/seals/t27f_token_floor_scenes.json @@ -0,0 +1,28 @@ +{ + "built_by": "t27c-bootstrap@0.5.2+9448ab4be", + "closure": [], + "config": "gen=c,rust,verilog,zig;tests=zig", + "gen_hash_c": "sha256:0b13178692bc1a8cacc426ff975d81b3b1e6e8d228e1b045a00685f4139487f5", + "gen_hash_rust": "sha256:947642f7ab01247088201607ef4ed132c8b366766046d053dbcec84a57c48ecc", + "gen_hash_verilog": "sha256:f3ca1ecad2079d5b09b275d89920e6b688297ae3f3425fa701d580dca663c642", + "gen_hash_zig": "sha256:d4aa0250db25da21749e13d7127ce2c22b93d8f0cfa5daaa18eaddda4f032027", + "module": "token_floor_scenes", + "ring": 12, + "seal_schema": 2, + "sealed_at": "2026-10-18T12:14:34Z", + "sealed_by": "t27c-bootstrap@0.5.2", + "spec_hash": "sha256:0cdfbe9ca10442fe1f29d54a75d433ea60dafb399e45caae678838bc660d552e", + "spec_path": "specs/t27f/token_floor_scenes.t27", + "tests": { + "failed": 0, + "failing": [], + "forced": false, + "passed": 11, + "total": 11, + "vacuous": 0 + }, + "toolchain": { + "test_runner": "sha256:1982ae948e24d00c5d221ccec12dfce1eb01204b53e37b0f857f93b16d64629d", + "zig": "0.16.0" + } +} \ No newline at end of file diff --git a/specs/t27f/pbr_sphere.t27 b/specs/t27f/pbr_sphere.t27 new file mode 100644 index 0000000000..675419ec11 --- /dev/null +++ b/specs/t27f/pbr_sphere.t27 @@ -0,0 +1,629 @@ +// SPDX-License-Identifier: Apache-2.0 +// specs/t27f/pbr_sphere.t27 -- a polished sphere lit the way a product studio lights one: a +// microfacet metal or a glazed dielectric, mirroring an analytic set of softboxes, its neighbours +// and the floor's horizon, smeared by the camera's shutter. +// +// WHY: the owner, 2026-10-18, on the token_floor reel: the balls do not look real; study the best +// practice of 3D modelling and bring it into t27f. The old shader was Lambert plus a Blinn +// highlight plus a rim: a plastic look, because a real gold ball has almost no diffuse light at +// all. Everything it shows is a reflection, tinted by gold's own Fresnel colour, and what makes it +// read as real is WHAT it reflects: crisp rectangular softboxes, a dark room, the bright line where +// the floor meets the backdrop, and the balls next to it. This module is that practice, as used in +// physically based renderers (B. Karis, "Real Shading in Unreal Engine 4", SIGGRAPH 2013): +// +// the lobe GGX / Trowbridge-Reitz microfacets, alpha = roughness^2 (B. Walter et al., EGSR +// 2007; B. Burley, SIGGRAPH 2012). The lobe's half-width softens every edge the +// sphere mirrors: a rough ball blurs the softbox, a polished one keeps it sharp. +// Fresnel Schlick's approximation with a coloured F0 for a metal (C. Schlick, CGF 13, 1994). +// Gold's F0 is (1.00, 0.71, 0.29) linear (N. Hoffman, "Physics and Math of +// Shading", SIGGRAPH 2015 course): blue is mostly absorbed, so gold mirrors in gold. +// Glaze is a dielectric, F0 0.04, and its reflections stay white. +// split sum the light a lobe gathers is the environment, prefiltered to the lobe's width, +// times a scale and bias on F0 that integrate the BRDF (Karis's analytic fit, +// "Physically Based Shading on Mobile", 2014). +// the studio four rectangular area lights and a horizon, analytic in the reflection +// direction, so the reflection is exact and costs nothing to look up. +// neighbours a reflected ray that strikes the ball above or below sees that ball, mirrored +// once more: the stacked-balls look of every gold-sphere product shot. +// occlusion a touching sphere hides the sky from the contact (I. Quilez's analytic sphere +// occlusion): the dark ring between two balls. +// the shutter a ball falling 6 m/s moves 100 px in half a frame. A camera with a 180 degree +// shutter sees the time-average of the disk, not a sharp disk: swept_cover is that +// average, exactly, for a disk sliding along a line. +// +// The frame is the screen's: x right, y DOWN, z toward the camera. The camera is orthographic, so +// the view vector is (0, 0, 1) everywhere and a point on a unit sphere is its own normal. +// +// NOT CLAIMED: the light rig, the roughnesses and the glaze colour are design choices. The rig is +// not measured from any studio; it is the classic three-plus-one (key, rim strip, top, fill). +// phi^2 + 1/phi^2 = 3 | TRINITY + +module pbr_sphere; + +pub const ID : str = "pbr-sphere"; +pub const PI : f64 = 3.141592653589793; + +// ---- materials ---- +// Gold's reflectance at normal incidence, linear r g b (Hoffman 2015). +pub const GOLD_F0 : [3]f64 = [1.0, 0.71, 0.29]; +pub const GOLD_ROUGH : f64 = 0.24; // polished, not mirror: the softbox edge stays crisp +// A white glazed ceramic: a dielectric, F0 4 percent, with a slightly cool body. +pub const GLAZE_F0 : f64 = 0.04; +pub const GLAZE_ALBEDO : [3]f64 = [0.80, 0.81, 0.84]; +pub const GLAZE_ROUGH : f64 = 0.18; + +// ---- the studio (design choices) ---- +// Each light: its axis c (unit, the direction a reflected ray must point to see its centre), its +// half-size along u and v in the tangent plane at distance 1, and its radiance r g b. u is +// horizontal, c x Y normalised; v = u x c. +pub const LIGHTS : usize = 4; +pub const LIGHT : [36]f64 = [ + // key: a tall softbox up-left, in front, a little warm + -0.55, -0.55, 0.62849, 0.42, 0.62, 5.0, 4.8, 4.5, 0.0, + // rim: a narrow strip behind-right, cool + 0.80, -0.20, -0.565685, 0.10, 1.60, 2.6, 2.9, 3.2, 0.0, + // top: a wide overhead panel, tipped toward the camera + 0.0, -0.939693, 0.342020, 0.90, 0.50, 1.35, 1.35, 1.35, 0.0, + // fill: a dim card low on the left, warm + -0.92, 0.10, 0.378946, 0.25, 0.50, 0.70, 0.64, 0.57, 0.0 +]; +pub const HOT : f64 = 0.25; // a softbox is hotter in its centre by this share +// The room: black above, the glossy black floor below, and the bright line where they meet. +pub const SKY : f64 = 0.012; +pub const HZ_GLOW : f64 = 0.10; +pub const HZ_UP : f64 = 0.14; // how far up the horizon's glow reaches, in ry +pub const FLOOR_L : f64 = 0.008; +pub const FLOOR_LINE : f64 = 0.38; +pub const HZ_DN : f64 = 0.035; // the floor's line is thin +pub const ROOM_TINT : [3]f64 = [1.0, 0.93, 0.84]; +pub const EDGE : f64 = 2.0; // a mirrored edge blurs over the lobe on both sides +pub const NO_NB : f64 = 1000000.0; // no neighbour + +pub const SHUTTER : f64 = 0.5; // 180 degrees: the shutter is open half of each frame + +// ---- small helpers ---- + +fn clamp01(x: f64) -> f64 { + if (x < 0.0) { return 0.0; } + if (x > 1.0) { return 1.0; } + return x; +} + +// Hermite smoothstep from e0 to e1 (e0 > e1 runs it backwards). +fn sstep(e0: f64, e1: f64, x: f64) -> f64 { + const t = clamp01((x - e0) / (e1 - e0)); + return t * t * (3.0 - 2.0 * t); +} + +fn pow5(x: f64) -> f64 { + const x2 = x * x; + return x2 * x2 * x; +} + +// arcsin by its series, accurate to 1e-4 below 0.5, which is all the rig needs. +fn asin_small(x: f64) -> f64 { + const x2 = x * x; + return x * (1.0 + x2 * (1.0 / 6.0 + x2 * (3.0 / 40.0 + x2 * (5.0 / 112.0 + x2 * 35.0 / 1152.0)))); +} + +// ---- the microfacet lobe ---- + +pub fn alpha_of(rough: f64) -> f64 { + return rough * rough; +} + +// GGX: the density of microfacet normals at cos(theta_h) = noh. +pub fn ggx_d(noh: f64, alpha: f64) -> f64 { + const a2 = alpha * alpha; + const t = noh * noh * (a2 - 1.0) + 1.0; + return a2 / (PI * t * t); +} + +// The projected area of the lobe over the hemisphere, by the midpoint rule in s = sin^2(theta_h): +// d(omega) cos = pi ds, so the integral is pi * sum D ds. A true distribution gives 1. +fn ggx_projected(alpha: f64, n: i64) -> f64 { + const ds = 1.0 / @as(f64, @floatFromInt(n)); + var sum : f64 = 0.0; + var i : i64 = 0; + while (i < n) { + const s = (@as(f64, @floatFromInt(i)) + 0.5) * ds; + sum = sum + ggx_d(@sqrt(1.0 - s), alpha); + i = i + 1; + } + return PI * sum * ds; +} + +// sin of the half-angle at which D falls to half its peak: D(mu) = D(1) / 2 solved in closed form, +// sin^2 = (sqrt 2 - 1) a^2 / (1 - a^2). A mirrored direction turns twice the half-vector's angle. +pub fn lobe_sin(alpha: f64) -> f64 { + const a2 = alpha * alpha; + return @sqrt((1.4142135623730951 - 1.0) * a2 / (1.0 - a2)); +} + +pub fn blur_width(rough: f64) -> f64 { + return EDGE * lobe_sin(alpha_of(rough)); +} + +// ---- Fresnel and the split sum ---- + +pub fn fresnel(f0: f64, c: f64) -> f64 { + return f0 + (1.0 - f0) * pow5(1.0 - clamp01(c)); +} + +// Karis's analytic fit of the integrated BRDF: the share of F0 (env_a) and the bias (env_b) that a +// lobe of this roughness returns at this viewing cosine. +fn env_r(rough: f64, k: i64) -> f64 { + if (k == 0) { return 1.0 - rough; } + if (k == 1) { return 0.0425 - 0.0275 * rough; } + if (k == 2) { return 1.04 - 0.572 * rough; } + return 0.022 * rough - 0.04; +} + +fn env_a004(rough: f64, nov: f64) -> f64 { + const rx = env_r(rough, 0); + var m : f64 = rx * rx; + const e = @exp(0.0 - 9.28 * nov * 0.6931471805599453); + if (e < m) { m = e; } + return m * rx + env_r(rough, 1); +} + +pub fn env_a(rough: f64, nov: f64) -> f64 { + var a : f64 = 0.0 - 1.04 * env_a004(rough, nov) + env_r(rough, 2); + if (a < 0.0) { a = 0.0; } + return a; +} + +pub fn env_b(rough: f64, nov: f64) -> f64 { + var b : f64 = 1.04 * env_a004(rough, nov) + env_r(rough, 3); + if (b < 0.0) { b = 0.0; } + return b; +} + +// What a lobe returns of the light it gathers, for reflectance f0; never more than all of it. +pub fn spec_scale(f0: f64, rough: f64, nov: f64) -> f64 { + var s : f64 = f0 * env_a(rough, nov) + env_b(rough, nov); + if (s > 1.0) { s = 1.0; } + return s; +} + +// ---- the studio ---- + +fn lf(i: usize, f: usize) -> f64 { + return LIGHT[i * 9 + f]; +} + +// The solid angle of light i: a centred rectangle hu x hv at distance 1 subtends +// 4 asin(hu hv / sqrt((1 + hu^2)(1 + hv^2))). +pub fn light_omega(i: usize) -> f64 { + const hu = lf(i, 3); + const hv = lf(i, 4); + return 4.0 * asin_small(hu * hv / @sqrt((1.0 + hu * hu) * (1.0 + hv * hv))); +} + +// How much of light i a reflected direction r sees, 0..1, its edges softened by w: r is projected +// onto the light's tangent plane (gnomonic), and the rectangle is tested there. +pub fn light_mask(i: usize, rx: f64, ry: f64, rz: f64, w: f64) -> f64 { + const cx = lf(i, 0); + const cy = lf(i, 1); + const cz = lf(i, 2); + const d = rx * cx + ry * cy + rz * cz; + if (d <= 0.05) { + return 0.0; + } + // u = c x Y / |c x Y| = (-cz, 0, cx) / sqrt(cz^2 + cx^2); v = u x c + const un = @sqrt(cz * cz + cx * cx); + const ux = 0.0 - cz / un; + const uz = cx / un; + const vx = 0.0 - uz * cy; + const vy = uz * cx - ux * cz; + const vz = ux * cy; + const a = (rx * ux + rz * uz) / d; + const b = (rx * vx + ry * vy + rz * vz) / d; + const hu = lf(i, 3); + const hv = lf(i, 4); + const m = sstep(hu + w, hu - w, @abs(a)) * sstep(hv + w, hv - w, @abs(b)); + if (m <= 0.0) { + return 0.0; + } + const hot = 1.0 + HOT - 2.0 * HOT * clamp01((a * a / (hu * hu) + b * b / (hv * hv)) * 0.5); + return m * hot; +} + +// The room's light in direction r: the black backdrop with a glow toward the horizon, the glossy +// floor with a thin bright line under it. Both soften with the lobe's width w. +fn room(ry: f64, w: f64) -> f64 { + if (ry < 0.0) { + return SKY + HZ_GLOW * @exp(ry / (HZ_UP + w)); + } + return FLOOR_L + FLOOR_LINE * @exp(0.0 - ry / (HZ_DN + w)) * HZ_DN / (HZ_DN + w); +} + +// The prefiltered studio seen in direction r through a lobe of edge width w, r g b into out. +pub fn env(out: *[3]f64, rx: f64, ry: f64, rz: f64, w: f64) -> void { + const h = room(ry, w); + out[0] = h * ROOM_TINT[0]; + out[1] = h * ROOM_TINT[1]; + out[2] = h * ROOM_TINT[2]; + var i : usize = 0; + while (i < LIGHTS) { + const m = light_mask(i, rx, ry, rz, w); + if (m > 0.0) { + out[0] = out[0] + m * lf(i, 5); + out[1] = out[1] + m * lf(i, 6); + out[2] = out[2] + m * lf(i, 7); + } + i = i + 1; + } +} + +// The irradiance the rig sends a surface of normal n, over pi: the radiance a white Lambertian +// surface would send back. The room adds a dim floor of its own. +fn irradiance(ch: usize, nx: f64, ny: f64, nz: f64) -> f64 { + var e : f64 = 0.0; + var i : usize = 0; + while (i < LIGHTS) { + var c : f64 = nx * lf(i, 0) + ny * lf(i, 1) + nz * lf(i, 2); + if (c > 0.0) { + e = e + lf(i, 5 + ch) * light_omega(i) * c; + } + i = i + 1; + } + var up : f64 = 0.0 - ny; + if (up < 0.0) { up = 0.0; } + return e / PI + (SKY + 0.3 * HZ_GLOW) * ROOM_TINT[ch] * (0.5 + 0.5 * up); +} + +// ---- neighbours: rays and occlusion between unit spheres on the y axis ---- + +// Distance along unit direction r from point p to the unit sphere centred at (0, oy, 0), or -1 on +// a miss. p is outside that sphere, so the nearer root is the one that counts. +pub fn ray_sphere(px: f64, py: f64, pz: f64, rx: f64, ry: f64, rz: f64, oy: f64) -> f64 { + const qy = py - oy; + const b = px * rx + qy * ry + pz * rz; + const c = px * px + qy * qy + pz * pz - 1.0; + const disc = b * b - c; + if (disc < 0.0) { + return 0.0 - 1.0; + } + const t = 0.0 - b - @sqrt(disc); + if (t <= 0.000001) { + return 0.0 - 1.0; + } + return t; +} + +// How much of the sky the unit sphere at (0, oy, 0) hides from point p with normal n +// (Quilez's analytic sphere occlusion): (n . d) r^2 / |d|^3 with d = centre - p, 0..1. +pub fn sphere_occ(nx: f64, ny: f64, nz: f64, oy: f64) -> f64 { + const dx = 0.0 - nx; + const dy = oy - ny; + const dz = 0.0 - nz; + const l2 = dx * dx + dy * dy + dz * dz; + const l = @sqrt(l2); + return clamp01((nx * dx + ny * dy + nz * dz) / (l2 * l)); +} + +// The light a neighbour sends back along ray r, which struck it at (hx, hy, hz) on the unit sphere +// at (0, oy, 0): one more mirror bounce into the studio, and for glaze its body colour too. +fn neighbour(out: *[3]f64, hx: f64, hy: f64, hz: f64, oy: f64, rx: f64, ry: f64, rz: f64, metal: bool) -> void { + const nx = hx; + const ny = hy - oy; + const nz = hz; + const dn = rx * nx + ry * ny + rz * nz; + const c = 0.0 - dn; + const sx = rx - 2.0 * dn * nx; + const sy = ry - 2.0 * dn * ny; + const sz = rz - 2.0 * dn * nz; + var rough : f64 = GOLD_ROUGH; + if (metal == false) { rough = GLAZE_ROUGH; } + env(out, sx, sy, sz, blur_width(rough)); + var ch : usize = 0; + while (ch < 3) { + if (metal) { + out[ch] = out[ch] * spec_scale(GOLD_F0[ch], rough, c); + } else { + const f = fresnel(GLAZE_F0, c); + out[ch] = out[ch] * spec_scale(GLAZE_F0, rough, c) + GLAZE_ALBEDO[ch] * (1.0 - f) * irradiance(ch, nx, ny, nz); + } + ch = ch + 1; + } +} + +// ---- the sphere ---- + +// The light the unit sphere sends to the camera from the point whose normal is (nx, ny), screen +// frame. up and down are the centres of the neighbours above and below in radii (NO_NB if absent), +// up_metal and down_metal their material. r g b into out. +pub fn shade(out: *[3]f64, nx: f64, ny: f64, metal: bool, up: f64, up_metal: bool, down: f64, down_metal: bool) -> void { + var q : f64 = 1.0 - nx * nx - ny * ny; + if (q < 0.0) { q = 0.0; } + const nz = @sqrt(q); + const nov = nz; + const rx = 2.0 * nz * nx; + const ry = 2.0 * nz * ny; + const rz = 2.0 * nz * nz - 1.0; + var rough : f64 = GOLD_ROUGH; + if (metal == false) { rough = GLAZE_ROUGH; } + // what the reflected ray sees: a neighbour, or the studio + var t : f64 = 0.0 - 1.0; + var oy : f64 = 0.0; + var om : bool = false; + if (up < NO_NB) { + const tu = ray_sphere(nx, ny, nz, rx, ry, rz, up); + if (tu > 0.0) { t = tu; oy = up; om = up_metal; } + } + if (t < 0.0 && down < NO_NB) { + const td = ray_sphere(nx, ny, nz, rx, ry, rz, down); + if (td > 0.0) { t = td; oy = down; om = down_metal; } + } + if (t > 0.0) { + neighbour(out, nx + t * rx, ny + t * ry, nz + t * rz, oy, rx, ry, rz, om); + } else { + env(out, rx, ry, rz, blur_width(rough)); + } + // the sky the neighbours hide + var occ : f64 = 0.0; + if (up < NO_NB) { occ = occ + sphere_occ(nx, ny, nz, up); } + if (down < NO_NB) { occ = occ + sphere_occ(nx, ny, nz, down); } + const ao = 1.0 - clamp01(occ); + var ch : usize = 0; + while (ch < 3) { + if (metal) { + out[ch] = out[ch] * spec_scale(GOLD_F0[ch], rough, nov) * (0.35 + 0.65 * ao); + } else { + const f = fresnel(GLAZE_F0, nov); + out[ch] = out[ch] * spec_scale(GLAZE_F0, rough, nov) + GLAZE_ALBEDO[ch] * (1.0 - f) * irradiance(ch, nx, ny, nz) * ao; + } + ch = ch + 1; + } +} + +// ---- the shutter ---- + +// The share of the open shutter during which pixel (dx, dy) lies inside a disk of radius r whose +// centre slides from -s/2 to +s/2 along y: the time-average a real camera records. Under a pixel +// of travel it is the plain anti-aliased disk. +pub fn swept_cover(dx: f64, dy: f64, r: f64, s: f64) -> f64 { + const sa = @abs(s); + if (sa < 1.0) { + return clamp01(r - @sqrt(dx * dx + dy * dy) + 0.5); + } + const ax = clamp01(r - @abs(dx) + 0.5); + if (ax <= 0.0) { + return 0.0; + } + var hx : f64 = @abs(dx); + if (hx > r) { hx = r; } + const h = @sqrt(r * r - hx * hx); + var lo : f64 = dy - h; + var hi : f64 = dy + h; + if (lo < 0.0 - 0.5 * sa) { lo = 0.0 - 0.5 * sa; } + if (hi > 0.5 * sa) { hi = 0.5 * sa; } + if (hi <= lo) { + return 0.0; + } + return ax * (hi - lo) / sa; +} + +// Where the centre is, on average, while pixel (dx, dy) is covered: the shading uses the normal +// at that moment. +pub fn swept_mid(dx: f64, dy: f64, r: f64, s: f64) -> f64 { + const sa = @abs(s); + if (sa < 1.0) { + return 0.0; + } + var hx : f64 = @abs(dx); + if (hx > r) { hx = r; } + const h = @sqrt(r * r - hx * hx); + var lo : f64 = dy - h; + var hi : f64 = dy + h; + if (lo < 0.0 - 0.5 * sa) { lo = 0.0 - 0.5 * sa; } + if (hi > 0.5 * sa) { hi = 0.5 * sa; } + return 0.5 * (lo + hi); +} + +// The light at normal (nx, ny) of a lone sphere, channel ch: a probe for the tests. +fn lone(nx: f64, ny: f64, metal: bool, ch: usize) -> f64 { + var o : [3]f64 = [0.0, 0.0, 0.0]; + shade(&o, nx, ny, metal, NO_NB, false, NO_NB, false); + return o[ch]; +} + +// The screen normal whose mirror direction is light i's axis: the half-vector of c and (0, 0, 1). +fn aim(i: usize, k: usize) -> f64 { + const hx = lf(i, 0); + const hy = lf(i, 1); + const hz = lf(i, 2) + 1.0; + const n = @sqrt(hx * hx + hy * hy + hz * hz); + if (k == 0) { return hx / n; } + return hy / n; +} + +// The swept cover summed over a pixel grid around the disk: its area on the screen, px^2. +fn swept_area(r: f64, s: f64) -> f64 { + var a : f64 = 0.0; + var y : f64 = 0.0 - r - @abs(s) - 2.0; + while (y < r + @abs(s) + 2.0) { + var x : f64 = 0.0 - r - 2.0; + while (x < r + 2.0) { + a = a + swept_cover(x + 0.5, y + 0.5, r, s); + x = x + 1.0; + } + y = y + 1.0; + } + return a; +} + +// ---- tests ---- + +test "GGX is a distribution: its projected area is 1, for a polished and a rough surface" { + assert(@abs(ggx_projected(alpha_of(GOLD_ROUGH), 200000) - 1.0) < 0.002); + assert(@abs(ggx_projected(alpha_of(0.6), 20000) - 1.0) < 0.002); + // and its peak is 1 / (pi a^2) + const a = alpha_of(GOLD_ROUGH); + assert(@abs(ggx_d(1.0, a) * PI * a * a - 1.0) < 1e-12); +} + +test "the lobe's half-width is where GGX falls to half its peak, and it grows with roughness" { + const a = alpha_of(GOLD_ROUGH); + const s = lobe_sin(a); + assert(@abs(ggx_d(@sqrt(1.0 - s * s), a) / ggx_d(1.0, a) - 0.5) < 1e-9); + assert(blur_width(0.5) > 3.0 * blur_width(GOLD_ROUGH)); + // polished gold blurs a mirrored edge over about 0.07 of the tangent plane + assert(blur_width(GOLD_ROUGH) > 0.05); + assert(blur_width(GOLD_ROUGH) < 0.1); +} + +test "Schlick's Fresnel: F0 face on, total at grazing, for gold in every channel" { + var ch : usize = 0; + while (ch < 3) { + assert(@abs(fresnel(GOLD_F0[ch], 1.0) - GOLD_F0[ch]) < 1e-12); + assert(@abs(fresnel(GOLD_F0[ch], 0.0) - 1.0) < 1e-12); + ch = ch + 1; + } + assert(fresnel(GLAZE_F0, 0.5) > GLAZE_F0); + assert(fresnel(GLAZE_F0, 0.5) < 0.1); +} + +test "the split sum returns F0 for a mirror seen face on, and never more light than it gathers" { + assert(@abs(spec_scale(1.0, 0.0, 1.0) - 1.0) < 0.001); + assert(@abs(spec_scale(0.04, 0.0, 1.0) - 0.04) < 0.008); + var r : f64 = 0.0; + var worst : f64 = 0.0; + while (r <= 1.0) { + var c : f64 = 0.0; + while (c <= 1.0) { + if (spec_scale(1.0, r, c) > worst) { worst = spec_scale(1.0, r, c); } + assert(env_a(r, c) >= 0.0); + c = c + 0.05; + } + r = r + 0.05; + } + assert(worst <= 1.0); + // a rough surface loses some of its grazing reflection; a smooth one keeps it + assert(spec_scale(0.04, 0.0, 0.05) > spec_scale(0.04, 0.8, 0.05)); +} + +test "the rig's axes are unit vectors and their solid angles are exact" { + var i : usize = 0; + while (i < LIGHTS) { + const n = lf(i, 0) * lf(i, 0) + lf(i, 1) * lf(i, 1) + lf(i, 2) * lf(i, 2); + assert(@abs(n - 1.0) < 0.00001); + i = i + 1; + } + // a cube face seen from the centre is a sixth of the sphere: 4 asin(1/2) = 2 pi / 3 + assert(@abs(4.0 * asin_small(0.5) - 2.0 * PI / 3.0) < 0.001); + // the key is the largest light, the rim strip the smallest + assert(light_omega(0) > light_omega(1) * 2.0); +} + +test "a softbox mirrors as a crisp rectangle: bright on its axis, dark just past its edge" { + var o : [3]f64 = [0.0, 0.0, 0.0]; + env(&o, lf(0, 0), lf(0, 1), lf(0, 2), blur_width(GOLD_ROUGH)); + assert(o[0] > 5.0); + // a direction past the key's left edge: turn the axis sideways by 0.6 in the tangent plane + const un = @sqrt(lf(0, 2) * lf(0, 2) + lf(0, 0) * lf(0, 0)); + const ex = lf(0, 0) - 0.6 * lf(0, 2) / un; + const ez = lf(0, 2) + 0.6 * lf(0, 0) / un; + const en = @sqrt(ex * ex + lf(0, 1) * lf(0, 1) + ez * ez); + var p : [3]f64 = [0.0, 0.0, 0.0]; + env(&p, ex / en, lf(0, 1) / en, ez / en, blur_width(GOLD_ROUGH)); + assert(o[0] > 20.0 * p[0]); + // the softbox's centre is hotter than its rim + assert(light_mask(0, lf(0, 0), lf(0, 1), lf(0, 2), 0.01) > 1.2); +} + +test "gold mirrors in gold, glaze mirrors in white" { + const kx = aim(0, 0); + const ky = aim(0, 1); + // the key's highlight on gold keeps gold's colour + const r = lone(kx, ky, true, 0); + const g = lone(kx, ky, true, 1); + const b = lone(kx, ky, true, 2); + assert(r > 4.0); + assert(g / r > 0.62); + assert(g / r < 0.75); + assert(b / r < 0.35); + // the same highlight on glaze is white: the dielectric's reflection is untinted + const wr = lone(kx, ky, false, 0); + const wg = lone(kx, ky, false, 1); + assert(wg / wr > 0.9); +} + +test "a metal has no body colour: it sends back only what it mirrors" { + // the normal (0, -0.5) mirrors the direction (0, -0.866, 0.5) + var o : [3]f64 = [0.0, 0.0, 0.0]; + shade(&o, 0.0, 0.0 - 0.5, true, NO_NB, false, NO_NB, false); + var e : [3]f64 = [0.0, 0.0, 0.0]; + env(&e, 0.0, 0.0 - 0.866, 0.5, blur_width(GOLD_ROUGH)); + var ch : usize = 0; + while (ch < 3) { + assert(o[ch] <= e[ch] + 0.000001); + ch = ch + 1; + } + // and glaze at the same point still shows its lit body + assert(lone(0.0 - 0.3, 0.0 - 0.3, false, 1) > 0.6); +} + +test "no point of a lone gold ball sends more light than the whole rig could" { + var worst : f64 = 0.0; + var y : f64 = 0.0 - 0.99; + while (y < 1.0) { + var x : f64 = 0.0 - 0.99; + while (x < 1.0) { + if (x * x + y * y < 1.0) { + const v = lone(x, y, true, 0); + if (v > worst) { worst = v; } + } + x = x + 0.03; + } + y = y + 0.03; + } + var rig : f64 = SKY + HZ_GLOW + FLOOR_L + FLOOR_LINE; + var i : usize = 0; + while (i < LIGHTS) { + rig = rig + lf(i, 5) * (1.0 + HOT); + i = i + 1; + } + assert(worst <= rig); + assert(worst > 4.0); +} + +test "the equator mirrors the floor's bright line; the bottom mirrors the dark floor" { + // just below the equator the reflection grazes the floor near the horizon + const eq = lone(0.0, 0.08, true, 0); + const bottom = lone(0.0, 0.75, true, 0); + assert(eq > 3.0 * bottom); +} + +test "a ray from a ball's top pole straight up strikes the ball above; a sideways one misses" { + assert(@abs(ray_sphere(0.0, 0.0 - 1.0, 0.0, 0.0, 0.0 - 1.0, 0.0, 0.0 - 2.2) - 0.2) < 1e-12); + assert(ray_sphere(1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0 - 2.0) < 0.0); + assert(ray_sphere(0.0, 0.0 - 1.0, 0.0, 0.0, 1.0, 0.0, 0.0 - 2.2) < 0.0); +} + +test "a touching neighbour darkens the contact, not the far side" { + // the ball above touches at the top pole + assert(@abs(sphere_occ(0.0, 0.0 - 1.0, 0.0, 0.0 - 2.0) - 1.0) < 1e-12); + assert(sphere_occ(1.0, 0.0, 0.0, 0.0 - 2.0) < 0.4); + assert(sphere_occ(0.0, 1.0, 0.0, 0.0 - 2.0) == 0.0); + // the near-pole of a stacked gold ball mirrors its neighbour, darker than the open sky's top + var o : [3]f64 = [0.0, 0.0, 0.0]; + shade(&o, 0.0, 0.0 - 0.9, true, 0.0 - 2.0, true, 2.0, true); + const lone_top = lone(0.0, 0.0 - 0.9, true, 0); + assert(o[0] != lone_top); +} + +test "the shutter spreads a falling ball but keeps its area" { + const r = 22.0; + const still = swept_area(r, 0.0); + const fast = swept_area(r, 90.0); + assert(@abs(still - PI * r * r) < 0.02 * PI * r * r); + assert(@abs(fast - still) < 0.02 * still); + // smeared over 90 px, no pixel is covered all the time but the middle of the streak is solid + assert(swept_cover(0.5, 0.5, r, 90.0) < 0.5); + assert(swept_cover(0.5, 0.5, r, 90.0) > 0.45); + assert(swept_cover(0.5, 70.0, r, 90.0) == 0.0); + // the shading normal follows the centre: at the streak's head, the head of the disk + assert(swept_mid(0.0, 40.0, r, 90.0) > 20.0); +} diff --git a/specs/t27f/token_floor.t27 b/specs/t27f/token_floor.t27 index 7ac808680c..315847ef2b 100644 --- a/specs/t27f/token_floor.t27 +++ b/specs/t27f/token_floor.t27 @@ -39,22 +39,35 @@ // glow_of(v); the floor mirrors everything with the Fresnel reflectance of lacquer at the angle // the engine's camera sees it (mirror_alpha); light adds and the exposure 1 - exp(-L) never clips. // -// THE OUTPUT: `main` writes FRAMES frames of FRAME_W x FRAME_H raw RGB24 to stderr, row by row, as -// specs/t27f/reel_particles.t27 does; ffmpeg reads them with plain flags -// (`-f rawvideo -pix_fmt rgb24 -s 1080x1920 -r 30 -i frames.rgb`). +// THE FILM (specs/t27f/token_floor_scenes.t27 and specs/t27f/pbr_sphere.t27): the owner, +// 2026-10-18, asked for real-looking balls, a story told in shots instead of one still frame, and +// a horizontal and a vertical cut of the same reel. The stage above is built and run in world px +// exactly as before; each frame the camera of its shot (eight shots: cuts on the beats the voice +// names, two fast moves and a closing dolly) maps it to the screen. Every moving splat is swept +// over the half frame a 180-degree shutter is open, so a falling ball and a flying letter smear as +// a camera sees them, and a cut carries no smear across it. A ball is shaded by pbr_sphere: GGX +// gold or glazed ceramic under four softboxes, with the ball above and the one below reflected in +// it and shadowing it. A landing flashes at the contact; a word's dots start left to right, one +// frame each STAGGER_PX of its width; a glass card with a gold edge and a PASS pill rises over the +// three verdict beats; the lens adds a vignette and half a code value of dither before rounding. +// +// THE OUTPUT: `render(o)` writes FRAMES frames of frame_w(o) x frame_h(o) raw RGB24 to stderr, row +// by row, as specs/t27f/reel_particles.t27 does. `main` writes the 1080x1920 cut, then the +// 1920x1080 cut, the same number of bytes each; split the stream in half and read each with plain +// flags (`-f rawvideo -pix_fmt rgb24 -s 1080x1920 -r 30`, `-s 1920x1080` for the second). // Each row of a frame visits only the splats a per-frame row index lists for it, in the order they -// were splatted; the raster's own check (touches_row) still decides, so the index only skips rows a -// splat cannot light and the bytes are those of a scan over every splat. Without the index each of -// the 1920 rows checked all ~42k splats and one process took 51 s; with it, 33 s on one core. -// The empty scene is the same in every frame, so its bytes and each row's light factors are -// computed once; a row exposes only the pixels a splat visited and copies the rest, 19 s. -// To use more cores, `main` renders shard k of N -// (environment TF_SHARD and TF_SHARDS; unset, 0 of 1, the whole reel): the frames -// [shard_a(k, N), shard_b(k, N)). Frame f's state is the integral from frame 0, so every shard steps -// the physics from frame 0 and skips only the splats and the raster before its range. The shards -// of one N tile [0, FRAMES) in order, so their outputs concatenated are the one-process output -// byte for byte. Unchanged frames need no render at all: specs/t27f/frame_cache.t27 keys them by -// this spec's seal. +// were splatted (#10391); the raster's own check (touches_row) still decides, so the index only +// skips rows a splat cannot light and the bytes are those of a scan over every splat. A frame whose +// index would overflow INDEX_CAP is scanned over every splat instead, so a close-up can never write +// different bytes. The empty scene is not cached across frames as it was for the fixed camera +// (#10407): the camera moves the backdrop and the lens dithers it, so it differs in every frame. +// To use more cores, `main` renders shard k of N (environment TF_SHARD and TF_SHARDS; unset, 0 of +// 1, the whole reel) of the cut TF_CUT names (0 the tall cut, 1 the wide one; unset, both, tall +// first): the frames [shard_a(k, N), shard_b(k, N)). Frame f's state is the integral from frame 0, +// so every shard steps the physics from frame 0 and skips only the splats and the raster before its +// range. The shards of one N and one cut tile [0, FRAMES) in order, so their outputs concatenated +// are that cut's one-process output byte for byte. Unchanged frames need no render at all: +// specs/t27f/frame_cache.t27 keys them by this spec's seal. // // NOT CLAIMED: the ball mass, the drop height, the tube and the light levels are design choices, // labelled as such. The balls are not a model of any computer; they are a bar chart that obeys @@ -79,7 +92,6 @@ use t27f::particle_engine::KP; use t27f::particle_engine::KD; use t27f::particle_engine::contact_f_over_r; use t27f::particle_engine::mirror_alpha; -use t27f::particle_engine::schlick; use t27f::particle_engine::gauss; use t27f::particle_engine::expose; use t27f::particle_engine::glow_of; @@ -97,6 +109,30 @@ use t27f::outfit_atlas::cov; use t27f::outfit_atlas::run_start; use t27f::outfit_atlas::run_end; use t27f::outfit_atlas::em_dots; +use t27f::token_floor_scenes::TALL; +use t27f::token_floor_scenes::WIDE; +use t27f::token_floor_scenes::SHOTS; +use t27f::token_floor_scenes::SCENE_FRAMES; +use t27f::token_floor_scenes::SCENE_FPS; +use t27f::token_floor_scenes::frame_w; +use t27f::token_floor_scenes::frame_h; +use t27f::token_floor_scenes::shot_of; +use t27f::token_floor_scenes::pose; +use t27f::token_floor_scenes::overshoot; +use t27f::token_floor_scenes::safe_f; +use t27f::token_floor_scenes::card_of; +use t27f::token_floor_scenes::card_opacity; +use t27f::token_floor_scenes::card_top; +use t27f::token_floor_scenes::card_f; +use t27f::token_floor_scenes::pill_x; +use t27f::token_floor_scenes::pill_dy; +use t27f::token_floor_scenes::PILL_W; +use t27f::token_floor_scenes::PILL_H; +use t27f::pbr_sphere::shade; +use t27f::pbr_sphere::swept_cover; +use t27f::pbr_sphere::swept_mid; +use t27f::pbr_sphere::SHUTTER; +use t27f::pbr_sphere::NO_NB; pub const ID : str = "token-floor"; pub const VERSION : u8 = 1; @@ -144,13 +180,7 @@ pub const TINT_G : f64 = 0.93; pub const TINT_B : f64 = 0.84; // the key light is warm pub const WALL_A : f64 = 0.14; pub const STREAK_A : f64 = 0.05; -pub const AMB : f64 = 0.08; -pub const SPEC : f64 = 2.2; -pub const RIM : f64 = 0.5; -pub const LX : f64 = -0.42; -pub const LY : f64 = -0.56; -pub const LZ : f64 = 0.714; // the key light from up-left, in front -pub const HALO : f64 = 0.10; +pub const HALO : f64 = 0.04; pub const HALO_R : f64 = 1.25; // ---- the letters ---- @@ -174,7 +204,8 @@ pub const TICK_DELAY : f64 = 3.0; pub const MAX_DOTS : usize = 22528; pub const MAX_SPLATS : usize = 45312; // every dot, its mirror image, and 256 more pub const ROW_BYTES : usize = 3240; -pub const INDEX_CAP : usize = 524288; // entries of the row index; index_bound() fits +pub const INDEX_CAP : usize = 524288; // entries of the row index; index_bound() fits at zoom 1 +pub const INDEX_ROWS : i32 = 1920; // the taller cut's rows; the index has a slot per row // Splat kinds. pub const K_DOT : u8 = 0; @@ -685,9 +716,9 @@ fn back_light(kx: f64, ky: f64, gx: f64, gy: f64, below: bool) -> f64 { return BG * FLOOR_DIM + KEY * FLOOR_KEY * kx * ky + GLINT * gx * gy; } -// What the row whose centre is y gives the light of the empty scene, from y alone: the key light's -// vertical factor (seen in the mirror below the floor), the horizon glint's, and the share of a -// mirror image that survives at that depth. Above the floor the glint and the fade are 0. +// What the row whose centre is y (world px) gives the light of the empty scene, from y alone: the +// key light's vertical factor (seen in the mirror below the floor), the horizon glint's, and the +// share of a mirror image that survives at that depth. Above the floor the glint and the fade are 0. pub fn row_key(y: f64) -> f64 { if (y > FLOOR_PX) { return key_w(2.0 * FLOOR_PX - y - KEY_Y); @@ -709,7 +740,7 @@ pub fn row_fade(y: f64) -> f64 { return 0.0; } -// The byte one channel of light l is written as. +// The byte one channel of light l is written as, before the lens's dither. pub fn light_byte(l: f64) -> u8 { return quantize(expose(l)); } @@ -726,32 +757,121 @@ fn reflect_alpha(y_px: f64) -> f64 { return mirror_alpha((FLOOR_PX - y_px) / PXM, 1.0); } -// The light a glossy ball sends to the camera from the point whose normal is (nx, ny): Lambert -// under the key light, a Blinn highlight, and a Fresnel rim, the lacquer law of the engine. -fn ball_light(nx: f64, ny: f64, base: f64) -> f64 { - var q : f64 = 1.0 - nx * nx - ny * ny; - if (q < 0.0) { q = 0.0; } - const nz = @sqrt(q); - var lam : f64 = nx * LX + ny * LY + nz * LZ; - if (lam < 0.0) { lam = 0.0; } - const hz = LZ + 1.0; - const hn = @sqrt(LX * LX + LY * LY + hz * hz); - var s : f64 = (nx * LX + ny * LY + nz * hz) / hn; - if (s < 0.0) { s = 0.0; } - const s2 = s * s; - const s5 = s2 * s2 * s; - const s10 = s5 * s5; - const s20 = s10 * s10; - const rim = schlick(nz, 1.5) - schlick(1.0, 1.5); - return base * (AMB + (1.0 - AMB) * lam) + SPEC * s20 * s20 + RIM * rim; +// ---- the camera, the shutter, the flashes and the glass card ---- + +// A dot of a label starts STAGGER_PX of its label's width after the one before, up to +// STAGGER_MAX frames: the words write themselves left to right. +pub const STAGGER_PX : f64 = 50.0; +pub const STAGGER_MAX : f64 = 18.0; + +fn stagger(dx: f64) -> i32 { + var f : f64 = @floor(dx / STAGGER_PX); + if (f > STAGGER_MAX) { f = STAGGER_MAX; } + if (f < 0.0) { f = 0.0; } + return @as(i32, @intFromFloat(f)); +} + +// A landing lights the contact for FLASH_F frames: brightest for the white ball, the first gold +// one and the last, the ones the narration names. +pub const FLASH_R : f64 = 70.0; +pub const FLASH_F : i32 = 15; +pub const FLASH_HI : f64 = 0.9; +pub const FLASH_LO : f64 = 0.35; +pub const FLASH_T : f64 = 2.5; + +fn flash_amp(i: usize, dt: i32) -> f64 { + if (dt < 0 || dt > FLASH_F) { + return 0.0; + } + var a : f64 = FLASH_LO; + if (i == 0 || i == 1 || i == BALLS - 1) { a = FLASH_HI; } + return a * @exp(0.0 - @as(f64, @floatFromInt(dt)) / FLASH_T); +} + +// The lens darkens the corners by VIG at the edge of each axis. +pub const VIG : f64 = 0.18; + +fn vignette(u: f64) -> f64 { + return 1.0 - VIG * u * u; +} + +// Half a code value of noise before the 8-bit rounding, so the dark gradients do not band. +fn dither(px: usize, py: i32, f: i32) -> f64 { + const seed = @as(i64, @intCast(px)) + @as(i64, @intCast(py)) * 1920 + @as(i64, @intCast(f)) * 3686400; + return unit(lcg(lcg(seed))) - 0.5; +} + +// The centre of ball j above (k = 1) or below (k = 0) ball i, in ball radii from i's, screen +// frame: above is negative. NO_NB when there is none, it is not yet dropped, or it is further than +// four radii. +fn neighbour_at(y: *[20]f64, i: usize, k: i64, frame: i32) -> f64 { + if (k == 1) { + if (i + 1 >= BALLS) { return NO_NB; } + if (frame < release_frame(i + 1)) { return NO_NB; } + const u = (y[i + 1] - y[i]) / R_PX; + if (@abs(u) > 4.0) { return NO_NB; } + return u; + } + if (i == 0) { return NO_NB; } + const d = (y[i - 1] - y[i]) / R_PX; + if (@abs(d) > 4.0) { return NO_NB; } + return d; +} + +// The signed distance from (dx, dy) to a rounded rectangle of half sizes hw, hh and corner r +// centred on the origin, negative inside. +fn rrect(dx: f64, dy: f64, hw: f64, hh: f64, r: f64) -> f64 { + const qx = @abs(dx) - hw + r; + const qy = @abs(dy) - hh + r; + var mx : f64 = qx; + if (mx < 0.0) { mx = 0.0; } + var my : f64 = qy; + if (my < 0.0) { my = 0.0; } + var inn : f64 = qx; + if (qy > inn) { inn = qy; } + if (inn > 0.0) { inn = 0.0; } + return @sqrt(mx * mx + my * my) + inn - r; +} + +// The distance from (x, y) to the segment (x0, y0)-(x1, y1). +fn seg_dist(x: f64, y: f64, x0: f64, y0: f64, x1: f64, y1: f64) -> f64 { + const ux = x1 - x0; + const uy = y1 - y0; + const t = clamp01(((x - x0) * ux + (y - y0) * uy) / (ux * ux + uy * uy)); + const ex = x - x0 - t * ux; + const ey = y - y0 - t * uy; + return @sqrt(ex * ex + ey * ey); +} + +// The card's glass darkens what is behind it by GLASS, its foot casts a shadow SHADOW_DY below, +// and its border is a thin gold line. +pub const GLASS : f64 = 0.55; +pub const SHADOW : f64 = 0.45; +pub const SHADOW_DY : f64 = 10.0; +pub const SHADOW_W : f64 = 24.0; +pub const BORDER : f64 = 0.35; +pub const SHEEN : f64 = 0.05; +pub const SHEEN_H : f64 = 30.0; +pub const FILL : [3]f64 = [0.010, 0.012, 0.016]; +pub const PASS_COL : [3]f64 = [0.25, 1.6, 0.6]; +pub const CHECK_HW : f64 = 1.75; + +// The light a pixel of the card adds and keeps, at sdf distance sd (negative inside) from its +// outline, y px below its top, opacity a: 0 the share of the scene kept, 1 the light added. +fn glass(sd: f64, y: f64, a: f64, k: i64) -> f64 { + const ins = clamp01(0.5 - sd) * a; + if (k == 0) { + return 1.0 - GLASS * ins; + } + return ins * SHEEN * @exp(0.0 - y / SHEEN_H); } fn reel_ms() -> i32 { return FRAMES * 1000 / FPS; } -fn frame_bytes() -> i32 { - return FRAME_W * FRAME_H * 3; +fn cut_bytes(o: usize) -> i32 { + return frame_w(o) * frame_h(o) * 3; } // ---- shards ---- @@ -797,6 +917,18 @@ pub fn shard_k(k: i32, n: i32) -> i32 { return k; } +// Whether main renders cut o when TF_CUT reads c: 0 the tall cut, 1 the wide one, anything else +// both, so a mistyped TF_CUT renders the whole stream rather than none of it. +pub fn cut_runs(c: i32, o: usize) -> bool { + if (c == 0) { + return o == TALL; + } + if (c == 1) { + return o == WIDE; + } + return true; +} + // The environment variable `name` as a count; dflt when it is unset. Only main calls this, so the // tests, which never link libc, do not reach getenv. fn env_count(name: [*:0]const u8, dflt: i32) -> i32 { @@ -811,7 +943,8 @@ fn env_count(name: [*:0]const u8, dflt: i32) -> i32 { // Whether a splat of kind k (m 1 for a mirror image) can light the row whose centre is y: an image // lives only below the floor, a vertical line between its ends sy and sw, anything else within its -// reach sc of its centre sy. This is the raster's test, and the only one it makes. +// reach sc of its centre sy. This is the raster's test. Through a camera the floor is a world +// height, so the raster asks the image question itself and passes m 0 here. pub fn touches_row(k: u8, m: u8, sy: f64, sw: f64, sc: f64, y: f64) -> bool { if (m == 1 && (y > FLOOR_PX) == false) { return false; @@ -844,19 +977,20 @@ pub fn splat_bot(k: u8, sy: f64, sw: f64, sc: f64) -> f64 { } // The rows the index lists a splat under: every row whose centre lies in [top, bot], and one more -// on each side, clipped to the frame. The margin covers rounding; the raster still asks touches_row, -// so a listed row the splat misses costs a test and changes no byte. row_lo > row_hi lists none. +// on each side, clipped to INDEX_ROWS. The margin covers rounding; the raster still asks +// touches_row, so a listed row the splat misses costs a test and changes no byte. row_lo > row_hi +// lists none. pub fn row_lo(top: f64) -> i32 { var r : f64 = @floor(top - 0.5) - 1.0; if (r < 0.0) { r = 0.0; } - if (r > @as(f64, @floatFromInt(FRAME_H))) { r = @as(f64, @floatFromInt(FRAME_H)); } + if (r > @as(f64, @floatFromInt(INDEX_ROWS))) { r = @as(f64, @floatFromInt(INDEX_ROWS)); } return @as(i32, @intFromFloat(r)); } pub fn row_hi(bot: f64) -> i32 { var r : f64 = @ceil(bot - 0.5) + 1.0; if (r < -1.0) { r = -1.0; } - if (r > @as(f64, @floatFromInt(FRAME_H - 1))) { r = @as(f64, @floatFromInt(FRAME_H - 1)); } + if (r > @as(f64, @floatFromInt(INDEX_ROWS - 1))) { r = @as(f64, @floatFromInt(INDEX_ROWS - 1)); } return @as(i32, @intFromFloat(r)); } @@ -865,8 +999,10 @@ pub fn span_max(ext: f64) -> i64 { return @as(i64, @intFromFloat(@ceil(ext))) + 5; } -// The most entries the index can hold in any frame: every dot and its image, the counter's pool, -// each ball's halo, body and image, and the five vertical lines, each at its tallest band. +// The most entries the index can hold in a frame seen at zoom 1 with no shutter smear: every dot +// and its image, the counter's pool, each ball's halo, landing flash, body and image, and the five +// vertical lines, each at its tallest band. A close-up can list more; the raster then scans every +// splat. pub fn index_bound() -> i64 { var b : i64 = 0; var l : usize = 0; @@ -877,21 +1013,33 @@ pub fn index_bound() -> i64 { l = l + 1; } b = b + @as(i64, @intCast(POOL)) * span_max(2.0 * (DOT_R * grid(EXP_PITCH) + 1.0)); - b = b + @as(i64, @intCast(BALLS)) * (span_max(2.0 * 3.0 * HALO_R * R_PX) + 2 * span_max(2.0 * (R_PX + 1.0))); + b = b + @as(i64, @intCast(BALLS)) * (span_max(2.0 * 3.0 * HALO_R * R_PX) + span_max(2.0 * 3.0 * FLASH_R) + 2 * span_max(2.0 * (R_PX + 1.0))); b = b + 2 * span_max(REFLECT_SPAN) + 3 * span_max(FLOOR_PX - TUBE_TOP); return b; } // ---- the engine ---- -pub fn main() -> void { - const sk0 = env_count("TF_SHARD", 0); - const sn0 = env_count("TF_SHARDS", 1); - const fa = shard_a(shard_k(sk0, sn0), shard_n(sk0, sn0)); - const fb = shard_b(shard_k(sk0, sn0), shard_n(sk0, sn0)); +// The reel through orientation o's cameras: FRAMES frames of frame_w(o) x frame_h(o) raw RGB24 on +// stderr. The stage is built and run in world px as before; each frame the camera of its shot +// maps it to the screen, and the shutter sweeps every moving splat over the half frame it is open. +// Only the frames [f_lo, f_hi) are written; the physics runs from frame 0 to f_hi. +fn render(o: usize, f_lo: i32, f_hi: i32) -> void { + const wi = frame_w(o); + const hi = frame_h(o); + const wf = @as(f64, @floatFromInt(wi)); + const hf = @as(f64, @floatFromInt(hi)); + const hw2 = 0.5 * wf; + const hh2 = 0.5 * hf; + const fps = @as(f64, @floatFromInt(FPS)); var by : [20]f64 = undefined; + var byo : [20]f64 = undefined; var bv : [20]f64 = undefined; var ba : [20]f64 = undefined; + var yp : [20]f64 = undefined; + var ypo : [20]f64 = undefined; + var bup : [20]f64 = undefined; + var bdn : [20]f64 = undefined; var landed : [20]i32 = undefined; var bext : [2]f64 = [1000000000.0, 1000000000.0]; var i : usize = 0; @@ -918,6 +1066,7 @@ pub fn main() -> void { var dvx : [22528]f64 = undefined; var dvy : [22528]f64 = undefined; var dw : [22528]f64 = undefined; + var dst : [22528]i32 = undefined; var nd : usize = 0; var lfirst : [53]usize = undefined; var lnum : [53]usize = undefined; @@ -949,6 +1098,7 @@ pub fn main() -> void { dtx[nd] = ix + (@as(f64, @floatFromInt(cc - rs)) + 0.5) * q; dty[nd] = y0 + (@as(f64, @floatFromInt(@as(i64, @intCast(r)) - CAP_TOP)) + 0.5) * q; dw[nd] = weight(cc, r); + dst[nd] = stagger(dtx[nd] - x0); dx[nd] = dtx[nd]; dy[nd] = dty[nd]; dvx[nd] = 0.0; @@ -985,68 +1135,54 @@ pub fn main() -> void { ci0 = ci0 + 1; } + // splats: built in world px with where they were a frame ago (spx, spy), then mapped to the + // screen with their shutter sweep (sex, sey, its length sel); sb is a ball's index var sx : [45312]f64 = undefined; var sy : [45312]f64 = undefined; + var spx : [45312]f64 = undefined; + var spy : [45312]f64 = undefined; var sr : [45312]f64 = undefined; var sw : [45312]f64 = undefined; var sa : [45312]f64 = undefined; var sc : [45312]f64 = undefined; + var sex : [45312]f64 = undefined; + var sey : [45312]f64 = undefined; + var sel : [45312]f64 = undefined; var scr : [45312]f64 = undefined; var scg : [45312]f64 = undefined; var scb : [45312]f64 = undefined; var sk : [45312]u8 = undefined; var sm : [45312]u8 = undefined; - var accr : [1080]f64 = undefined; - var accg : [1080]f64 = undefined; - var accb : [1080]f64 = undefined; - var kxa : [1080]f64 = undefined; - var gxa : [1080]f64 = undefined; - var row : [3240]u8 = undefined; + var sb : [45312]usize = undefined; + var accr : [1920]f64 = undefined; + var accg : [1920]f64 = undefined; + var accb : [1920]f64 = undefined; + var kxa : [1920]f64 = undefined; + var gxa : [1920]f64 = undefined; + var vxa : [1920]f64 = undefined; + var rowt : [3240]u8 = undefined; var roff : [1921]usize = undefined; var rcur : [1920]usize = undefined; var rsp : [524288]u16 = undefined; + var roww : [5760]u8 = undefined; + var lt : [3]f64 = [0.0, 0.0, 0.0]; var px : usize = 0; - while (px < 1080) { - const x = @as(f64, @floatFromInt(px)) + 0.5; - kxa[px] = key_w(x - KEY_X); - gxa[px] = glint_x(x - TUBE_X); - px = px + 1; - } - // the empty scene is the same in every frame: its row factors and its bytes are computed once, - // and a pixel no splat visits in a frame is written from them - var kya : [1920]f64 = undefined; - var gya : [1920]f64 = undefined; - var fra : [1920]f64 = undefined; - var bgq : [6220800]u8 = undefined; - var dirty : [1080]bool = undefined; - var py0 : usize = 0; - while (py0 < 1920) { - const y = @as(f64, @floatFromInt(py0)) + 0.5; - kya[py0] = row_key(y); - gya[py0] = row_glint(y); - fra[py0] = row_fade(y); - px = 0; - while (px < 1080) { - const b = back_light(kxa[px], kya[py0], gxa[px], gya[py0], y > FLOOR_PX); - const o = py0 * ROW_BYTES + px * 3; - bgq[o] = light_byte(b * TINT_R); - bgq[o + 1] = light_byte(b * TINT_G); - bgq[o + 2] = light_byte(b * TINT_B); - px = px + 1; - } - py0 = py0 + 1; - } - px = 0; - while (px < 1080) { - dirty[px] = false; + const pw = @as(usize, @intCast(wi)); + while (px < pw) { + vxa[px] = vignette((@as(f64, @floatFromInt(px)) + 0.5 - hw2) / hw2); px = px + 1; } const mir0 = reflect_alpha(FLOOR_PX); - // the physics runs from frame 0 to the shard's last frame; the picture only inside [fa, fb) + // the physics runs from frame 0 to the shard's last frame; the picture only inside [f_lo, f_hi) var frame : i32 = 0; - while (frame < fb) { + while (frame < f_hi) { // ---- physics: the column, then what its landings start and stop ---- + i = 0; + while (i < BALLS) { + byo[i] = by[i]; + i = i + 1; + } stack_step(&by, &bv, &ba, &landed, &bext, frame); l = 0; while (l < LABELS) { @@ -1056,9 +1192,9 @@ pub fn main() -> void { if (tr < 0) { lon[l] = dl0; } else { - const lt = landed[@as(usize, @intCast(tr))]; - if (lt >= 0) { - lon[l] = lt + dl0; + const lt0 = landed[@as(usize, @intCast(tr))]; + if (lt0 >= 0) { + lon[l] = lt0 + dl0; } } } @@ -1069,8 +1205,8 @@ pub fn main() -> void { var d : usize = 0; while (d < nd) { const ld = dl[d]; - const on = lon[ld]; - if (is_counter(ld) == false && on >= 0 && frame >= on) { + const on = lon[ld] + dst[d]; + if (is_counter(ld) == false && lon[ld] >= 0 && frame >= on) { if (frame == on) { var seed : i64 = lcg(@as(i64, @intCast(d)) * 7919 + 101); const u1 = unit(seed); @@ -1142,9 +1278,22 @@ pub fn main() -> void { } pi = pi + 1; } + // the balls' centres now and a frame ago, and what each one has above and below it + i = 0; + while (i < BALLS) { + yp[i] = FLOOR_PX - by[i] * PXM; + ypo[i] = FLOOR_PX - byo[i] * PXM; + i = i + 1; + } + i = 0; + while (i < BALLS) { + bup[i] = neighbour_at(&yp, i, 1, frame); + bdn[i] = neighbour_at(&yp, i, 0, frame); + i = i + 1; + } - // ---- splats, back to front ---- - if (frame < fa) { + // ---- splats, back to front, in world px ---- + if (frame < f_lo) { frame = frame + 1; continue; } @@ -1154,27 +1303,32 @@ pub fn main() -> void { while (w < 2) { sx[n] = TUBE_X - TUBE_HALF + 2.0 * TUBE_HALF * @as(f64, @floatFromInt(w)); sy[n] = FLOOR_PX; sw[n] = FLOOR_PX + REFLECT_SPAN; sr[n] = 0.8; sa[n] = WALL_A * mir0; - scr[n] = 1.0; scg[n] = 1.0; scb[n] = 1.0; sk[n] = K_VLINE; sm[n] = 1; n = n + 1; + scr[n] = 1.0; scg[n] = 1.0; scb[n] = 1.0; sk[n] = K_VLINE; sm[n] = 1; + spx[n] = sx[n]; spy[n] = sy[n]; sb[n] = 0; n = n + 1; w = w + 1; } // the letters and their images d = 0; while (d < nd) { const ld = dl[d]; - const on = lon[ld]; - if (is_counter(ld) == false && on >= 0 && frame >= on) { + const on = lon[ld] + dst[d]; + if (is_counter(ld) == false && lon[ld] >= 0 && frame >= on) { const a = clamp01(@as(f64, @floatFromInt(frame - on + 1)) / RAMP); if (a > 0.0) { const ci = @as(usize, @intFromFloat(lbl_f(ld, 3))) * 3; const q = grid(lbl_f(ld, 2)); const gl = glow_of(dvx[d] / PXM, dvy[d] / PXM) * dw[d]; + const ox = dx[d] - dvx[d] / fps; + const oy = dy[d] - dvy[d] / fps; sx[n] = dx[d]; sy[n] = dy[d]; sr[n] = DOT_R * q; sw[n] = 0.0; sa[n] = a * gl; scr[n] = COL[ci]; scg[n] = COL[ci + 1]; scb[n] = COL[ci + 2]; sk[n] = K_DOT; sm[n] = 0; + spx[n] = ox; spy[n] = oy; sb[n] = 0; n = n + 1; if (dy[d] < FLOOR_PX && FLOOR_PX - dy[d] < REFLECT_SPAN) { sx[n] = dx[d]; sy[n] = 2.0 * FLOOR_PX - dy[d]; sr[n] = DOT_R * q; sw[n] = 0.0; sa[n] = a * gl * reflect_alpha(dy[d]); scr[n] = COL[ci]; scg[n] = COL[ci + 1]; scb[n] = COL[ci + 2]; sk[n] = K_DOT; sm[n] = 1; + spx[n] = ox; spy[n] = 2.0 * FLOOR_PX - oy; sb[n] = 0; n = n + 1; } } @@ -1188,26 +1342,38 @@ pub fn main() -> void { sx[n] = cx[pi]; sy[n] = cy[pi]; sr[n] = DOT_R * grid(EXP_PITCH); sw[n] = 0.0; sa[n] = ca[pi] * cw[pi] * glow_of(cvx[pi] / PXM, cvy[pi] / PXM); scr[n] = COL[0]; scg[n] = COL[1]; scb[n] = COL[2]; sk[n] = K_DOT; sm[n] = 0; + spx[n] = cx[pi] - cvx[pi] / fps; spy[n] = cy[pi] - cvy[pi] / fps; sb[n] = 0; n = n + 1; } pi = pi + 1; } - // the balls, their halos and their images + // the balls, their halos, their landing flashes and their images i = 0; while (i < BALLS) { if (frame >= release_frame(i)) { var bc : usize = 3; if (i == 0) { bc = 0; } - const yp = FLOOR_PX - by[i] * PXM; - sx[n] = TUBE_X; sy[n] = yp; sr[n] = HALO_R * R_PX; sw[n] = 0.0; sa[n] = HALO * glow_of(0.0, bv[i]); + sx[n] = TUBE_X; sy[n] = yp[i]; sr[n] = HALO_R * R_PX; sw[n] = 0.0; sa[n] = HALO * glow_of(0.0, bv[i]); scr[n] = BALL_COL[bc]; scg[n] = BALL_COL[bc + 1]; scb[n] = BALL_COL[bc + 2]; sk[n] = K_GLOW; sm[n] = 0; + spx[n] = TUBE_X; spy[n] = yp[i]; sb[n] = i; n = n + 1; - sx[n] = TUBE_X; sy[n] = yp; sr[n] = R_PX; sw[n] = 0.0; sa[n] = 1.0; - scr[n] = BALL_COL[bc]; scg[n] = BALL_COL[bc + 1]; scb[n] = BALL_COL[bc + 2]; sk[n] = K_BALL; sm[n] = 0; + if (landed[i] >= 0) { + const fa = flash_amp(i, frame - landed[i]); + if (fa > 0.0) { + sx[n] = TUBE_X; sy[n] = yp[i] + R_PX; sr[n] = FLASH_R; sw[n] = 0.0; sa[n] = fa; + scr[n] = BALL_COL[bc]; scg[n] = BALL_COL[bc + 1]; scb[n] = BALL_COL[bc + 2]; sk[n] = K_GLOW; sm[n] = 0; + spx[n] = TUBE_X; spy[n] = sy[n]; sb[n] = i; + n = n + 1; + } + } + sx[n] = TUBE_X; sy[n] = yp[i]; sr[n] = R_PX; sw[n] = 0.0; sa[n] = 1.0; + scr[n] = 1.0; scg[n] = 1.0; scb[n] = 1.0; sk[n] = K_BALL; sm[n] = 0; + spx[n] = TUBE_X; spy[n] = ypo[i]; sb[n] = i; n = n + 1; - if (yp < FLOOR_PX && FLOOR_PX - yp < REFLECT_SPAN) { - sx[n] = TUBE_X; sy[n] = 2.0 * FLOOR_PX - yp; sr[n] = R_PX; sw[n] = 0.0; sa[n] = reflect_alpha(yp); - scr[n] = BALL_COL[bc]; scg[n] = BALL_COL[bc + 1]; scb[n] = BALL_COL[bc + 2]; sk[n] = K_BALLF; sm[n] = 1; + if (yp[i] < FLOOR_PX && FLOOR_PX - yp[i] < REFLECT_SPAN) { + sx[n] = TUBE_X; sy[n] = 2.0 * FLOOR_PX - yp[i]; sr[n] = R_PX; sw[n] = 0.0; sa[n] = reflect_alpha(yp[i]); + scr[n] = 1.0; scg[n] = 1.0; scb[n] = 1.0; sk[n] = K_BALLF; sm[n] = 1; + spx[n] = TUBE_X; spy[n] = 2.0 * FLOOR_PX - ypo[i]; sb[n] = i; n = n + 1; } } @@ -1218,21 +1384,99 @@ pub fn main() -> void { while (w < 2) { sx[n] = TUBE_X - TUBE_HALF + 2.0 * TUBE_HALF * @as(f64, @floatFromInt(w)); sy[n] = TUBE_TOP; sw[n] = FLOOR_PX; sr[n] = 0.8; sa[n] = WALL_A; - scr[n] = 1.0; scg[n] = 1.0; scb[n] = 1.0; sk[n] = K_VLINE; sm[n] = 0; n = n + 1; + scr[n] = 1.0; scg[n] = 1.0; scb[n] = 1.0; sk[n] = K_VLINE; sm[n] = 0; + spx[n] = sx[n]; spy[n] = sy[n]; sb[n] = 0; n = n + 1; w = w + 1; } sx[n] = TUBE_X - TUBE_HALF + 7.0; sy[n] = TUBE_TOP; sw[n] = FLOOR_PX; sr[n] = 1.6; sa[n] = STREAK_A; - scr[n] = 1.0; scg[n] = 1.0; scb[n] = 1.0; sk[n] = K_VLINE; sm[n] = 0; n = n + 1; - + scr[n] = 1.0; scg[n] = 1.0; scb[n] = 1.0; sk[n] = K_VLINE; sm[n] = 0; + spx[n] = sx[n]; spy[n] = sy[n]; sb[n] = 0; n = n + 1; + + // ---- the camera: world to screen, each splat swept over the open shutter ---- + // A frame's previous position is seen through the same shot's camera, so a cut has no blur. + const s = shot_of(frame); + const ff = @as(f64, @floatFromInt(frame)); + const zc = pose(o, s, ff, 2); + const cxc = pose(o, s, ff, 0); + const cyc = pose(o, s, ff, 1); + const zp = pose(o, s, ff - 1.0, 2); + const cxp = pose(o, s, ff - 1.0, 0); + const cyp = pose(o, s, ff - 1.0, 1); + var m : usize = 0; var j : usize = 0; while (j < n) { - if (sk[j] == K_GLOW) { - sc[j] = 3.0 * sr[j]; + const k = sk[j]; + const nx0 = (sx[j] - cxc) * zc + hw2; + const ny0 = (sy[j] - cyc) * zc + hh2; + var ex : f64 = SHUTTER * (nx0 - ((spx[j] - cxp) * zp + hw2)); + var ey : f64 = SHUTTER * (ny0 - ((spy[j] - cyp) * zp + hh2)); + var r : f64 = sr[j] * zc; + var a : f64 = sa[j]; + var yb : f64 = 0.0; + var cxs : f64 = nx0; + var cys : f64 = ny0; + var ext : f64 = 0.0; + var keep : bool = true; + if (k == K_VLINE) { + // a line keeps its light per unit length as it thins and as the camera smears it + yb = (sw[j] - cyc) * zc + hh2; + var r2 : f64 = r; + if (r2 < 0.5) { r2 = 0.5; } + const wide = r2 + 0.5 * @abs(ex); + a = a * r / wide; + r = wide; + ex = 0.0; + ey = 0.0; + ext = r + 1.0; + if (ny0 > hf || yb < 0.0 || nx0 + ext < 0.0 || nx0 - ext > wf) { keep = false; } } else { - sc[j] = sr[j] + 1.0; + if (k == K_GLOW) { + ex = 0.0; + ey = 0.0; + ext = 3.0 * r; + } else { + cxs = nx0 - 0.5 * ex; + cys = ny0 - 0.5 * ey; + ext = r + 1.0 + 0.5 * @sqrt(ex * ex + ey * ey); + } + if (cxs + ext < 0.0 || cxs - ext > wf || cys + ext < 0.0 || cys - ext > hf) { keep = false; } + } + if (keep) { + sx[m] = cxs; sy[m] = cys; sr[m] = r; sw[m] = yb; sa[m] = a; sc[m] = ext; + sex[m] = ex; sey[m] = ey; sel[m] = @sqrt(ex * ex + ey * ey); + scr[m] = scr[j]; scg[m] = scg[j]; scb[m] = scb[j]; sk[m] = k; sm[m] = sm[j]; sb[m] = sb[j]; + m = m + 1; } j = j + 1; } + n = m; + // the backdrop's columns, through this frame's camera + px = 0; + while (px < pw) { + const x = (@as(f64, @floatFromInt(px)) + 0.5 - hw2) / zc + cxc; + kxa[px] = key_w(x - KEY_X); + gxa[px] = glint_x(x - TUBE_X); + px = px + 1; + } + // the card up now, in screen px + const cid = card_of(frame); + var cal : f64 = 0.0; + var ctop : f64 = 0.0; + var cu : usize = 0; + if (cid >= 0) { + cu = @as(usize, @intCast(cid)); + cal = card_opacity(cu, ff); + ctop = card_top(o, cu, ff); + } + const cx0 = card_f(o, 0); + const cwd = card_f(o, 2); + const cht = card_f(o, 3); + const crr = card_f(o, 4); + const ccx = cx0 + 0.5 * cwd; + const ccy = ctop + 0.5 * cht; + const plx = pill_x(o); + const pcx = plx + 0.5 * PILL_W; + const pcy = ctop + pill_dy(o) + 0.5 * PILL_H; // ---- the row index: each row lists the splats that can touch it, in splat order ---- var ri : usize = 0; @@ -1257,76 +1501,108 @@ pub fn main() -> void { rcur[ri] = roff[ri]; ri = ri + 1; } - j = 0; - while (j < n) { - var rq : i32 = row_lo(splat_top(sk[j], sy[j], sc[j])); - const rh = row_hi(splat_bot(sk[j], sy[j], sw[j], sc[j])); - while (rq <= rh) { - const ru = @as(usize, @intCast(rq)); - rsp[rcur[ru]] = @as(u16, @intCast(j)); - rcur[ru] = rcur[ru] + 1; - rq = rq + 1; + // a frame whose index would not fit is scanned over every splat, row by row + const indexed = roff[1920] <= INDEX_CAP; + if (indexed) { + j = 0; + while (j < n) { + var rq : i32 = row_lo(splat_top(sk[j], sy[j], sc[j])); + const rh = row_hi(splat_bot(sk[j], sy[j], sw[j], sc[j])); + while (rq <= rh) { + const ru = @as(usize, @intCast(rq)); + rsp[rcur[ru]] = @as(u16, @intCast(j)); + rcur[ru] = rcur[ru] + 1; + rq = rq + 1; + } + j = j + 1; } - j = j + 1; } // ---- raster: one row at a time, over the splats its index lists ---- - // A pixel takes the empty scene's light the first time a splat visits it in the row and is - // marked dirty; only dirty pixels are exposed, the rest are copied from bgq. A visit that adds - // nothing leaves the light as it was, so a dirty pixel writes the byte bgq holds for it. var py : i32 = 0; - while (py < FRAME_H) { + while (py < hi) { const y = @as(f64, @floatFromInt(py)) + 0.5; - const below = y > FLOOR_PX; + const wy = (y - hh2) / zc + cyc; + const below = wy > FLOOR_PX; + const ky = row_key(wy); + const gy = row_glint(wy); + const fr = row_fade(wy); const pu = @as(usize, @intCast(py)); - const ky = kya[pu]; - const gy = gya[pu]; - const fr = fra[pu]; - var ei : usize = roff[pu]; - while (ei < roff[pu + 1]) { - j = @as(usize, @intCast(rsp[ei])); + var ei : usize = 0; + var ee : usize = n; + if (indexed) { + ei = roff[pu]; + ee = roff[pu + 1]; + } + px = 0; + while (px < pw) { + const b = back_light(kxa[px], ky, gxa[px], gy, below); + accr[px] = b * TINT_R; + accg[px] = b * TINT_G; + accb[px] = b * TINT_B; + px = px + 1; + } + while (ei < ee) { + j = ei; + if (indexed) { + j = @as(usize, @intCast(rsp[ei])); + } const k = sk[j]; var dyy : f64 = y - sy[j]; if (k == K_VLINE) { dyy = 0.0; } - if (touches_row(k, sm[j], sy[j], sw[j], sc[j], y)) { + // an image lives below the floor, a world height; the rest is the index's own test + if ((sm[j] == 0 || below) && touches_row(k, 0, sy[j], sw[j], sc[j], y)) { var mf : f64 = 1.0; if (sm[j] == 1) { mf = fr; } var x0 : f64 = @floor(sx[j] - sc[j]); var x1 : f64 = @ceil(sx[j] + sc[j]); if (x0 < 0.0) { x0 = 0.0; } - if (x1 > 1079.0) { x1 = 1079.0; } + if (x1 > wf - 1.0) { x1 = wf - 1.0; } var q : i32 = @as(i32, @intFromFloat(x0)); const qe = @as(i32, @intFromFloat(x1)); + const el = sel[j]; + var ux : f64 = 0.0; + var uy : f64 = 0.0; + if (el >= 1.0) { + ux = sex[j] / el; + uy = sey[j] / el; + } while (q <= qe) { const ddx = @as(f64, @floatFromInt(q)) + 0.5 - sx[j]; const kk = @as(usize, @intCast(q)); - if (dirty[kk] == false) { - const b = back_light(kxa[kk], ky, gxa[kk], gy, below); - accr[kk] = b * TINT_R; - accg[kk] = b * TINT_G; - accb[kk] = b * TINT_B; - dirty[kk] = true; - } if (k == K_BALL || k == K_BALLF) { - const dd = @sqrt(ddx * ddx + dyy * dyy); - const cov = clamp01(sr[j] - dd + 0.5) * sa[j]; + var cov : f64 = 0.0; + var nx : f64 = 0.0; + var ny : f64 = 0.0; + if (el < 1.0) { + cov = swept_cover(ddx, dyy, sr[j], 0.0); + nx = ddx / sr[j]; + ny = dyy / sr[j]; + } else { + const al = ddx * ux + dyy * uy; + const pe = dyy * ux - ddx * uy; + cov = swept_cover(pe, al, sr[j], el); + if (cov > 0.0) { + const mid = swept_mid(pe, al, sr[j], el); + nx = (ddx - mid * ux) / sr[j]; + ny = (dyy - mid * uy) / sr[j]; + } + } + cov = cov * sa[j]; if (cov > 0.0) { - const nx = ddx / sr[j]; - var ny : f64 = dyy / sr[j]; + const bi = sb[j]; if (k == K_BALLF) { ny = 0.0 - ny; } - const lr = ball_light(nx, ny, scr[j]); - const lg = ball_light(nx, ny, scg[j]); - const lb = ball_light(nx, ny, scb[j]); + shade(<, nx, ny, bi > 0, bup[bi], true, bdn[bi], bi > 1); if (k == K_BALL) { - accr[kk] = accr[kk] * (1.0 - cov) + lr * cov; - accg[kk] = accg[kk] * (1.0 - cov) + lg * cov; - accb[kk] = accb[kk] * (1.0 - cov) + lb * cov; + accr[kk] = accr[kk] * (1.0 - cov) + lt[0] * cov; + accg[kk] = accg[kk] * (1.0 - cov) + lt[1] * cov; + accb[kk] = accb[kk] * (1.0 - cov) + lt[2] * cov; } else { - accr[kk] = accr[kk] + lr * cov * mf; - accg[kk] = accg[kk] + lg * cov * mf; - accb[kk] = accb[kk] + lb * cov * mf; + accr[kk] = accr[kk] + lt[0] * cov * mf; + accg[kk] = accg[kk] + lt[1] * cov * mf; + accb[kk] = accb[kk] + lt[2] * cov * mf; } } } else { @@ -1337,7 +1613,11 @@ pub fn main() -> void { if (k == K_VLINE) { wt = clamp01(sr[j] - @abs(ddx) + 0.5); } else { - wt = clamp01(sr[j] - @sqrt(ddx * ddx + dyy * dyy) + 0.5); + if (el < 1.0) { + wt = swept_cover(ddx, dyy, sr[j], 0.0); + } else { + wt = swept_cover(dyy * ux - ddx * uy, ddx * ux + dyy * uy, sr[j], el); + } } } const e = wt * sa[j] * mf; @@ -1350,28 +1630,97 @@ pub fn main() -> void { } ei = ei + 1; } - const o = pu * ROW_BYTES; + // the glass card over the scene: its shadow, its glass, its PASS pill and its gold edge + if (cal > 0.0 && y > ctop - 30.0 && y < ctop + cht + SHADOW_DY + SHADOW_W + 6.0) { + var x0 : f64 = @floor(cx0 - 30.0); + var x1 : f64 = @ceil(cx0 + cwd + 30.0); + if (x0 < 0.0) { x0 = 0.0; } + if (x1 > wf - 1.0) { x1 = wf - 1.0; } + var q : usize = @as(usize, @intFromFloat(x0)); + const qe = @as(usize, @intFromFloat(x1)); + while (q <= qe) { + const x = @as(f64, @floatFromInt(q)) + 0.5; + const sd = rrect(x - ccx, y - ccy, 0.5 * cwd, 0.5 * cht, crr); + if (sd > 0.0) { + const s2 = rrect(x - ccx, y - ccy - SHADOW_DY, 0.5 * cwd, 0.5 * cht, crr); + const sh = 1.0 - SHADOW * cal * clamp01(1.0 - s2 / SHADOW_W); + accr[q] = accr[q] * sh; + accg[q] = accg[q] * sh; + accb[q] = accb[q] * sh; + } else { + const keepl = glass(sd, y - ctop, cal, 0); + const add = glass(sd, y - ctop, cal, 1); + const ins = clamp01(0.5 - sd) * cal; + accr[q] = accr[q] * keepl + add + ins * FILL[0]; + accg[q] = accg[q] * keepl + add + ins * FILL[1]; + accb[q] = accb[q] * keepl + add + ins * FILL[2]; + const pd = rrect(x - pcx, y - pcy, 0.5 * PILL_W, 0.5 * PILL_H, 0.5 * PILL_H); + const pc = clamp01(0.5 - pd) * cal; + if (pc > 0.0) { + accr[q] = accr[q] * (1.0 - pc) + pc * PASS_COL[0]; + accg[q] = accg[q] * (1.0 - pc) + pc * PASS_COL[1]; + accb[q] = accb[q] * (1.0 - pc) + pc * PASS_COL[2]; + var cd : f64 = seg_dist(x, y, plx + 18.0, pcy, plx + 25.0, pcy + 7.0); + const cd2 = seg_dist(x, y, plx + 25.0, pcy + 7.0, plx + 38.0, pcy - 8.0); + if (cd2 < cd) { cd = cd2; } + const kc2 = clamp01(CHECK_HW + 0.5 - cd) * pc; + accr[q] = accr[q] * (1.0 - kc2); + accg[q] = accg[q] * (1.0 - kc2); + accb[q] = accb[q] * (1.0 - kc2); + } + } + const bd = BORDER * cal * clamp01(1.2 - @abs(sd)); + accr[q] = accr[q] + bd * COL[0]; + accg[q] = accg[q] + bd * COL[1]; + accb[q] = accb[q] + bd * COL[2]; + q = q + 1; + } + } + // the lens: vignette, then half a code value of dither before the rounding + const vy = vignette((y - hh2) / hh2); px = 0; - while (px < 1080) { - if (dirty[px]) { - row[px * 3] = light_byte(accr[px]); - row[px * 3 + 1] = light_byte(accg[px]); - row[px * 3 + 2] = light_byte(accb[px]); - dirty[px] = false; + while (px < pw) { + const v = vxa[px] * vy; + const dn = dither(px, py, frame); + const r8 = quantize(expose(accr[px] * v) + dn); + const g8 = quantize(expose(accg[px] * v) + dn); + const b8 = quantize(expose(accb[px] * v) + dn); + if (o == WIDE) { + roww[px * 3] = r8; + roww[px * 3 + 1] = g8; + roww[px * 3 + 2] = b8; } else { - row[px * 3] = bgq[o + px * 3]; - row[px * 3 + 1] = bgq[o + px * 3 + 1]; - row[px * 3 + 2] = bgq[o + px * 3 + 2]; + rowt[px * 3] = r8; + rowt[px * 3 + 1] = g8; + rowt[px * 3 + 2] = b8; } px = px + 1; } - std.debug.print("{s}", .{row}); + if (o == WIDE) { + std.debug.print("{s}", .{roww}); + } else { + std.debug.print("{s}", .{rowt}); + } py = py + 1; } frame = frame + 1; } } +pub fn main() -> void { + const sk0 = env_count("TF_SHARD", 0); + const sn0 = env_count("TF_SHARDS", 1); + const cut = env_count("TF_CUT", 2); + const fa = shard_a(shard_k(sk0, sn0), shard_n(sk0, sn0)); + const fb = shard_b(shard_k(sk0, sn0), shard_n(sk0, sn0)); + if (cut_runs(cut, TALL)) { + render(TALL, fa, fb); + } + if (cut_runs(cut, WIDE)) { + render(WIDE, fa, fb); + } +} + // ---- tests ---- test "every number on the screen is the number physical_limits computes" { @@ -1485,68 +1834,7 @@ test "every dot and its image fit the buffers" { assert(total_dots() > 8000); assert(total_dots() <= @as(i64, @intCast(MAX_DOTS))); assert(2 * MAX_DOTS + 256 == MAX_SPLATS); - assert(POOL + 3 * BALLS + 5 <= 256); -} - -test "the row index lists every row a splat touches, and no more than span_max rows" { - // a dot, a halo, a ball and a vertical line, each at 300 centres from above the frame to below - // it, a third of a pixel apart, then as mirror images - var touched : i64 = 0; - var missed : i64 = 0; - var wide : i64 = 0; - var m : u8 = 0; - while (m < 2) { - var c : i64 = 0; - while (c < 4) { - var k : u8 = K_DOT; - var sc : f64 = DOT_R * grid(9.0) + 1.0; - if (c == 1) { k = K_GLOW; sc = 3.0 * HALO_R * R_PX; } - if (c == 2) { k = K_BALL; sc = R_PX + 1.0; } - if (c == 3) { k = K_VLINE; sc = 0.8 + 1.0; } - var t : i64 = 0; - while (t < 300) { - const sy = 0.0 - 200.0 + @as(f64, @floatFromInt(t)) * 7.7 + @as(f64, @floatFromInt(t)) / 3.0; - const sw = sy + 360.25; - const lo = row_lo(splat_top(k, sy, sc)); - const hi = row_hi(splat_bot(k, sy, sw, sc)); - if (hi - lo + 1 > span_max(splat_bot(k, sy, sw, sc) - splat_top(k, sy, sc))) { - wide = wide + 1; - } - var py : i32 = 0; - while (py < FRAME_H) { - if (touches_row(k, m, sy, sw, sc, @as(f64, @floatFromInt(py)) + 0.5)) { - touched = touched + 1; - if (py < lo || py > hi) { - missed = missed + 1; - } - } - py = py + 1; - } - t = t + 1; - } - c = c + 1; - } - m = m + 1; - } - assert(missed == 0); - assert(wide == 0); - assert(touched > 100000); - // a splat wholly above the frame is listed under no row; one at its last row only there - assert(row_lo(splat_top(K_DOT, 0.0 - 300.0, 3.0)) > row_hi(splat_bot(K_DOT, 0.0 - 300.0, 0.0, 3.0))); - assert(row_hi(splat_bot(K_DOT, 1919.5, 0.0, 0.25)) == FRAME_H - 1); - // an image is never on a row above the floor, a vertical line never off its ends - assert(touches_row(K_DOT, 1, FLOOR_PX, 0.0, 3.0, FLOOR_PX - 0.5) == false); - assert(touches_row(K_DOT, 0, FLOOR_PX, 0.0, 3.0, FLOOR_PX - 0.5)); - assert(touches_row(K_VLINE, 0, TUBE_TOP, FLOOR_PX, 1.8, TUBE_TOP - 0.5) == false); - assert(touches_row(K_VLINE, 0, TUBE_TOP, FLOOR_PX, 1.8, TUBE_TOP + 0.5)); -} - -test "the row index fits its buffer in every frame, and a u16 names every splat" { - assert(index_bound() <= @as(i64, @intCast(INDEX_CAP))); - assert(index_bound() > 2 * total_dots() * 5); - assert(MAX_SPLATS <= 65536); - assert(span_max(70.0) == 75); - assert(span_max(70.25) == 76); + assert(POOL + 4 * BALLS + 5 <= 256); } test "a dot reaches its letter on the critically damped path and never overshoots" { @@ -1632,6 +1920,263 @@ test "the field is near-black and a lit dot is bright without clipping" { assert(expose(COL[2]) < expose(COL[1])); } +test "a ball sees the ball above it and the one below it, and nothing past the column's ends" { + var y : [20]f64 = undefined; + var i : usize = 0; + while (i < BALLS) { + y[i] = rest_px(i); + i = i + 1; + } + // at rest the next ball up is just under two radii above, negative in the screen frame + const u = neighbour_at(&y, 5, 1, FRAMES - 1); + assert(u < 0.0 - 1.8); + assert(u > 0.0 - 2.0); + assert(neighbour_at(&y, 5, 0, FRAMES - 1) > 1.8); + assert(neighbour_at(&y, 0, 0, FRAMES - 1) == NO_NB); + assert(neighbour_at(&y, BALLS - 1, 1, FRAMES - 1) == NO_NB); + // a ball not yet dropped is not there + assert(neighbour_at(&y, 5, 1, release_frame(6) - 1) == NO_NB); + // one still far above is not seen + y[6] = y[5] - 10.0 * R_PX; + assert(neighbour_at(&y, 5, 1, FRAMES - 1) == NO_NB); +} + +test "the reel is 15.000 s at 30 fps in both frames, 6220800 bytes a frame either way" { + assert(reel_ms() == 15000); + assert(FRAMES == SCENE_FRAMES); + assert(FPS == SCENE_FPS); + assert(cut_bytes(TALL) == 6220800); + assert(cut_bytes(WIDE) == 6220800); + assert(ROW_BYTES == 3240); + assert(frame_w(WIDE) * 3 == 5760); +} + +test "the last ball lands in time for the closing lines to settle" { + // the closing lines start 66 frames after the last landing, their last dot STAGGER_MAX frames + // later, and they need about 90 frames to settle + assert(land_frame(19) + 66 + 18 + 90 < FRAMES); + assert(@as(f64, @floatFromInt(stagger(10000.0))) == STAGGER_MAX); + assert(release_frame(19) == 258); +} + +test "a word writes itself left to right, one frame every STAGGER_PX" { + assert(stagger(0.0) == 0); + assert(stagger(49.9) == 0); + assert(stagger(50.0) == 1); + assert(stagger(0.0 - 5.0) == 0); + // the widest line, 0, takes most of the cap + assert(stagger(label_width(0)) >= 8); + assert(@as(f64, @floatFromInt(stagger(label_width(0)))) <= STAGGER_MAX); +} + +test "a landing flashes for half a second, brightest for the balls the voice names" { + assert(flash_amp(0, 0) == FLASH_HI); + assert(flash_amp(1, 0) == FLASH_HI); + assert(flash_amp(BALLS - 1, 0) == FLASH_HI); + assert(flash_amp(7, 0) == FLASH_LO); + assert(flash_amp(7, 0 - 1) == 0.0); + assert(flash_amp(7, FLASH_F + 1) == 0.0); + assert(flash_amp(0, 5) < 0.15 * FLASH_HI); + assert(flash_amp(0, 5) > 0.13 * FLASH_HI); +} + +test "the dither is half a code value either way and changes every frame" { + var lo : f64 = 1.0; + var hi : f64 = 0.0 - 1.0; + var same : i64 = 0; + var k : usize = 0; + while (k < 4000) { + const v = dither(k, 7, 3); + if (v < lo) { lo = v; } + if (v > hi) { hi = v; } + if (v == dither(k, 7, 4)) { same = same + 1; } + k = k + 1; + } + assert(lo >= 0.0 - 0.5); + assert(hi < 0.5); + assert(lo < 0.0 - 0.49); + assert(hi > 0.49); + assert(same < 5); + // the lens: centre untouched, a corner VIG down on each axis + assert(vignette(0.0) == 1.0); + assert(@abs(vignette(1.0) - (1.0 - VIG)) < 1e-12); +} + +test "the card's outline is a rounded rectangle and its check mark a pair of segments" { + assert(@abs(rrect(0.0, 0.0, 100.0, 40.0, 10.0) + 40.0) < 1e-12); + assert(@abs(rrect(100.0, 0.0, 100.0, 40.0, 10.0)) < 1e-12); + // past a corner the distance is to the arc, not to the box + const c = rrect(100.0, 40.0, 100.0, 40.0, 10.0); + assert(c > 4.0); + assert(c < 4.2); + assert(@abs(seg_dist(5.0, 3.0, 0.0, 0.0, 10.0, 0.0) - 3.0) < 1e-12); + assert(@abs(seg_dist(13.0, 4.0, 0.0, 0.0, 10.0, 0.0) - 5.0) < 1e-12); + // the glass keeps less of the scene inside than outside, and adds a sheen at the top only + assert(glass(0.0 - 5.0, 0.0, 1.0, 0) == 1.0 - GLASS); + assert(glass(5.0, 0.0, 1.0, 0) == 1.0); + assert(glass(0.0 - 5.0, 0.0, 1.0, 1) > glass(0.0 - 5.0, 120.0, 1.0, 1) * 10.0); +} + +// The box label l rests in, world px: k 0..3 for x0, y0, x1, y1, from a grid row above the cap to +// two below it. +fn label_box(l: usize, k: usize) -> f64 { + const x0 = lbl_f(l, 0); + const y0 = lbl_f(l, 1); + const p = lbl_f(l, 2); + if (k == 0) { return x0; } + if (k == 1) { return y0 - grid(p); } + if (k == 2) { return x0 + label_width(l); } + return y0 + 7.0 * p + 2.0 * grid(p); +} + +// The box labels l0..l1 rest in together, world px, k 0..3. +fn union_box(l0: usize, l1: usize, k: usize) -> f64 { + var m : f64 = label_box(l0, k); + var l = l0 + 1; + while (l <= l1) { + const v = label_box(l, k); + if (k < 2 && v < m) { m = v; } + if (k >= 2 && v > m) { m = v; } + l = l + 1; + } + return m; +} + +fn either(a: f64, b: f64, k: usize) -> f64 { + if (k < 2) { + if (a < b) { return a; } + return b; + } + if (a > b) { return a; } + return b; +} + +// What shot s is about, as a world box, k 0..3: the title, the white ball on the floor with its +// image, the floor's three lines, the counter, (4: the column itself, a pan), the bill, the +// verdict, and the whole infographic with the tube and its ticks. +fn shot_subject(s: usize, k: usize) -> f64 { + if (s == 0) { return union_box(0, 1, k); } + if (s == 1) { + if (k == 0) { return TUBE_X - R_PX; } + if (k == 1) { return rest_px(0) - R_PX; } + if (k == 2) { return TUBE_X + R_PX; } + return FLOOR_PX + R_PX; + } + if (s == 2) { return union_box(2, 4, k); } + if (s == 3) { return either(union_box(5, 5, k), union_box(FIXED + 18, FIXED + 18, k), k); } + if (s == 5) { return union_box(6, 8, k); } + if (s == 6) { return union_box(9, 10, k); } + return either(union_box(0, FIXED - 1, k), union_box(FIXED + 19, LABELS - 1, k), k); +} + +// The worst overshoot of shot s's subject past orientation o's safe rect, screen px: the closing +// dolly from frame 430, the pan up the column not at all. +fn shot_over(o: usize, s: usize) -> f64 { + if (s == 4) { return 0.0; } + var from : f64 = 0.0; + if (s == 7) { from = 430.0; } + return overshoot(o, s, from, shot_subject(s, 0), shot_subject(s, 1), shot_subject(s, 2), shot_subject(s, 3)); +} + +// The pose that sets shot s's subject in the middle of o's safe rect, filling share fill of it, +// k 0 cx, 1 cy, 2 z: the probe CAM's poses were read from. +fn frame_fit(o: usize, s: usize, fill: f64, k: usize) -> f64 { + const bw = shot_subject(s, 2) - shot_subject(s, 0); + const bh = shot_subject(s, 3) - shot_subject(s, 1); + const sw = safe_f(o, 2) - safe_f(o, 0); + const sh = safe_f(o, 3) - safe_f(o, 1); + var z : f64 = sw / bw; + if (sh / bh < z) { z = sh / bh; } + z = z * fill; + if (k == 2) { return z; } + if (k == 0) { + return 0.5 * (shot_subject(s, 0) + shot_subject(s, 2)) - (0.5 * (safe_f(o, 0) + safe_f(o, 2)) - 0.5 * @as(f64, @floatFromInt(frame_w(o)))) / z; + } + return 0.5 * (shot_subject(s, 1) + shot_subject(s, 3)) - (0.5 * (safe_f(o, 1) + safe_f(o, 3)) - 0.5 * @as(f64, @floatFromInt(frame_h(o)))) / z; +} + +test "every shot frames what it is about, in both frames" { + var o : usize = 0; + var worst : f64 = 0.0; + while (o < 2) { + var s : usize = 0; + while (s < SHOTS) { + const v = shot_over(o, s); + std.debug.print("frame {d} shot {d} over {d:.2} fit {d:.1} {d:.1} {d:.3} / {d:.1} {d:.1} {d:.3}\n", .{ o, s, v, frame_fit(o, s, 0.86, 0), frame_fit(o, s, 0.86, 1), frame_fit(o, s, 0.86, 2), frame_fit(o, s, 0.94, 0), frame_fit(o, s, 0.94, 1), frame_fit(o, s, 0.94, 2) }); + if (v > worst) { worst = v; } + s = s + 1; + } + o = o + 1; + } + assert(worst < 1.0); + // and the check bites: the white ball's shot does not frame the title + assert(overshoot(TALL, 1, 0.0, shot_subject(0, 0), shot_subject(0, 1), shot_subject(0, 2), shot_subject(0, 3)) > 100.0); +} + +test "the row index lists every row a splat touches, and no more than span_max rows" { + // a dot, a halo, a ball and a vertical line, each at 300 centres from above the frame to below + // it, a third of a pixel apart, then as mirror images + var touched : i64 = 0; + var missed : i64 = 0; + var wide : i64 = 0; + var m : u8 = 0; + while (m < 2) { + var c : i64 = 0; + while (c < 4) { + var k : u8 = K_DOT; + var sc : f64 = DOT_R * grid(9.0) + 1.0; + if (c == 1) { k = K_GLOW; sc = 3.0 * HALO_R * R_PX; } + if (c == 2) { k = K_BALL; sc = R_PX + 1.0; } + if (c == 3) { k = K_VLINE; sc = 0.8 + 1.0; } + var t : i64 = 0; + while (t < 300) { + const sy = 0.0 - 200.0 + @as(f64, @floatFromInt(t)) * 7.7 + @as(f64, @floatFromInt(t)) / 3.0; + const sw = sy + 360.25; + const lo = row_lo(splat_top(k, sy, sc)); + const hi = row_hi(splat_bot(k, sy, sw, sc)); + if (hi - lo + 1 > span_max(splat_bot(k, sy, sw, sc) - splat_top(k, sy, sc))) { + wide = wide + 1; + } + var py : i32 = 0; + while (py < INDEX_ROWS) { + if (touches_row(k, m, sy, sw, sc, @as(f64, @floatFromInt(py)) + 0.5)) { + touched = touched + 1; + if (py < lo || py > hi) { + missed = missed + 1; + } + } + py = py + 1; + } + t = t + 1; + } + c = c + 1; + } + m = m + 1; + } + assert(missed == 0); + assert(wide == 0); + assert(touched > 100000); + // a splat wholly above the frame is listed under no row; one at its last row only there + assert(row_lo(splat_top(K_DOT, 0.0 - 300.0, 3.0)) > row_hi(splat_bot(K_DOT, 0.0 - 300.0, 0.0, 3.0))); + assert(row_hi(splat_bot(K_DOT, 1919.5, 0.0, 0.25)) == INDEX_ROWS - 1); + // an image is never on a row above the floor, a vertical line never off its ends + assert(touches_row(K_DOT, 1, FLOOR_PX, 0.0, 3.0, FLOOR_PX - 0.5) == false); + assert(touches_row(K_DOT, 0, FLOOR_PX, 0.0, 3.0, FLOOR_PX - 0.5)); + assert(touches_row(K_VLINE, 0, TUBE_TOP, FLOOR_PX, 1.8, TUBE_TOP - 0.5) == false); + assert(touches_row(K_VLINE, 0, TUBE_TOP, FLOOR_PX, 1.8, TUBE_TOP + 0.5)); + // the index has a slot for every row of the taller cut + assert(INDEX_ROWS == frame_h(TALL)); + assert(INDEX_ROWS >= frame_h(WIDE)); +} + +test "the row index fits its buffer at zoom 1, and a u16 names every splat" { + assert(index_bound() <= @as(i64, @intCast(INDEX_CAP))); + assert(index_bound() > 2 * total_dots() * 5); + assert(MAX_SPLATS <= 65536); + assert(span_max(70.0) == 75); + assert(span_max(70.25) == 76); +} + test "a row's light comes from its centre alone" { // on the key light's centre line its factor is exactly 1, and the mirror sees that line as far // below the floor as the key is above it @@ -1650,10 +2195,10 @@ test "a row's light comes from its centre alone" { var bad : i64 = 0; var seen : i64 = 0; var py : i32 = 0; - while (py < FRAME_H) { + while (py < frame_h(TALL)) { const y = @as(f64, @floatFromInt(py)) + 0.5; var px : i32 = 0; - while (px < FRAME_W) { + while (px < frame_w(TALL)) { const x = @as(f64, @floatFromInt(px)) + 0.5; const b = back_light(key_w(x - KEY_X), row_key(y), glint_x(x - TUBE_X), row_glint(y), y > FLOOR_PX); if (b != backdrop(x, y)) { @@ -1668,12 +2213,12 @@ test "a row's light comes from its centre alone" { assert(seen == 23100); } -test "the empty scene's bytes are the ones the reel writes where no splat has been" { +test "the empty scene's bytes before the lens are the ones the fixed-camera reel wrote" { assert(light_byte(0.0) == 0); assert(light_byte(100.0) == 255); assert(light_byte(BG) == 10); - // read off frame 0 of the reel, whose pixels here no splat reaches: the corners, the key - // light's centre, and the floor line by the tube, just above it and just below + // read off frame 0 of the fixed-camera reel (#10407), whose pixels here no splat reached: the + // corners, the key light's centre, and the floor line by the tube, just above it and below assert(light_byte(backdrop(0.5, 0.5) * TINT_R) == 11); assert(light_byte(backdrop(0.5, 0.5) * TINT_G) == 10); assert(light_byte(backdrop(0.5, 0.5) * TINT_B) == 9); @@ -1687,26 +2232,6 @@ test "the empty scene's bytes are the ones the reel writes where no splat has be assert(light_byte(backdrop(859.5, 1640.5) * TINT_B) == 29); } -test "a ball is lit from up-left, has a rim, and its mirror image is lit from below" { - const hx = -0.23; - const hy = -0.30; - assert(ball_light(hx, hy, 1.0) > 2.0); - assert(ball_light(0.0 - hx, 0.0 - hy, 1.0) < 0.5); - assert(ball_light(0.7, 0.7, 0.0) > ball_light(0.0, 0.0, 0.0)); -} - -test "the reel is 15.000 s of 1080x1920 at 30 fps, 6220800 bytes a frame" { - assert(reel_ms() == 15000); - assert(frame_bytes() == 6220800); - assert(ROW_BYTES == 3240); -} - -test "the last ball lands in time for the closing lines to settle" { - // the closing lines start 66 frames after the last landing and need about 90 frames to settle - assert(land_frame(19) + 66 + 90 < FRAMES); - assert(release_frame(19) == 258); -} - test "the shards of every n from 1 to 64 tile the reel in order, with no gap and no overlap" { var n : i32 = 1; while (n <= 64) { @@ -1759,3 +2284,14 @@ test "a shard that cannot be rendered as asked renders the whole reel" { assert(shard_a(shard_k(9, 4), shard_n(9, 4)) == 0); assert(shard_b(shard_k(9, 4), shard_n(9, 4)) == FRAMES); } + +test "TF_CUT picks one cut, and anything but 0 or 1 renders both, tall first" { + assert(cut_runs(0, TALL)); + assert(cut_runs(0, WIDE) == false); + assert(cut_runs(1, WIDE)); + assert(cut_runs(1, TALL) == false); + assert(cut_runs(2, TALL)); + assert(cut_runs(2, WIDE)); + assert(cut_runs(7, TALL) && cut_runs(7, WIDE)); + assert(TALL != WIDE); +} diff --git a/specs/t27f/token_floor_mux.t27 b/specs/t27f/token_floor_mux.t27 index 8aa7235265..dccf57a4f2 100644 --- a/specs/t27f/token_floor_mux.t27 +++ b/specs/t27f/token_floor_mux.t27 @@ -1,12 +1,15 @@ // SPDX-License-Identifier: Apache-2.0 -// specs/t27f/token_floor_mux.t27 -- the owner's voice, the yellow word captions and the site's -// music over the frames of specs/t27f/token_floor.t27, each phrase spoken at the physics event it -// names. +// specs/t27f/token_floor_mux.t27 -- the owner's voice, the gold word captions, the scene tags, the +// spec cards' text and the site's music over the frames of specs/t27f/token_floor.t27, each phrase +// spoken at the physics event it names, for the tall 1080x1920 cut and the wide 1920x1080 one. // // WHY: the owner asked for the token-floor infographic to be voiced the way the first reel was: // their own voice, one-word captions, the music underneath. The recipe of that reel (loudness, the -// caption look, the tail, the music fades, the limiter) is specs/automation/owner_voice_reel.t27; -// the time printers are specs/t27f/reel_particles_mux.t27. Neither is restated here. +// caption tail, the music fades, the limiter) is specs/automation/owner_voice_reel.t27; the time +// printers are specs/t27f/reel_particles_mux.t27. Neither is restated here. The owner, 2026-10-18: +// the captions in the same gold as the reel, one style; and a story of scenes, not one scene. The +// shots, their tags and the cards are specs/t27f/token_floor_scenes.t27; this module sets their +// text over the frames. // // THE SCRIPT (Russian; English gloss): P0 "one token against physics", P1 "the white ball is the // Landauer floor", P2 "each gold ball: times ten", P3 "19 balls, 19 decades", P4 "a real token: @@ -24,15 +27,23 @@ // it is not a word) and P4's "1,7" came as two tokens, joined into one caption. The words are // Russian, so they live in data files WORD_DIR/wKK.txt and only their lengths are here. // -// THE CAPTIONS: the v1 reel centred them at y=1600; here that is the floor's labels. The free -// band on this frame is the left column between the bill label and the floor labels, left of the -// tube, so captions are left-aligned at BAND_X, centred on CAPTION_Y, and sized to fit BAND_W. -// They are set in t27.ai's own caption face, JetBrains Mono ExtraBold, not the v1 reel's Inter. +// THE TYPE: everything is set in t27.ai's own mono face, JetBrains Mono ExtraBold, and coloured +// with the reel's own tone curve: the gold is particle_engine's expose of token_floor's gold ball +// light, so a caption is the colour of the balls it names, not a yellow picked by hand. Two +// levels, as in tetsuo's interfaces: the captions are big and gold; the scene tags and the card's +// path line are small and grey, with the tag's number in gold. A tag slides in from the left at +// each shot; the card's lines rise with the glass card the frames draw, on the same critically +// damped path and at the same opacity. // -// THE OUTPUT: `main` writes the graph to stderr, for `ffmpeg -filter_complex_script`. Inputs: -// 0 = the frames, 1..6 = the phrases p0.wav..p5.wav, 7 = the music (bgmusic.mp3). +// THE CAPTIONS sit in the band token_floor_scenes leaves for them: in the tall cut under the card, +// in the wide one at the foot of the interface column right of the scene. They are left-aligned +// at band_x and sized to fit band_w with their border. // -// NOT CLAIMED: nothing in CI runs the graph; it is checked by the reel it makes. +// THE OUTPUT: `main` writes two graphs to stderr, one line each, the tall cut's and then the wide +// cut's, for `ffmpeg -filter_complex_script`. Inputs: 0 = the frames, 1..6 = the phrases +// p0.wav..p5.wav, 7 = the music (bgmusic.mp3). +// +// NOT CLAIMED: nothing in CI runs the graphs; they are checked by the reels they make. // phi^2 + 1/phi^2 = 3 | TRINITY module token_floor_mux; @@ -53,31 +64,73 @@ use automation::owner_voice_reel::font_size; use t27f::reel_particles_mux::MUSIC_FADE_OUT_MS; use t27f::reel_particles_mux::secs; use t27f::reel_particles_mux::millis; +use t27f::particle_engine::expose; use t27f::token_floor::FPS; -use t27f::token_floor::FIXED; -use t27f::token_floor::TUBE_X; -use t27f::token_floor::TUBE_HALF; +use t27f::token_floor::COL; use t27f::token_floor::lbl_f; use t27f::token_floor::lbl_trig; use t27f::token_floor::land_frame; +use t27f::token_floor_scenes::TALL; +use t27f::token_floor_scenes::WIDE; +use t27f::token_floor_scenes::SHOTS; +use t27f::token_floor_scenes::SHOT_START; +use t27f::token_floor_scenes::SHOT_KIND; +use t27f::token_floor_scenes::CUT; +use t27f::token_floor_scenes::TAG_X; +use t27f::token_floor_scenes::TAG_Y; +use t27f::token_floor_scenes::TAG_FONT; +use t27f::token_floor_scenes::tag_text; +use t27f::token_floor_scenes::CARDS; +use t27f::token_floor_scenes::CARD_ON; +use t27f::token_floor_scenes::CARD_IN; +use t27f::token_floor_scenes::CARD_OUT; +use t27f::token_floor_scenes::CARD_RISE; +use t27f::token_floor_scenes::CARD_W; +use t27f::token_floor_scenes::CARD_LY; +use t27f::token_floor_scenes::CARD_PAD; +use t27f::token_floor_scenes::PILL_W; +use t27f::token_floor_scenes::PILL_H; +use t27f::token_floor_scenes::card_f; +use t27f::token_floor_scenes::card_line; +use t27f::token_floor_scenes::pill_x; +use t27f::token_floor_scenes::pill_dy; +use t27f::token_floor_scenes::safe_f; +use t27f::token_floor_scenes::band_x; +use t27f::token_floor_scenes::band_w; +use t27f::token_floor_scenes::caption_y; +use t27f::token_floor_scenes::frame_w; +use t27f::token_floor_scenes::frame_h; pub const ID : str = "token-floor-mux"; pub const WORD_DIR : str = "/root/tf/w"; -// ---- the caption face: t27.ai's JetBrains Mono ExtraBold ---- +// ---- the face: t27.ai's JetBrains Mono ExtraBold ---- // t27.ai serves JetBrains Mono as variable woff2 files, one per script. ffmpeg's drawtext reads // no variation axes, so each was cut to a static wght=800 TTF on the lab (fontTools varLib // instancer). The Cyrillic subset has no digits, so the three words that are numbers ("19", "19", -// "1,7") use the Latin one. Every glyph is 600 of the 1000-unit em wide. +// "1,7") use the Latin one, as do the tags and the cards. Every glyph is 600 of the 1000-unit em +// wide. pub const CAPTION_FONT_CYR : str = "/root/tf/fonts/jbm-cyr-800.ttf"; pub const CAPTION_FONT_LAT : str = "/root/tf/fonts/jbm-lat-800.ttf"; pub const MONO_ADVANCE_PERMILLE : i32 = 600; -// ---- the caption band (design choices, px) ---- -pub const BAND_X : i32 = 60; -pub const BAND_W : i32 = 720; -pub const CAPTION_Y : i32 = 1250; -pub const GLYPH_ROWS : f64 = 8.2; // a label is 7 pitches to its cap; a comma hangs 14/12 of that +// ---- the colours: the reel's own tone curve over token_floor's lights ---- +// COL rows: 0 the gold balls, 1 the white ball, 2 a unit light, 3 the grey. +pub const GOLD_ROW : usize = 0; +pub const WHITE_ROW : usize = 1; +pub const GREY_ROW : usize = 2; + +// One channel of a COL light as the byte the frames carry. +fn tone8(k: usize) -> u32 { + var v : f64 = @floor(expose(COL[k]) + 0.5); + if (v > 255.0) { v = 255.0; } + return @as(u32, @intFromFloat(v)); +} + +fn print_col(row: usize) -> void { + std.debug.print("0x{X:0>2}{X:0>2}{X:0>2}", .{ tone8(3 * row), tone8(3 * row + 1), tone8(3 * row + 2) }); +} + // ---- the phrases ---- pub const PHRASES : usize = 6; @@ -96,6 +149,7 @@ pub const WORD_CHARS : [24]i32 = [4, 5, 6, 6, 5, 3, 6, 9, 6, 7, 3, 8, 2, 6, 2, 5 // The words written in digits: P3's two "19" and P4's "1,7". pub const WORD_LATIN : [24]bool = [false, false, false, false, false, false, false, false, false, false, false, false, false, false, true, false, true, false, false, false, true, false, false, false]; + // ---- the events ---- // The frame label l lights: its delay after the clock or after its ball lands. @@ -154,45 +208,152 @@ fn caption_font(k: usize) -> str { return CAPTION_FONT_CYR; } -// The v1 size rule, narrowed to the band, at the mono face's advance. -fn band_font(chars: i32) -> i32 { - const fit : i32 = @divTrunc(BAND_W * 1000, chars * MONO_ADVANCE_PERMILLE); +// The v1 size rule, narrowed to the frame's band less the border on both sides, at the mono +// face's advance. +fn band_font(o: usize, chars: i32) -> i32 { + const room_px : i32 = @divTrunc((band_w(o) - 2 * CAPTION_BORDER_PX) * 1000, chars * MONO_ADVANCE_PERMILLE); const v1 = font_size(chars); - if (fit < v1) { - return fit; + if (room_px < v1) { + return room_px; } return v1; } -// True when a box starting at x, from top to bottom, stays clear of the caption band: either right -// of it, or wholly above or below the tallest caption. -fn box_clear(x: f64, top: f64, bottom: f64) -> bool { - const band_top = @as(f64, @floatFromInt(CAPTION_Y - CAPTION_FONT_MAX)); - const band_bottom = @as(f64, @floatFromInt(CAPTION_Y + CAPTION_FONT_MAX)); - if (x >= @as(f64, @floatFromInt(BAND_X + BAND_W))) { - return true; +// The box the tallest caption can ink: x0, y0, x1, y1, with its border and shadow. +fn caption_box(o: usize, k: usize) -> f64 { + const half = @as(f64, @floatFromInt(@divTrunc(CAPTION_FONT_MAX, 2) + CAPTION_BORDER_PX)); + const cy = @as(f64, @floatFromInt(caption_y(o))); + if (k == 0) { return @as(f64, @floatFromInt(band_x(o))); } + if (k == 1) { return cy - half; } + if (k == 2) { return @as(f64, @floatFromInt(band_x(o) + band_w(o))); } + return cy + half + @as(f64, @floatFromInt(CAPTION_SHADOW_Y_PX)); +} + +// True when two boxes (x0, y0, x1, y1) share any area. +fn rects_meet(ax0: f64, ay0: f64, ax1: f64, ay1: f64, bx0: f64, by0: f64, bx1: f64, by1: f64) -> bool { + return ax0 < bx1 && bx0 < ax1 && ay0 < by1 && by0 < ay1; +} + +// ---- the beats: a hard cut comes just before a word or an event, so the eye arrives first ---- +pub const BEAT_LEAD_MS : i32 = 450; + +// From ms to the next word start or event, or 100000 when there is none. +fn beat_gap(ms: i32) -> i32 { + var g : i32 = 100000; + var k : usize = 0; + while (k < WORDS) { + const d = word_from(k) - ms; + if (d >= 0 && d < g) { g = d; } + k = k + 1; + } + var p : usize = 0; + while (p < PHRASES) { + const d = event_ms(p) - ms; + if (d >= 0 && d < g) { g = d; } + p = p + 1; + } + return g; +} + +// ---- the scene tags: grey, the number gold, sliding in at each shot ---- +pub const TAG_SLIDE_F : i32 = 8; +pub const TAG_SLIDE_PX : i32 = 28; + +fn print_tag(o: usize, s: usize, number: bool) -> void { + const a = SHOT_START[s]; + const b = SHOT_START[s + 1] - 1; + if (number) { + std.debug.print(",drawtext=fontfile={s}:text='{d:0>2}':fontsize={d}:fontcolor=", .{ CAPTION_FONT_LAT, s + 1, TAG_FONT[o] }); + print_col(GOLD_ROW); + } else { + std.debug.print(",drawtext=fontfile={s}:text='{s}':fontsize={d}:fontcolor=", .{ CAPTION_FONT_LAT, tag_text(s), TAG_FONT[o] }); + print_col(GREY_ROW); } - return bottom < band_top || top > band_bottom; + std.debug.print(":x='{d}-{d}*pow(1-clip((n-{d})/{d},0,1),3)':y={d}:alpha='clip((n-{d})/{d},0,1)':enable='between(n,{d},{d})'", .{ TAG_X[o], TAG_SLIDE_PX, a, TAG_SLIDE_F, TAG_Y[o], a, TAG_SLIDE_F, a, b }); +} + +// ---- the cards' text: three lines and PASS, on the glass the frames draw ---- +pub const PASS_DX : f64 = 50.0; // right of the check mark, which ends 38 px in +pub const PASS_FONT : [2]i32 = [26, 24]; + +fn card_font(o: usize, l: usize) -> i32 { + if (l == 0) { return @as(i32, @intFromFloat(@floor(0.75 * card_f(o, 5) + 0.5))); } + return @as(i32, @intFromFloat(card_f(o, 5))); } -fn label_clear(l: usize) -> bool { - return box_clear(lbl_f(l, 0), lbl_f(l, 1), lbl_f(l, 1) + GLYPH_ROWS * lbl_f(l, 2)); +fn line_row(l: usize) -> usize { + if (l == 0) { return GREY_ROW; } + if (l == 1) { return WHITE_ROW; } + return GOLD_ROW; } -pub fn main() -> void { +// v, the card's ramp, as an ffmpeg expression in the frame number n: the same clip as card_opacity +fn print_ramp(c: usize) -> void { + std.debug.print("clip(min((n-{d})/{d:.1},({d}-n)/{d:.1}),0,1)", .{ CARD_ON[2 * c], CARD_IN, CARD_ON[2 * c + 1] + 1, CARD_OUT }); +} + +// the text's top: its place below the card's top, plus card_drop +fn print_rise(o: usize, c: usize, dy: f64) -> void { + std.debug.print(":y='{d:.1}+{d:.1}*(1+{d:.1}*max(0,(n-{d})/{d}))*exp(-{d:.1}*max(0,(n-{d})/{d}))", .{ card_f(o, 1) + dy, CARD_RISE, CARD_W, CARD_ON[2 * c], FPS, CARD_W, CARD_ON[2 * c], FPS }); +} + +// the smootherstep of the ramp, as card_opacity has it, and the frames the card is up +fn print_fade(c: usize) -> void { + std.debug.print(":alpha='pow(", .{}); + print_ramp(c); + std.debug.print(",3)*(", .{}); + print_ramp(c); + std.debug.print("*(6*", .{}); + print_ramp(c); + std.debug.print("-15)+10)':enable='between(n,{d},{d})'", .{ CARD_ON[2 * c], CARD_ON[2 * c + 1] }); +} + +fn print_card(o: usize, c: usize) -> void { + var l : usize = 0; + while (l < 3) { + std.debug.print(",drawtext=fontfile={s}:text='{s}':fontsize={d}:fontcolor=", .{ CAPTION_FONT_LAT, card_line(c, l), card_font(o, l) }); + print_col(line_row(l)); + std.debug.print(":x={d:.1}", .{card_f(o, 0) + CARD_PAD}); + print_rise(o, c, CARD_LY[o * 3 + l]); + std.debug.print("'", .{}); + print_fade(c); + l = l + 1; + } + std.debug.print(",drawtext=fontfile={s}:text='PASS':fontsize={d}:fontcolor=0x000000:x={d:.1}", .{ CAPTION_FONT_LAT, PASS_FONT[o], pill_x(o) + PASS_DX }); + print_rise(o, c, pill_dy(o) + 0.5 * PILL_H); + std.debug.print("-text_h/2'", .{}); + print_fade(c); +} + +// ---- the graph of one cut ---- + +fn graph(o: usize) -> void { std.debug.print("[0:v]format=yuv420p", .{}); + var s : usize = 0; + while (s < SHOTS) { + print_tag(o, s, false); + print_tag(o, s, true); + s = s + 1; + } + var c : usize = 0; + while (c < CARDS) { + print_card(o, c); + c = c + 1; + } var k : usize = 0; while (k < WORDS) { const a = word_from(k); const b = word_until(k); - std.debug.print(",drawtext=fontfile={s}:textfile={s}/w{d:0>2}.txt:fontsize={d}:fontcolor=0xFFFF00:borderw={d}:bordercolor=0x000000:shadowx=0:shadowy={d}:shadowcolor=0x000000@0.6:x={d}:y={d}-text_h/2:enable='between(t,{d}.{d:0>3},{d}.{d:0>3})'", .{ caption_font(k), WORD_DIR, k + 1, band_font(WORD_CHARS[k]), CAPTION_BORDER_PX, CAPTION_SHADOW_Y_PX, BAND_X, CAPTION_Y, secs(a), millis(a), secs(b), millis(b) }); + std.debug.print(",drawtext=fontfile={s}:textfile={s}/w{d:0>2}.txt:fontsize={d}:fontcolor=", .{ caption_font(k), WORD_DIR, k + 1, band_font(o, WORD_CHARS[k]) }); + print_col(GOLD_ROW); + std.debug.print(":borderw={d}:bordercolor=0x000000:shadowx=0:shadowy={d}:shadowcolor=0x000000@0.6:x={d}:y={d}-text_h/2:enable='between(t,{d}.{d:0>3},{d}.{d:0>3})'", .{ CAPTION_BORDER_PX, CAPTION_SHADOW_Y_PX, band_x(o), caption_y(o), secs(a), millis(a), secs(b), millis(b) }); k = k + 1; } std.debug.print("[v];", .{}); var p : usize = 0; while (p < PHRASES) { - const s = phrase_start(p); - std.debug.print("[{d}:a]aresample=48000,pan=stereo|c0=c0|c1=c0,adelay={d}|{d}[p{d}];", .{ p + 1, s, s, p }); + const t = phrase_start(p); + std.debug.print("[{d}:a]aresample=48000,pan=stereo|c0=c0|c1=c0,adelay={d}|{d}[p{d}];", .{ p + 1, t, t, p }); p = p + 1; } std.debug.print("[p0][p1][p2][p3][p4][p5]amix=inputs=6:normalize=0:duration=longest,loudnorm=I={d}:TP=-1.5:LRA={d},aresample=48000,apad=whole_dur={d}[vo];", .{ VOICE_LUFS, VOICE_LRA, secs(REEL_MS) }); @@ -200,6 +361,11 @@ pub fn main() -> void { std.debug.print("[vo][mu]amix=inputs=2:normalize=0:duration=first,alimiter=limit=0.{d:0>3}[a]\n", .{millis(LIMIT_PERMILLE)}); } +pub fn main() -> void { + graph(TALL); + graph(WIDE); +} + // ---- tests ---- test "the events are the physics: landings and labels" { @@ -270,34 +436,117 @@ test "the voice and the last caption end before the music fades" { assert(MUSIC_FADE_OUT_START_MS + MUSIC_FADE_OUT_MS == REEL_MS); } -test "the widest caption fits the band" { - var k : usize = 0; - while (k < WORDS) { - assert(WORD_CHARS[k] * MONO_ADVANCE_PERMILLE * band_font(WORD_CHARS[k]) + 2 * CAPTION_BORDER_PX * 1000 <= BAND_W * 1000); - k = k + 1; +test "the widest caption fits its band, in both cuts" { + var o : usize = 0; + while (o < 2) { + var k : usize = 0; + while (k < WORDS) { + assert(WORD_CHARS[k] * MONO_ADVANCE_PERMILLE * band_font(o, WORD_CHARS[k]) + 2 * CAPTION_BORDER_PX * 1000 <= band_w(o) * 1000); + k = k + 1; + } + o = o + 1; } - assert(band_font(9) == CAPTION_FONT_MAX); - assert(band_font(12) == 100); + assert(band_font(TALL, 9) == CAPTION_FONT_MAX); + assert(band_font(WIDE, 9) == 122); + assert(band_font(WIDE, 12) == 91); + assert(band_font(TALL, 2) == CAPTION_FONT_MAX); assert(WORD_LATIN[14] && WORD_LATIN[16] && WORD_LATIN[20]); assert(WORD_CHARS[14] == 2 && WORD_CHARS[20] == 3); assert(WORD_LATIN[0] == false && WORD_LATIN[23] == false); - assert(band_font(8) == CAPTION_FONT_MAX); - assert(band_font(2) == CAPTION_FONT_MAX); } -test "the band is clear of every label and of the tube" { - var l : usize = 0; - while (l < FIXED) { - assert(label_clear(l)); - l = l + 1; +test "the captions keep to their band: in the frame, off the scene, the card and the tags" { + var o : usize = 0; + while (o < 2) { + const x0 = caption_box(o, 0); + const y0 = caption_box(o, 1); + const x1 = caption_box(o, 2); + const y1 = caption_box(o, 3); + const w = @as(f64, @floatFromInt(frame_w(o))); + const h = @as(f64, @floatFromInt(frame_h(o))); + assert(x0 >= 0.0 && y0 >= 0.0 && x1 <= w && y1 <= h); + // the scene's safe rect + assert(rects_meet(x0, y0, x1, y1, safe_f(o, 0), safe_f(o, 1), safe_f(o, 2), safe_f(o, 3)) == false); + // the card, anywhere on its rise + const cx0 = card_f(o, 0); + const cy0 = card_f(o, 1); + assert(rects_meet(x0, y0, x1, y1, cx0, cy0, cx0 + card_f(o, 2), cy0 + card_f(o, 3) + CARD_RISE) == false); + // the tag line, from where it slides in + const tx0 = @as(f64, @floatFromInt(TAG_X[o] - TAG_SLIDE_PX)); + const ty0 = @as(f64, @floatFromInt(TAG_Y[o])); + const ty1 = ty0 + 1.2 * @as(f64, @floatFromInt(TAG_FONT[o])); + assert(rects_meet(x0, y0, x1, y1, tx0, ty0, w, ty1) == false); + o = o + 1; + } +} + +test "the box check bites: overlapping boxes meet, apart ones do not" { + assert(rects_meet(0.0, 0.0, 10.0, 10.0, 5.0, 5.0, 15.0, 15.0)); + assert(rects_meet(0.0, 0.0, 10.0, 10.0, 10.0, 0.0, 20.0, 10.0) == false); + // the tall caption band, moved up by 300 px, would land on the card + assert(rects_meet(caption_box(TALL, 0), caption_box(TALL, 1) - 300.0, caption_box(TALL, 2), caption_box(TALL, 3) - 300.0, card_f(TALL, 0), card_f(TALL, 1), card_f(TALL, 0) + card_f(TALL, 2), card_f(TALL, 1) + card_f(TALL, 3))); +} + +test "the tags sit above the scene, fit the frame, and slide in from inside it" { + var o : usize = 0; + while (o < 2) { + var s : usize = 0; + while (s < SHOTS) { + const wd = @as(f64, @floatFromInt(tag_text(s).len)) * 0.6 * @as(f64, @floatFromInt(TAG_FONT[o])); + assert(@as(f64, @floatFromInt(TAG_X[o])) + wd + 40.0 <= @as(f64, @floatFromInt(frame_w(o)))); + s = s + 1; + } + assert(TAG_X[o] - TAG_SLIDE_PX >= 20); + assert(@as(f64, @floatFromInt(TAG_Y[o])) + 1.2 * @as(f64, @floatFromInt(TAG_FONT[o])) <= safe_f(o, 1)); + o = o + 1; } - assert(@as(f64, @floatFromInt(BAND_X + BAND_W)) < TUBE_X - TUBE_HALF); } -test "the band check bites: boxes inside or across it are refused" { - assert(!box_clear(60.0, 1200.0, 1230.0)); - assert(!box_clear(60.0, 1100.0, 1130.0)); - assert(!box_clear(60.0, 1370.0, 1400.0)); - assert(box_clear(60.0, 1036.0, 1064.0)); - assert(box_clear(822.0, 1200.0, 1230.0)); +test "the card's text fits its card, and PASS sits in its pill right of the check" { + var o : usize = 0; + while (o < 2) { + var c : usize = 0; + while (c < CARDS) { + var l : usize = 1; + while (l < 3) { + const wd = @as(f64, @floatFromInt(card_line(c, l).len)) * 0.6 * @as(f64, @floatFromInt(card_font(o, l))); + assert(CARD_PAD + wd + CARD_PAD <= card_f(o, 2)); + l = l + 1; + } + c = c + 1; + } + const pf = @as(f64, @floatFromInt(PASS_FONT[o])); + assert(PASS_DX > 38.0 + 6.0); + assert(PASS_DX + 4.0 * 0.6 * pf + 12.0 <= PILL_W); + assert(1.2 * pf <= PILL_H); + assert(card_font(o, 0) < card_font(o, 1)); + o = o + 1; + } + assert(card_font(TALL, 0) == 26 && card_font(WIDE, 0) == 20); +} + +test "the type is the reel's own gold, grey and white" { + // gold: expose(2.4, 1.45, 0.35) = #E8C34B + assert(tone8(0) == 232 && tone8(1) == 195 && tone8(2) == 75); + assert(tone8(3 * WHITE_ROW) == tone8(3 * WHITE_ROW + 2)); + assert(tone8(3 * GREY_ROW) == tone8(3 * GREY_ROW + 1)); + assert(tone8(3 * GREY_ROW) < tone8(3 * WHITE_ROW)); + assert(tone8(3 * GREY_ROW) > 120); +} + +test "every hard cut lands just before a word or an event" { + var n : i32 = 0; + var s : usize = 1; + while (s < SHOTS) { + if (SHOT_KIND[s] == CUT) { + const g = beat_gap(frame_ms(SHOT_START[s])); + assert(g >= 0 && g <= BEAT_LEAD_MS); + n = n + 1; + } + s = s + 1; + } + assert(n == 4); + // the check bites: after the last word there is no beat, and a cut 200 ms late misses its word + assert(beat_gap(14000) > BEAT_LEAD_MS); + assert(beat_gap(frame_ms(SHOT_START[3]) + 200) > BEAT_LEAD_MS); } diff --git a/specs/t27f/token_floor_scenes.t27 b/specs/t27f/token_floor_scenes.t27 new file mode 100644 index 0000000000..1942bfec19 --- /dev/null +++ b/specs/t27f/token_floor_scenes.t27 @@ -0,0 +1,545 @@ +// SPDX-License-Identifier: Apache-2.0 +// specs/t27f/token_floor_scenes.t27 -- the token_floor reel cut into a story of shots: a camera per +// shot, the transitions between them, the glass spec cards that float over them, and the small +// mono scene tags, for the tall 1080x1920 frame and the wide 1920x1080 one. +// +// WHY: the owner, 2026-10-18: one scene is boring; the transitions between shots must tell a story +// of scenes. And: study the artist tetsuo (x.com/tetsuoai), who works in our direction, AI agents +// and AI interfaces made visible. What his reels do, and what this module does: +// +// shots 2-3 s each, cut on the beats of the voice, never on a clock. Here every hard +// cut falls just before a narration event or a phrase, so the eye arrives first +// and the word lands on what it already sees. +// one hero a recurring object carries the continuity across cuts. Here it is the column: +// every shot looks at the same world, only the camera moves. +// moves a slow push inside a shot, and a fast move between two shots when the story +// travels: the camera follows the white ball down, then the dots it sheds left. +// two levels a small mono label over a big bold statement. The statements are the reel's own +// particle letters; the small labels are the scene tags, top left. +// UI cards a translucent card in screen space, over the scene, showing the machine's side +// of the story: here the t27 function that computes the number on screen, and a +// green PASS. A card stays up across a cut: the interface outlives the shot. +// +// THE CAMERA maps the stage of specs/t27f/token_floor.t27 (world px, 1 px = 1 mm, the 1080x1920 +// layout) to the screen: screen = (world - c) z + (W / 2, H / 2). It is axis-aligned, so the +// stage's separable light stays separable. Inside a shot c moves linearly and z geometrically +// from pose 0 to pose 1. A shot that enters by a move (MOVE or DOLLY) first travels from the last +// pose of the shot before, on a smootherstep, over MOVE_F or DOLLY_F frames. +// +// NOT CLAIMED: the shot list, the framings, the card layout and the tags are editing choices. +// This module owns where the camera looks and when; what is there to see is token_floor's. +// phi^2 + 1/phi^2 = 3 | TRINITY + +module token_floor_scenes; + +pub const ID : str = "token-floor-scenes"; +pub const SCENE_FRAMES : i32 = 450; // token_floor's FRAMES; a test there holds them equal +pub const SCENE_FPS : i32 = 30; + +// ---- the two frames ---- +pub const TALL : usize = 0; +pub const WIDE : usize = 1; + +pub fn frame_w(o: usize) -> i32 { + if (o == WIDE) { return 1920; } + return 1080; +} + +pub fn frame_h(o: usize) -> i32 { + if (o == WIDE) { return 1080; } + return 1920; +} + +// ---- the shots ---- +pub const SHOTS : usize = 8; +pub const CUT : i64 = 0; +pub const MOVE : i64 = 1; // a fast camera move from the last shot's pose +pub const DOLLY : i64 = 2; // a slow one +pub const MOVE_F : f64 = 10.0; +pub const DOLLY_F : f64 = 24.0; + +// The first frame of each shot, and the reel's end. +pub const SHOT_START : [9]i32 = [0, 68, 88, 130, 200, 262, 326, 380, 450]; +// How each shot enters. +pub const SHOT_KIND : [8]i64 = [0, 1, 1, 0, 0, 0, 0, 2]; +// 0 the title, 1 the white ball strikes, 2 the floor's labels, 3 the counter, 4 up the column, +// 5 the bill at the top, 6 the verdict, 7 the whole infographic. +// Per frame and shot: pose 0 (cx, cy, z), then pose 1, world px and zoom. +pub const CAM : [96]f64 = [ + // tall + 340.0, 145.0, 1.55, 340.0, 150.0, 1.75, + 860.0, 1590.0, 3.2, 860.0, 1600.0, 3.5, + 308.5, 1658.5, 1.730, 308.5, 1647.2, 1.891, + 227.2, 828.2, 2.572, 227.2, 820.6, 2.812, + 880.0, 1300.0, 2.0, 880.0, 560.0, 2.0, + 355.6, 771.9, 1.455, 355.6, 758.4, 1.590, + 306.9, 1165.2, 1.742, 306.9, 1154.0, 1.904, + 534.0, 1305.0, 0.72, 534.0, 1410.4, 0.542, + // wide + 528.1, 119.1, 1.801, 510.6, 121.3, 1.969, + 860.0, 1590.0, 2.6, 860.0, 1600.0, 2.9, + 520.0, 1500.0, 1.3, 530.0, 1490.0, 1.4, + 440.0, 720.0, 1.9, 450.0, 730.0, 2.2, + 860.0, 1300.0, 1.8, 860.0, 560.0, 1.8, + 655.6, 577.3, 1.233, 630.1, 580.4, 1.348, + 500.0, 1009.7, 1.916, 483.6, 1011.7, 2.094, + 1194.9, 905.3, 0.56, 1404.8, 879.8, 0.425 +]; + +pub fn shot_of(f: i32) -> usize { + var s : usize = 0; + while (s + 1 < SHOTS && f >= SHOT_START[s + 1]) { + s = s + 1; + } + return s; +} + +pub fn shot_len(s: usize) -> i32 { + return SHOT_START[s + 1] - SHOT_START[s]; +} + +fn cam_f(o: usize, s: usize, k: usize) -> f64 { + return CAM[o * 48 + s * 6 + k]; +} + +fn clamp01(x: f64) -> f64 { + if (x < 0.0) { return 0.0; } + if (x > 1.0) { return 1.0; } + return x; +} + +// 6 t^5 - 15 t^4 + 10 t^3: starts and stops with no speed and no acceleration. +pub fn smoother(t: f64) -> f64 { + const u = clamp01(t); + return u * u * u * (u * (u * 6.0 - 15.0) + 10.0); +} + +// Shot s's own pose at frame f (fractional), component k: 0 cx, 1 cy, 2 z. +fn drift(o: usize, s: usize, f: f64, k: usize) -> f64 { + const u = clamp01((f - @as(f64, @floatFromInt(SHOT_START[s]))) / @as(f64, @floatFromInt(shot_len(s)))); + const a = cam_f(o, s, k); + const b = cam_f(o, s, k + 3); + if (k == 2) { + return a * @exp(u * @log(b / a)); + } + return a + (b - a) * u; +} + +fn entry_frames(s: usize) -> f64 { + if (SHOT_KIND[s] == MOVE) { return MOVE_F; } + if (SHOT_KIND[s] == DOLLY) { return DOLLY_F; } + return 0.0; +} + +// The camera of shot s at frame f, component k. f may lie outside the shot: the pose is held at +// its ends, so the frame before a cut is seen through the new shot's camera and a cut carries no +// motion blur. +pub fn pose(o: usize, s: usize, f: f64, k: usize) -> f64 { + const own = drift(o, s, f, k); + const n = entry_frames(s); + if (s == 0 || n == 0.0) { + return own; + } + const st = @as(f64, @floatFromInt(SHOT_START[s])); + const w = smoother((f - st) / n); + const from = drift(o, s - 1, st, k); + if (k == 2) { + return from * @exp(w * @log(own / from)); + } + return from + (own - from) * w; +} + +pub fn cam(o: usize, f: i32, k: usize) -> f64 { + return pose(o, shot_of(f), @as(f64, @floatFromInt(f)), k); +} + +// World to screen and back, through shot s's camera at frame f. +pub fn to_screen_x(o: usize, s: usize, f: f64, x: f64) -> f64 { + return (x - pose(o, s, f, 0)) * pose(o, s, f, 2) + 0.5 * @as(f64, @floatFromInt(frame_w(o))); +} + +pub fn to_screen_y(o: usize, s: usize, f: f64, y: f64) -> f64 { + return (y - pose(o, s, f, 1)) * pose(o, s, f, 2) + 0.5 * @as(f64, @floatFromInt(frame_h(o))); +} + +// The world rectangle shot s sees at frame f: 0 left, 1 top, 2 right, 3 bottom. +pub fn visible(o: usize, s: usize, f: f64, k: usize) -> f64 { + const z = pose(o, s, f, 2); + const hw = 0.5 * @as(f64, @floatFromInt(frame_w(o))) / z; + const hh = 0.5 * @as(f64, @floatFromInt(frame_h(o))) / z; + if (k == 0) { return pose(o, s, f, 0) - hw; } + if (k == 1) { return pose(o, s, f, 1) - hh; } + if (k == 2) { return pose(o, s, f, 0) + hw; } + return pose(o, s, f, 1) + hh; +} + +// True when the world box (x0, y0)-(x1, y1) is on screen, with a margin of m screen px, for every +// frame of shot s after its entry move. +pub fn framed(o: usize, s: usize, x0: f64, y0: f64, x1: f64, y1: f64, m: f64) -> bool { + var f : f64 = @as(f64, @floatFromInt(SHOT_START[s])) + entry_frames(s); + const e = @as(f64, @floatFromInt(SHOT_START[s + 1])); + var ok : bool = true; + while (f < e) { + const z = pose(o, s, f, 2); + if (x0 < visible(o, s, f, 0) + m / z || x1 > visible(o, s, f, 2) - m / z) { ok = false; } + if (y0 < visible(o, s, f, 1) + m / z || y1 > visible(o, s, f, 3) - m / z) { ok = false; } + f = f + 1.0; + } + return ok; +} + +// The largest distance, screen px, any point of the world box moves between two frames of a shot +// once its entry move is over: a slow push, not a pan. +pub fn drift_px(o: usize, s: usize) -> f64 { + var f : f64 = @as(f64, @floatFromInt(SHOT_START[s])) + entry_frames(s) + 1.0; + const e = @as(f64, @floatFromInt(SHOT_START[s + 1])); + var m : f64 = 0.0; + while (f < e) { + var c : usize = 0; + while (c < 4) { + var x : f64 = visible(o, s, f, 0); + if (c == 1 || c == 3) { x = visible(o, s, f, 2); } + var y : f64 = visible(o, s, f, 1); + if (c >= 2) { y = visible(o, s, f, 3); } + const dx = to_screen_x(o, s, f, x) - to_screen_x(o, s, f - 1.0, x); + const dy = to_screen_y(o, s, f, y) - to_screen_y(o, s, f - 1.0, y); + const d = @sqrt(dx * dx + dy * dy); + if (d > m) { m = d; } + c = c + 1; + } + f = f + 1.0; + } + return m; +} + +// ---- the scene tags: the small mono line over each shot ---- +pub const TAG_X : [2]i32 = [60, 80]; +pub const TAG_Y : [2]i32 = [96, 72]; +pub const TAG_FONT : [2]i32 = [30, 28]; + +pub fn tag_text(s: usize) -> str { + if (s == 0) { return "01 / ONE TOKEN"; } + if (s == 1) { return "02 / THE FLOOR"; } + if (s == 2) { return "03 / LANDAUER"; } + if (s == 3) { return "04 / X10 A BALL"; } + if (s == 4) { return "05 / 19 DECADES"; } + if (s == 5) { return "06 / THE BILL"; } + if (s == 6) { return "07 / ENGINEERING"; } + return "08 / T27.AI"; +} + +// ---- the spec cards ---- +// A card is a rounded glass panel in screen space with three lines of mono text and a PASS pill. +// Per card: the first and the last frame it is up. +pub const CARDS : usize = 3; +pub const CARD_ON : [6]i32 = [98, 176, 272, 344, 392, 450]; +// Per frame: x, y, w, h, corner radius, font px of the code line. +pub const CARD_BOX : [12]f64 = [60.0, 1330.0, 960.0, 230.0, 22.0, 34.0, 1180.0, 96.0, 680.0, 200.0, 20.0, 27.0]; +pub const CARD_IN : f64 = 8.0; // frames to rise in +pub const CARD_OUT : f64 = 6.0; // frames to go +pub const CARD_RISE : f64 = 36.0; // px it rises as it comes in +pub const PILL_W : f64 = 132.0; +pub const PILL_H : f64 = 44.0; + +pub fn card_f(o: usize, k: usize) -> f64 { + return CARD_BOX[o * 6 + k]; +} + +pub fn card_line(c: usize, l: usize) -> str { + if (l == 0) { return "specs/physics/physical_limits.t27"; } + if (c == 0) { + if (l == 1) { return "index_bits(128256) * landauer_j(300)"; } + return "= 4.9E-20 J"; + } + if (c == 1) { + if (l == 1) { return "token_energy_j(0.24 Wh, 500)"; } + return "= 1.7 J PER TOKEN"; + } + if (l == 1) { return "gap(1.7 J, 17 bits, 300 K)"; } + return "= 3.5E19, 19 DECADES"; +} + +// The card up at frame f, or -1. +pub fn card_of(f: i32) -> i64 { + var c : usize = 0; + while (c < CARDS) { + if (f >= CARD_ON[2 * c] && f <= CARD_ON[2 * c + 1]) { + return @as(i64, @intCast(c)); + } + c = c + 1; + } + return 0 - 1; +} + +// Card c's opacity at frame f (fractional): it comes in over CARD_IN and goes over CARD_OUT. +pub fn card_opacity(c: usize, f: f64) -> f64 { + const a = (f - @as(f64, @floatFromInt(CARD_ON[2 * c]))) / CARD_IN; + const b = (@as(f64, @floatFromInt(CARD_ON[2 * c + 1])) + 1.0 - f) / CARD_OUT; + var v : f64 = clamp01(a); + if (clamp01(b) < v) { v = clamp01(b); } + return smoother(v); +} + +// How far below its place card c still is at frame f: it rises on the critically damped path, +// x0 (1 + w t) e^(-w t), at CARD_W rad/s. +pub const CARD_W : f64 = 14.0; + +pub fn card_drop(c: usize, f: f64) -> f64 { + var t : f64 = (f - @as(f64, @floatFromInt(CARD_ON[2 * c]))) / @as(f64, @floatFromInt(SCENE_FPS)); + if (t < 0.0) { t = 0.0; } + return CARD_RISE * (1.0 + CARD_W * t) * @exp(0.0 - CARD_W * t); +} + +// Where card c's lines and pill sit, screen px. Line 0 (the spec's path) is set at 3/4 size, grey; +// line 1 is the call, white; line 2 the value, gold. Per frame: the three lines' tops below the +// card's top. +pub const CARD_LY : [6]f64 = [26.0, 86.0, 150.0, 22.0, 74.0, 128.0]; +pub const CARD_PAD : f64 = 28.0; + +pub fn card_top(o: usize, c: usize, f: f64) -> f64 { + return card_f(o, 1) + card_drop(c, f); +} + +pub fn pill_x(o: usize) -> f64 { + return card_f(o, 0) + card_f(o, 2) - CARD_PAD - PILL_W; +} + +pub fn pill_dy(o: usize) -> f64 { + return CARD_LY[o * 3] - 4.0; +} + +// ---- the interface: where the subject must stay, and where the captions go ---- +// Per frame: the safe rect the scene's subject keeps inside (x0, y0, x1, y1), then the caption +// band (x, width, centre line y). Tall: the tags above, the card and the captions below. Wide: +// the scene on the left, an interface column on the right with the card at its top and the +// captions at its foot. +pub const SAFE : [14]f64 = [40.0, 150.0, 1040.0, 1310.0, 60.0, 960.0, 1700.0, 40.0, 130.0, 1140.0, 1040.0, 1180.0, 680.0, 960.0]; + +pub fn safe_f(o: usize, k: usize) -> f64 { + return SAFE[o * 7 + k]; +} + +pub fn band_x(o: usize) -> i32 { + return @as(i32, @intFromFloat(safe_f(o, 4))); +} + +pub fn band_w(o: usize) -> i32 { + return @as(i32, @intFromFloat(safe_f(o, 5))); +} + +pub fn caption_y(o: usize) -> i32 { + return @as(i32, @intFromFloat(safe_f(o, 6))); +} + +// True when the world box (x0, y0)-(x1, y1) lies inside the safe rect for every frame of shot s +// after its entry move, from frame `from` on. +pub fn inside(o: usize, s: usize, from: f64, x0: f64, y0: f64, x1: f64, y1: f64) -> bool { + var f : f64 = @as(f64, @floatFromInt(SHOT_START[s])) + entry_frames(s); + if (from > f) { f = from; } + const e = @as(f64, @floatFromInt(SHOT_START[s + 1])); + var ok : bool = true; + while (f < e) { + if (to_screen_x(o, s, f, x0) < safe_f(o, 0) || to_screen_x(o, s, f, x1) > safe_f(o, 2)) { ok = false; } + if (to_screen_y(o, s, f, y0) < safe_f(o, 1) || to_screen_y(o, s, f, y1) > safe_f(o, 3)) { ok = false; } + f = f + 1.0; + } + return ok; +} + +// The worst overshoot, screen px, of the box past the safe rect over the same frames: a probe for +// tuning a pose, 0 when the box is inside. +pub fn overshoot(o: usize, s: usize, from: f64, x0: f64, y0: f64, x1: f64, y1: f64) -> f64 { + var f : f64 = @as(f64, @floatFromInt(SHOT_START[s])) + entry_frames(s); + if (from > f) { f = from; } + const e = @as(f64, @floatFromInt(SHOT_START[s + 1])); + var m : f64 = 0.0; + while (f < e) { + const a = safe_f(o, 0) - to_screen_x(o, s, f, x0); + const b = to_screen_x(o, s, f, x1) - safe_f(o, 2); + const c = safe_f(o, 1) - to_screen_y(o, s, f, y0); + const d = to_screen_y(o, s, f, y1) - safe_f(o, 3); + if (a > m) { m = a; } + if (b > m) { m = b; } + if (c > m) { m = c; } + if (d > m) { m = d; } + f = f + 1.0; + } + return m; +} + +// ---- tests ---- + +test "the shots tile the reel, in order, 0.6 to 2.5 s each" { + assert(SHOT_START[0] == 0); + assert(SHOT_START[SHOTS] == SCENE_FRAMES); + var s : usize = 0; + while (s < SHOTS) { + assert(shot_len(s) >= 18); + assert(shot_len(s) <= 75); + s = s + 1; + } + assert(shot_of(0) == 0); + assert(shot_of(67) == 0); + assert(shot_of(68) == 1); + assert(shot_of(449) == SHOTS - 1); + // the first shot opens on a still frame; it does not enter by a move + assert(SHOT_KIND[0] == CUT); +} + +test "a shot pushes in slowly; only the crane up the column travels, under 30 px a frame" { + var o : usize = 0; + while (o < 2) { + var s : usize = 0; + while (s < SHOTS) { + if (s == 4) { + assert(drift_px(o, s) < 30.0); + assert(drift_px(o, s) > 12.0); + } else { + assert(drift_px(o, s) < 12.0); + } + s = s + 1; + } + o = o + 1; + } +} + +test "a move starts where the last shot ended and arrives at the new shot's own pose" { + var o : usize = 0; + while (o < 2) { + var s : usize = 1; + while (s < SHOTS) { + const st = @as(f64, @floatFromInt(SHOT_START[s])); + if (SHOT_KIND[s] != CUT) { + var k : usize = 0; + while (k < 3) { + assert(@abs(pose(o, s, st, k) - pose(o, s - 1, st, k)) < 1e-9); + assert(@abs(pose(o, s, st + entry_frames(s), k) - drift(o, s, st + entry_frames(s), k)) < 1e-9); + k = k + 1; + } + } + s = s + 1; + } + o = o + 1; + } + // a cut changes the picture at once: shot 3 does not look where shot 2 did + assert(@abs(cam(0, 130, 1) - cam(0, 129, 1)) > 300.0); +} + +test "smootherstep starts and stops at rest" { + assert(smoother(0.0) == 0.0); + assert(smoother(1.0) == 1.0); + assert(@abs(smoother(0.5) - 0.5) < 1e-12); + // its slope at the ends is under 1 percent of the mean after 2 percent of the way + assert(smoother(0.02) < 0.0001); + assert(smoother(2.0) == 1.0); + assert(smoother(0.0 - 1.0) == 0.0); +} + +test "the camera maps the world and back" { + var o : usize = 0; + while (o < 2) { + const f = 300.0; + const s = shot_of(300); + const x = visible(o, s, f, 0); + const y = visible(o, s, f, 3); + assert(@abs(to_screen_x(o, s, f, x)) < 1e-9); + assert(@abs(to_screen_y(o, s, f, y) - @as(f64, @floatFromInt(frame_h(o)))) < 1e-9); + o = o + 1; + } + assert(frame_w(TALL) * frame_h(TALL) == frame_w(WIDE) * frame_h(WIDE)); +} + +test "the framing check bites: a box off the screen is refused" { + assert(framed(0, 1, 840.0, 1580.0, 880.0, 1620.0, 20.0)); + assert(framed(0, 1, 60.0, 1580.0, 100.0, 1620.0, 20.0) == false); + assert(framed(1, 7, -3000.0, 0.0, 3000.0, 10.0, 0.0) == false); +} + +test "the cards come in, rise into place and go; one at a time" { + var c : usize = 0; + while (c < CARDS) { + const a = @as(f64, @floatFromInt(CARD_ON[2 * c])); + const b = @as(f64, @floatFromInt(CARD_ON[2 * c + 1])); + assert(b - a >= 45.0); + assert(card_opacity(c, a) == 0.0); + assert(card_opacity(c, a + CARD_IN) == 1.0); + assert(card_opacity(c, b + 1.0) == 0.0); + assert(card_drop(c, a) == CARD_RISE); + // after 0.4 s it is inside a pixel of its place + assert(card_drop(c, a + 12.0) < 1.0); + if (c > 0) { + assert(CARD_ON[2 * c] > CARD_ON[2 * c - 1]); + } + c = c + 1; + } + assert(card_of(97) < 0); + assert(card_of(98) == 0); + assert(card_of(300) == 1); + assert(card_of(449) == 2); + assert(CARD_ON[2 * CARDS - 1] <= SCENE_FRAMES); +} + +test "a card and its pill fit the frame, and a code line fits the card" { + var o : usize = 0; + while (o < 2) { + assert(card_f(o, 0) + card_f(o, 2) <= @as(f64, @floatFromInt(frame_w(o))) - 40.0); + assert(card_f(o, 1) + card_f(o, 3) <= @as(f64, @floatFromInt(frame_h(o))) - 40.0); + assert(PILL_W + 56.0 < card_f(o, 2)); + // JetBrains Mono advances 600 per mille of the size; the longest line is the path + var c : usize = 0; + while (c < CARDS) { + var l : usize = 0; + while (l < 3) { + const n = @as(f64, @floatFromInt(card_line(c, l).len)); + assert(n * 0.6 * card_f(o, 5) + 56.0 <= card_f(o, 2)); + l = l + 1; + } + c = c + 1; + } + o = o + 1; + } +} + +test "the card's lines, its pill and the caption band keep clear of each other and of the edges" { + var o : usize = 0; + while (o < 2) { + const w = @as(f64, @floatFromInt(frame_w(o))); + const h = @as(f64, @floatFromInt(frame_h(o))); + // the value line, 1.2 em tall, ends 16 px above the card's foot + assert(CARD_LY[o * 3 + 2] + 1.2 * card_f(o, 5) + 16.0 <= card_f(o, 3)); + // the path line, at 3/4 size, ends before the pill + const path = @as(f64, @floatFromInt(card_line(0, 0).len)) * 0.6 * 0.75 * card_f(o, 5); + assert(card_f(o, 0) + CARD_PAD + path + 16.0 < pill_x(o)); + // the caption band: inside the frame, and below the card or beside the scene + assert(safe_f(o, 4) >= 40.0); + assert(safe_f(o, 4) + safe_f(o, 5) <= w - 40.0); + assert(safe_f(o, 6) + 124.0 / 2.0 + 9.0 + 6.0 < h); + assert(safe_f(o, 6) - 124.0 / 2.0 - 9.0 > card_f(o, 1) + card_f(o, 3) + CARD_RISE); + // the safe rect sits inside the frame + assert(safe_f(o, 0) >= 0.0 && safe_f(o, 2) <= w && safe_f(o, 1) >= 0.0 && safe_f(o, 3) <= h); + o = o + 1; + } + // tall: the subject stays above the card; wide: left of the interface column + assert(safe_f(TALL, 3) <= card_f(TALL, 1)); + assert(safe_f(WIDE, 2) <= card_f(WIDE, 0)); + assert(safe_f(WIDE, 2) <= safe_f(WIDE, 4)); + assert(band_x(TALL) == 60 && band_w(WIDE) == 680 && caption_y(TALL) == 1700); +} + +test "the safe-rect check bites" { + // the white ball at rest is inside shot 1's safe rect; the title, at the top of the stage, is not + assert(inside(TALL, 1, 0.0, 826.0, 1572.0, 894.0, 1640.0)); + assert(inside(TALL, 1, 0.0, 60.0, 70.0, 600.0, 133.0) == false); + assert(overshoot(TALL, 1, 0.0, 826.0, 1572.0, 894.0, 1640.0) == 0.0); + assert(overshoot(TALL, 1, 0.0, 60.0, 70.0, 600.0, 133.0) > 100.0); +} + +test "every shot has its tag" { + var s : usize = 0; + while (s < SHOTS) { + assert(tag_text(s).len > 6); + assert(tag_text(s)[0] == 48); + assert(tag_text(s)[1] == 49 + @as(u8, @intCast(s))); + s = s + 1; + } +} diff --git a/tools/verilog_width_baseline.txt b/tools/verilog_width_baseline.txt index 03728a37ae..54302f1d38 100644 --- a/tools/verilog_width_baseline.txt +++ b/tools/verilog_width_baseline.txt @@ -60,17 +60,17 @@ specs/tri/t27b/conformance/big_frame.t27 specs/claw/suggest_feed.t27 # # specs/t27f/token_floor.t27 -- not a bus: the frame renderer's working -# memory. Two locals of the frame function, and both widths are exact: +# memory. The widest local of the frame function, and its width is exact: # # rsp : [524288]u16 = 524288*16 = 8,388,608 bits -> [8388607:0] # the per-frame row index (#10391): each raster row visits only the # splats that touch it; INDEX_CAP entries, sized by index_bound() -# bgq : [6220800]u8 = 6220800*8 = 49,766,400 bits -> [49766399:0] -# the empty scene, 1080*1920*3 bytes, computed once and copied into -# every pixel no splat visits (#10407) # -# Neither can shrink without removing what the spec does: the index is what -# makes a frame cost its splats rather than its pixels, and bgq is one whole -# video frame. The spec renders a 1080x1920 video on a host; it has no -# hardware form. Same class as big_frame.t27 and suggest_feed.t27. +# The splat arrays ([45312]f64, MAX_SPLATS = every dot, its mirror image and +# 256 more) are 2,899,968 bits each. The cached empty scene bgq of #10407 is +# gone: with a moving camera and a per-frame dither no two frames share an +# empty scene. The index cannot shrink without removing what the spec does: +# it is what makes a frame cost its splats rather than its pixels. The spec +# renders 1080x1920 and 1920x1080 video on a host; it has no hardware form. +# Same class as big_frame.t27 and suggest_feed.t27. specs/t27f/token_floor.t27