From cef575916e1f0f66d61795454c670850db43794e Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Sun, 11 Oct 2026 12:18:32 -0400 Subject: [PATCH] [Feat] (Video): add the paper-cutout intro and hype reel sources --- intro-video/.gitignore | 11 + intro-video/README.md | 9 + intro-video/hype/README.md | 76 ++ intro-video/hype/analyze.py | 17 + intro-video/hype/anim.py | 124 +++ intro-video/hype/devices.py | 180 ++++ intro-video/hype/dsp.py | 212 +++++ intro-video/hype/engine.py | 732 ++++++++++++++++ intro-video/hype/fetch_fonts.py | 22 + intro-video/hype/kit.py | 377 +++++++++ intro-video/hype/music.py | 576 +++++++++++++ intro-video/hype/render.py | 135 +++ intro-video/hype/requirements.txt | 10 + intro-video/hype/sc_break.py | 135 +++ intro-video/hype/sc_build3.py | 58 ++ intro-video/hype/sc_drop1.py | 215 +++++ intro-video/hype/sc_final.py | 215 +++++ intro-video/hype/sc_how.py | 208 +++++ intro-video/hype/sc_modes.py | 327 ++++++++ intro-video/hype/sc_open.py | 197 +++++ intro-video/hype/sc_pipe.py | 126 +++ intro-video/hype/scenes.py | 74 ++ intro-video/hype/score.py | 217 +++++ intro-video/hype/shaders/block.glsl | 245 ++++++ intro-video/hype/shaders/goo.glsl | 63 ++ intro-video/hype/sting.py | 21 + intro-video/hype/sting_audio.py | 67 ++ intro-video/paper/README.md | 51 ++ intro-video/paper/cover.py | 106 +++ intro-video/paper/fetch_fonts.py | 37 + intro-video/paper/music.py | 292 +++++++ intro-video/paper/paper.py | 618 ++++++++++++++ intro-video/paper/requirements.txt | 3 + intro-video/paper/video.py | 1201 +++++++++++++++++++++++++++ intro-video/paper/zh.py | 145 ++++ 35 files changed, 7102 insertions(+) create mode 100644 intro-video/.gitignore create mode 100644 intro-video/README.md create mode 100644 intro-video/hype/README.md create mode 100644 intro-video/hype/analyze.py create mode 100644 intro-video/hype/anim.py create mode 100644 intro-video/hype/devices.py create mode 100644 intro-video/hype/dsp.py create mode 100644 intro-video/hype/engine.py create mode 100644 intro-video/hype/fetch_fonts.py create mode 100644 intro-video/hype/kit.py create mode 100644 intro-video/hype/music.py create mode 100644 intro-video/hype/render.py create mode 100644 intro-video/hype/requirements.txt create mode 100644 intro-video/hype/sc_break.py create mode 100644 intro-video/hype/sc_build3.py create mode 100644 intro-video/hype/sc_drop1.py create mode 100644 intro-video/hype/sc_final.py create mode 100644 intro-video/hype/sc_how.py create mode 100644 intro-video/hype/sc_modes.py create mode 100644 intro-video/hype/sc_open.py create mode 100644 intro-video/hype/sc_pipe.py create mode 100644 intro-video/hype/scenes.py create mode 100644 intro-video/hype/score.py create mode 100644 intro-video/hype/shaders/block.glsl create mode 100644 intro-video/hype/shaders/goo.glsl create mode 100644 intro-video/hype/sting.py create mode 100644 intro-video/hype/sting_audio.py create mode 100644 intro-video/paper/README.md create mode 100644 intro-video/paper/cover.py create mode 100644 intro-video/paper/fetch_fonts.py create mode 100644 intro-video/paper/music.py create mode 100644 intro-video/paper/paper.py create mode 100644 intro-video/paper/requirements.txt create mode 100644 intro-video/paper/video.py create mode 100644 intro-video/paper/zh.py diff --git a/intro-video/.gitignore b/intro-video/.gitignore new file mode 100644 index 00000000..d62f7dc4 --- /dev/null +++ b/intro-video/.gitignore @@ -0,0 +1,11 @@ +# renders, audio and downloaded fonts are generated, not sources +out/ +out_*/ +fonts/ +__pycache__/ +*.mp4 +*.wav +*.png +*.jpg +*.srt +*.json diff --git a/intro-video/README.md b/intro-video/README.md new file mode 100644 index 00000000..f6c18ff8 --- /dev/null +++ b/intro-video/README.md @@ -0,0 +1,9 @@ +# MobileGL videos + +Sources for two promotional videos. Rendered output, audio and downloaded fonts are not committed (see +`.gitignore`); each folder has a `fetch_fonts.py` and a README with the exact render steps. + +| folder | video | +|---|---| +| [`paper/`](paper/README.md) | **MobileGL intro**: a ~4:50 handmade paper-cutout explainer (English + Chinese, captions, cover image). CPU-rendered with Pillow. | +| [`hype/`](hype/README.md) | **MobileGL hype reel**: a 3:33 beat-synced 1080p60 showreel with a synthesised soundtrack, rendered on the GPU through MobileGL itself. Also contains a short animated sting of the MobileGlues mark. | diff --git a/intro-video/hype/README.md b/intro-video/hype/README.md new file mode 100644 index 00000000..86decf78 --- /dev/null +++ b/intro-video/hype/README.md @@ -0,0 +1,76 @@ +# MobileGL hype reel + +A 3:33 code-rendered, beat-synced showreel at 1080p60. The track is a 128 BPM electro-pop instrumental, +synthesised from the same score the picture is cut to. The palette is sampled from the MobileGlues icon. + +The final cut was rendered **through MobileGL itself**. The renderer is an ordinary OpenGL 4.3 core program, and +pointing it at MobileGL's `opengl32.dll` (Magma → Vulkan) sends every draw, framebuffer and readback through +MobileGL. The outro credits the GL implementation that actually drew the frames. + +## How it is put together + +- **One score, two consumers.** `score.py` holds the tempo grid (128 BPM, 112 bars), the sections, harmony and + melodies, and every drum/fx event. `music.py` synthesises the track from it. Scenes read the same events + (kick, clap, roll, crash, impact…) as decaying pulses, so cuts, zooms, shakes and squashes land exactly on the + music. +- **A frame is a pure function of time.** Each frame averages 8 sub-frames across a 180° shutter (motion blur), + then goes through HDR post: bloom, chromatic aberration, vignette, a fixed print grain. +- **Pacing.** Each drop speeds up toward a climax: 4-beat ideas, then 2, then 1, then 8ths, with growing zoom, + shake and speed lines. Full-screen colour never changes faster than once a beat. + +| file | what | +|---|---| +| `score.py` | tempo grid, sections, chords, hook melody, all events, `pulse()` | +| `dsp.py` | numba DSP kit: PolyBLEP oscillators, TPT state-variable filter, ping-pong delay, compressor, look-ahead limiter | +| `music.py` | the instrumental: kick/clap/hats/rolls/risers/impacts, supersaw chords with sidechain, plucks, FM keys, a mono lead with glide, convolution reverb, stutters, mastering to −10.5 LUFS | +| `analyze.py` | per-section loudness/band report and a spectrogram of a rendered mix | +| `engine.py` | the GL engine (moderngl): instanced SDF shapes, HarfBuzz + FreeType variable-font text with a texture cache, offscreen layers, sub-frame accumulation, bloom and final post | +| `shaders/block.glsl` | the MobileGlues mark, raymarched: a crafting-table / command-block block in translucent, slime-like glue that can be dropped in (splash, ripples, tongues erupting) | +| `shaders/goo.glsl` | 2D glue metaballs (smooth-union circles and tapered capsules, dome-shaded) | +| `anim.py` | palette, easing, `Frame` (local beats and pulses) | +| `kit.py` | backgrounds, HUD, chips, slams, stamps, checks, arrows, speed lines, isometric boxes, glue, the mark | +| `devices.py` | isometric phone / monitor / laptop / tower / board and the screens painted into them | +| `sc_open.py` … `sc_final.py`, `scenes.py` | one module per section; `scenes.py` is the timeline and the stutter glitch | +| `render.py` | `still`, `sheet`, `video`, `final`; pipes raw frames to ffmpeg and muxes the audio | +| `sting.py`, `sting_audio.py` | a 4.5 s standalone sting of the mark (block drops into glue), no text, with its own sound | + +## Setup + +```sh +pip install -r requirements.txt +python fetch_fonts.py # Archivo (+ Italic) and JetBrains Mono, SIL OFL +``` + +`ffmpeg` must be on `PATH`. Any OpenGL 4.3 core driver works for previews. + +## Render + +```sh +python music.py out/music.wav # the soundtrack (~40 s) +python render.py sheet 30 60 24 --scale 0.5 # contact sheet of a time range +python render.py still 31.5 86 --scale 0.5 # single frames +python render.py video 30 60 --scale 0.5 --fps 30 # quick preview clip with audio +python render.py final --samples 8 --crf 18 # out/mobilegl_hype.mp4, 1080p60 +``` + +Through MobileGL (Windows, `opengl32.dll` drop-in built from this repo): + +```sh +MOBILEGL_BACKEND_TYPE=DirectVulkan MGL_LIBGL=C:\path\to\opengl32.dll \ + python render.py final --samples 8 --crf 18 +``` + +On the RTX 3080 this runs at about 8 fps (≈28 min for the full reel). The final credit line names the backend and +GPU from `GL_RENDERER`. To check MobileGL against the native driver, render the same frames both ways and diff +them. They match to well under 1/255 on average (the film grain uses an integer hash so it is identical on every +implementation). + +The sting: + +```sh +python sting_audio.py out/sting.wav +python render.py video 0 4.5 --module sting --audio out/sting.wav --samples 8 --out out/glue_drop.mp4 +``` + +Grain options: `GRAIN_K` scales the grain (default 0.6), and `GRAIN_RATE=n` re-rolls it every n frames +(default 0 = a fixed print texture; animated grain makes the encode several times larger). diff --git a/intro-video/hype/analyze.py b/intro-video/hype/analyze.py new file mode 100644 index 00000000..0dc85315 --- /dev/null +++ b/intro-video/hype/analyze.py @@ -0,0 +1,17 @@ +import sys, numpy as np, soundfile as sf +from scipy.signal import stft +import score as S +x, sr = sf.read(sys.argv[1]); m = x.mean(1) +def band(seg, lo, hi): + f = np.fft.rfft(seg * np.hanning(len(seg))); fr = np.fft.rfftfreq(len(seg), 1/sr) + return 10*np.log10(np.sum(np.abs(f[(fr>=lo)&(fr 1 else x + + +def lin(t, t0, t1): + return clamp01((t - t0) / (t1 - t0)) if t1 != t0 else float(t >= t0) + + +def out_expo(u): + u = clamp01(u) + return 1.0 if u >= 1 else 1 - 2 ** (-10 * u) + + +def in_expo(u): + u = clamp01(u) + return 0.0 if u <= 0 else 2 ** (10 * u - 10) + + +def out_cubic(u): + u = clamp01(u) + return 1 - (1 - u) ** 3 + + +def in_cubic(u): + u = clamp01(u) + return u ** 3 + + +def in_out_cubic(u): + u = clamp01(u) + return 4 * u ** 3 if u < 0.5 else 1 - (-2 * u + 2) ** 3 / 2 + + +def out_back(u, k=1.7): + u = clamp01(u) + return 1 + (k + 1) * (u - 1) ** 3 + k * (u - 1) ** 2 + + +def spring(u, freq=3.0, damp=6.0): + """0 -> 1 with overshoot; u in seconds-ish units (unclamped above).""" + if u <= 0: + return 0.0 + return 1 - math.exp(-damp * u) * math.cos(2 * math.pi * freq * u) + + +def bump(u): + """0 -> 1 -> 0 over u in [0, 1].""" + u = clamp01(u) + return math.sin(math.pi * u) + + +def lerp(a_, b, u): + return a_ + (b - a_) * u + + +def rnd(*k): + """Deterministic hash -> [0, 1).""" + h = 2166136261 + for v in k: + for ch in repr(v): + h = ((h ^ ord(ch)) * 16777619) & 0xffffffff + h ^= h >> 13 + h = (h * 1274126177) & 0xffffffff + return (h & 0xffffff) / float(0x1000000) + + +# --- frame timing ---------------------------------------------------------------------------------- +class Frame: + def __init__(self, t, entry_start_bar=0): + self.t = t + self.bar = t / S.BAR + self.beat = t / S.BEAT + self.b0 = entry_start_bar + self.lt = t - entry_start_bar * S.BAR # local seconds in entry + self.lbeat = self.lt / S.BEAT # local beats + self.lbar = self.lt / S.BAR + self.kick = S.pulse(t, 'kick', 0.11) + self.clap = S.pulse(t, 'clap', 0.10) + self.hat = S.pulse(t, 'hat', 0.05) + self.crash = S.pulse(t, 'crash', 0.6) + self.impact = S.pulse(t, 'impact', 0.35) + self.roll = S.pulse(t, 'roll', 0.08) + + def at(self, beat): + """Seconds since local beat `beat` (negative before).""" + return self.lt - beat * S.BEAT + + def u(self, b0, b1): + """Progress through local beats [b0, b1].""" + return clamp01((self.lbeat - b0) / (b1 - b0)) + + def step(self, div=1.0): + """Index of the current local beat subdivision (div=2 -> 8ths).""" + return int(math.floor(self.lbeat * div + 1e-6)) + + +def frame_idx(t, fps=60): + return int(math.floor(t * fps + 1e-6)) diff --git a/intro-video/hype/devices.py b/intro-video/hype/devices.py new file mode 100644 index 00000000..0a09ef5d --- /dev/null +++ b/intro-video/hype/devices.py @@ -0,0 +1,180 @@ +"""Isometric pop illustrations (icon style: flat faces, chunky light outlines) and their screen contents.""" +import math +from anim import * +from kit import Iso, COS30, SIN30, code_line, cubes, goo +from engine import LW, LH + +from engine import hexc as _hex + +FACE_DARK = (_hex('#2e6e2f'), _hex('#1d4a25'), _hex('#163a1d')) # top, left, right on dark plates +FACE_LIGHT = (_hex('#d4f0c9'), _hex('#82cb70'), _hex('#55b441')) # icon faces on light plates +OUTL = _hex('#62ba4b') + + +def screen_layer(E, name, w, h, painter, f): + """Paint 2D content into an offscreen layer (logical w x h) and return the texture.""" + tex = E.push_target(name, w, h) + painter(E, f, w, h) + E.pop_target() + return tex + + +def put_on_face(E, iso, tex, face, x, y, z, w, h, alpha=1.0): + """Map a w x h (world units) texture onto an iso face whose content centre is (x, y, z).""" + cx, cy = iso.p(x, y, z) + s = iso.s + if face == 'left': + m = iso.mat_left(1.0) + E.image(tex, cx, cy, w * s, h * s, mat=m, color=(alpha,) * 4) + elif face == 'right': + E.image(tex, cx, cy, w * s, h * s, mat=iso.mat_right(1.0), color=(alpha,) * 4) + else: + E.image(tex, cx, cy, w * s, h * s, mat=iso.mat_top(1.0), color=(alpha,) * 4) + + +# --- devices --------------------------------------------------------------------------------------- +def phone(E, iso, f, x=0.0, y=0.0, faces=FACE_DARK, screen=None, line=OUTL, sw=5.0): + """Upright phone facing left-front; screen texture on the left face.""" + W_, T, H_ = 3.0, 0.35, 6.2 + iso.box(E, x - W_ / 2, y, 0, W_, T, H_, faces[0], faces[1], faces[2], line, sw) + if screen is not None: + put_on_face(E, iso, screen, 'left', x, y + T, H_ / 2, W_ - 0.35, H_ - 0.7) + + +def monitor(E, iso, f, x=0.0, y=0.0, faces=FACE_DARK, screen=None, line=OUTL, sw=5.0): + W_, T, H_ = 8.0, 0.4, 4.8 + iso.box(E, x - 1.0, y - 1.0, 0, 2.0, 2.0, 0.25, faces[0], faces[1], faces[2], line, sw) # foot + iso.box(E, x - 0.25, y - 0.2, 0.25, 0.5, 0.35, 1.3, faces[0], faces[1], faces[2], line, sw) # neck + iso.box(E, x - W_ / 2, y, 1.5, W_, T, H_, faces[0], faces[1], faces[2], line, sw) + if screen is not None: + put_on_face(E, iso, screen, 'left', x, y + T, 1.5 + H_ / 2, W_ - 0.4, H_ - 0.4) + + +def laptop(E, iso, f, x=0.0, y=0.0, faces=FACE_DARK, screen=None, line=OUTL, sw=5.0, open_=1.0): + W_, D, T = 6.4, 4.2, 0.3 + iso.box(E, x - W_ / 2, y - D, 0, W_, D, T, faces[0], faces[1], faces[2], line, sw) # base + # keyboard hint + for r in range(4): + for c in range(10): + iso.quad_top(E, x - W_ / 2 + 0.4 + c * 0.56, y - D + 0.5 + r * 0.55, T + 0.001, 0.42, 0.4, + a(faces[2], 0.9)) + # lid (upright, back edge) + H_ = 4.0 * open_ + iso.box(E, x - W_ / 2, y - D - T, T, W_, T, H_, faces[0], faces[1], faces[2], line, sw) + if screen is not None and open_ > 0.6: + put_on_face(E, iso, screen, 'left', x, y - D, T + H_ / 2, W_ - 0.4, H_ - 0.4) + + +def tower(E, iso, f, x=0.0, y=0.0, faces=FACE_DARK, line=OUTL, sw=5.0, leds=True): + W_, D, H_ = 2.6, 4.0, 6.0 + iso.box(E, x - W_ / 2, y - D / 2, 0, W_, D, H_, faces[0], faces[1], faces[2], line, sw) + if leds: + for k in range(5): + on = (int(f.t * 8) + k * 3) % 5 < 3 + px, py = iso.p(x - W_ / 2 + 0.4 + k * 0.4, y + D / 2, H_ - 0.6) + E.circle(px, py, 5, NEON if on else a(GLUE, 0.3)) + for k in range(4): + px0, py0 = iso.p(x - W_ / 2 + 0.3, y + D / 2, H_ - 1.4 - k * 0.9) + px1, py1 = iso.p(x + W_ / 2 - 0.3, y + D / 2, H_ - 1.4 - k * 0.9) + E.line(px0, py0, px1, py1, 5, a(line, 0.6)) + + +def board(E, iso, f, x=0.0, y=0.0, faces=FACE_DARK, line=OUTL, sw=5.0): + """Single-board computer (ARM Linux).""" + iso.box(E, x - 3.0, y - 2.0, 0, 6.0, 4.0, 0.25, faces[1], faces[1], faces[2], line, sw) + iso.box(E, x - 1.0, y - 1.0, 0.25, 2.0, 2.0, 0.35, _hex('#202a22'), _hex('#111611'), _hex('#0b0f0b'), line, 3) + for k in range(10): + iso.box(E, x - 2.7 + k * 0.5, y - 1.9, 0.25, 0.3, 0.3, 0.45, a(AMBER, 1), a(AMBER, 1), a(AMBER, 1)) + iso.box(E, x + 1.5, y + 0.2, 0.25, 1.3, 1.6, 0.9, PALE, faces[1], faces[2], line, 3) + + +def file_card(E, x, y, w, h, title, sub='', col=PALE, ink=INK, rot=0.0, s=1.0, fold=36): + """Flat document icon with a folded corner.""" + c, sn = math.cos(rot), math.sin(rot) + + def P(px, py): + px, py = px * s, py * s + return (x + c * px - sn * py, y + sn * px + c * py) + pts = [P(-w / 2, -h / 2), P(w / 2 - fold, -h / 2), P(w / 2, -h / 2 + fold), P(w / 2, h / 2), P(-w / 2, h / 2)] + E.poly(pts, col, stroke=OUTL, sw=5 * s) + E.poly([P(w / 2 - fold, -h / 2), P(w / 2 - fold, -h / 2 + fold), P(w / 2, -h / 2 + fold)], MINT, stroke=OUTL, + sw=4 * s) + tx, ty = P(0, h * 0.12) + E.text(title, tx, ty, 34 * s, ink, font='mono', wght=800, rot=rot) + if sub: + sx, sy = P(0, h * 0.3) + E.text(sub, sx, sy, 20 * s, a(ink, 0.6), font='mono', wght=500, rot=rot) + + +# --- screen painters ------------------------------------------------------------------------------- +def paint_voxels(E, f, w, h): + """A tiny blocky landscape (a sandbox game, generically).""" + E.rect(w / 2, h / 2, w, h, _hex('#7fc8f0')) + E.rect(w / 2, h * 0.86, w, h * 0.5, _hex('#5aa0d0')) + iso = Iso(w / 2, h * 0.36, w / 15) + hgt = {} + for gx in range(-4, 5): + for gy in range(-4, 5): + hgt[(gx, gy)] = 1 + int(2.2 * (0.5 + 0.5 * math.sin(gx * 0.9 + 1.3) * math.cos(gy * 0.8 - 0.4))) + order = sorted(hgt, key=lambda k: (k[0] + k[1])) + for gx, gy in order: + z = hgt[(gx, gy)] + iso.box(E, gx, gy, 0, 1, 1, z, _hex('#6ccf4f'), _hex('#8a5a32'), _hex('#6b4426'), _hex('#3b6b2b'), 1.2) + # a tree + iso.box(E, 1, -2, hgt[(1, -2)], 1, 1, 2, _hex('#8a5a32'), _hex('#6b4426'), _hex('#5a3820')) + iso.box(E, 0, -3, hgt[(1, -2)] + 2, 3, 3, 1.6, _hex('#3f9a3a'), _hex('#2e7a2a'), _hex('#24621f')) + sun = 0.5 + 0.5 * math.sin(f.t * 0.7) + E.circle(w * 0.82, h * 0.12, w * 0.07, _hex('#fff7c2', 1.4)) + E.text('60 FPS', w * 0.07, h * 0.07, w * 0.06, BONE, font='mono', wght=700, align='left') + + +def paint_desktop(E, f, w, h): + E.rect(w / 2, h / 2, w, h, _hex('#1b3a5c')) + for k in range(5): # wallpaper bands + E.circle(w * (0.2 + 0.18 * k), h * (0.7 - 0.05 * k), w * 0.25, a(_hex('#2c5d8a'), 0.35)) + E.rect(w / 2, h - h * 0.045, w, h * 0.09, a(_hex('#0e1a26'), 0.92)) # panel + for k in range(6): + E.rect(w * 0.06 + k * w * 0.05, h - h * 0.045, w * 0.032, h * 0.05, a(_hex('#5fa8e8'), 0.85), r=3) + E.text('12:34', w * 0.95, h - h * 0.045, h * 0.035, BONE, font='mono', wght=600, align='right') + wins = [(0.32, 0.36, 0.42, 0.42, 'Terminal'), (0.62, 0.42, 0.46, 0.48, 'Browser'), (0.47, 0.6, 0.34, 0.3, 'glmark2')] + for i, (cx, cy, ww, hh, title) in enumerate(wins): + jig = 0.01 * math.sin(f.t * 1.3 + i) + X, Y, Wn, Hn = w * (cx + jig), h * cy, w * ww, h * hh + E.rect(X + 6, Y + 8, Wn, Hn, a(INK, 0.35), r=8) + E.rect(X, Y, Wn, Hn, _hex('#eef3f7'), r=8) + E.rect(X, Y - Hn / 2 + h * 0.025, Wn, h * 0.05, _hex('#d6dee6'), r=8) + E.text(title, X, Y - Hn / 2 + h * 0.025, h * 0.028, _hex('#30404e'), wght=700) + if title == 'Terminal': + E.rect(X, Y + h * 0.025, Wn - 10, Hn - h * 0.06, _hex('#0f1b14')) + for L, s in enumerate(('$ glxinfo -B', 'OpenGL core profile', 'version: 4.6', '$ _')): + E.text(s, X - Wn / 2 + 14, Y - Hn / 2 + h * 0.09 + L * h * 0.045, h * 0.03, GLUE, font='mono', + wght=500, align='left') + elif title == 'glmark2': + E.rect(X, Y + h * 0.025, Wn - 10, Hn - h * 0.06, _hex('#202020')) + E.circle(X + math.sin(f.t * 2) * Wn * 0.2, Y + h * 0.03, h * 0.06, _hex('#e05050')) + else: + for L in range(5): + E.rect(X - Wn * 0.1, Y - Hn / 2 + h * 0.1 + L * h * 0.06, Wn * 0.7 * (0.6 + 0.4 * ((L * 7) % 3) / 2), + h * 0.022, _hex('#b8c4cf'), r=4) + + +def paint_mac(E, f, w, h): + E.rect(w / 2, h / 2, w, h, _hex('#2a2440')) + for k in range(3): + E.circle(w * (0.3 + 0.25 * k), h * 0.6, w * 0.3, a(_hex('#5a3f86'), 0.3)) + E.rect(w / 2, h * 0.03, w, h * 0.06, a(_hex('#ffffff'), 0.25)) + E.rect(w / 2, h * 0.9, w * 0.5, h * 0.1, a(_hex('#ffffff'), 0.25), r=12) + X, Y, Wn, Hn = w * 0.5, h * 0.45, w * 0.6, h * 0.58 + E.rect(X, Y, Wn, Hn, _hex('#101010'), r=10) + E.rect(X, Y - Hn / 2 + h * 0.03, Wn, h * 0.06, _hex('#2a2a2a'), r=10) + for k, cc in enumerate(('#ff5f57', '#febc2e', '#28c840')): + E.circle(X - Wn / 2 + 18 + k * 18, Y - Hn / 2 + h * 0.03, 5, _hex(cc)) + # a little lit scene: hills + sun (a game, generically) + E.rect(X, Y + Hn * 0.2, Wn - 8, Hn * 0.5, _hex('#3b7d3a')) + E.circle(X + Wn * 0.25, Y - Hn * 0.1, h * 0.06, _hex('#ffd36b', 1.3)) + + +def paint_term(E, f, w, h, lines, typed=None): + E.rect(w / 2, h / 2, w, h, _hex('#0b160e')) + for i, s in enumerate(lines): + code_line(E, s, 20, 40 + i * 44, 26, chars=None if typed is None else typed - sum(len(x) for x in lines[:i])) diff --git a/intro-video/hype/dsp.py b/intro-video/hype/dsp.py new file mode 100644 index 00000000..aaf13288 --- /dev/null +++ b/intro-video/hype/dsp.py @@ -0,0 +1,212 @@ +"""Small numba DSP kit: band-limited oscillators, a TPT state-variable filter, a ping-pong delay, a +compressor and a look-ahead limiter. Everything runs on float64 mono arrays.""" +import math +import numpy as np +from numba import njit + +SR = 48000 + + +@njit(cache=True) +def _blep(t, dt): + if t < dt: + t /= dt + return t + t - t * t - 1.0 + if t > 1.0 - dt: + t = (t - 1.0) / dt + return t * t + t + t + 1.0 + return 0.0 + + +@njit(cache=True) +def saw(freq, phase0): + n = freq.shape[0] + out = np.empty(n) + p = phase0 + for i in range(n): + dt = freq[i] / SR + out[i] = 2.0 * p - 1.0 - _blep(p, dt) + p += dt + if p >= 1.0: + p -= 1.0 + return out + + +@njit(cache=True) +def pulse(freq, width, phase0): + n = freq.shape[0] + out = np.empty(n) + p = phase0 + for i in range(n): + dt = freq[i] / SR + v = 1.0 if p < width else -1.0 + v += _blep(p, dt) + q = p - width + if q < 0.0: + q += 1.0 + v -= _blep(q, dt) + out[i] = v + p += dt + if p >= 1.0: + p -= 1.0 + return out + + +@njit(cache=True) +def sine(freq, phase0): + n = freq.shape[0] + out = np.empty(n) + p = phase0 + for i in range(n): + out[i] = math.sin(2.0 * math.pi * p) + p += freq[i] / SR + if p >= 1.0: + p -= 1.0 + return out + + +@njit(cache=True) +def svf(x, cutoff, res, mode): + """Zavalishin TPT SVF. cutoff: per-sample Hz array; res 0..1; mode 0=lp 1=bp 2=hp.""" + n = x.shape[0] + out = np.empty(n) + ic1 = 0.0 + ic2 = 0.0 + k = 2.0 - 2.0 * min(res, 0.98) + for i in range(n): + fc = min(max(cutoff[i], 10.0), SR * 0.45) + g = math.tan(math.pi * fc / SR) + a1 = 1.0 / (1.0 + g * (g + k)) + a2 = g * a1 + a3 = g * a2 + v3 = x[i] - ic2 + v1 = a1 * ic1 + a2 * v3 + v2 = ic2 + a2 * ic1 + a3 * v3 + ic1 = 2.0 * v1 - ic1 + ic2 = 2.0 * v2 - ic2 + if mode == 0: + out[i] = v2 + elif mode == 1: + out[i] = v1 + else: + out[i] = x[i] - k * v1 - v2 + return out + + +@njit(cache=True) +def onepole_hp(x, fc): + n = x.shape[0] + out = np.empty(n) + a = math.exp(-2.0 * math.pi * fc / SR) + yp = 0.0 + xp = 0.0 + for i in range(n): + yp = a * (yp + x[i] - xp) + xp = x[i] + out[i] = yp + return out + + +@njit(cache=True) +def pingpong(l, r, delay_s, fb, damp_hz): + n = l.shape[0] + d = int(delay_s * SR) + bl = np.zeros(d) + br = np.zeros(d) + ol = np.empty(n) + orr = np.empty(n) + a = math.exp(-2.0 * math.pi * damp_hz / SR) + zl = 0.0 + zr = 0.0 + j = 0 + for i in range(n): + yl = bl[j] + yr = br[j] + ol[i] = yl + orr[i] = yr + zl = (1 - a) * yl + a * zl + zr = (1 - a) * yr + a * zr + bl[j] = 0.5 * (l[i] + r[i]) + fb * zr # mono in on the left tap, cross-feed + br[j] = fb * zl + j += 1 + if j >= d: + j = 0 + return ol, orr + + +@njit(cache=True) +def compress(l, r, thresh_db, ratio, attack, release, makeup_db): + n = l.shape[0] + ol = np.empty(n) + orr = np.empty(n) + aa = math.exp(-1.0 / (attack * SR)) + ar = math.exp(-1.0 / (release * SR)) + env = 0.0 + mk = 10 ** (makeup_db / 20) + for i in range(n): + x = max(abs(l[i]), abs(r[i])) + if x > env: + env = aa * env + (1 - aa) * x + else: + env = ar * env + (1 - ar) * x + db = 20 * math.log10(env + 1e-9) + over = db - thresh_db + gr = 0.0 + if over > 0: + gr = over * (1 - 1 / ratio) + g = 10 ** (-gr / 20) * mk + ol[i] = l[i] * g + orr[i] = r[i] * g + return ol, orr + + +@njit(cache=True) +def limit(l, r, ceiling, lookahead_s, release_s): + """Look-ahead peak limiter: gain = min over the window ahead, smoothed on release.""" + n = l.shape[0] + la = int(lookahead_s * SR) + need = np.ones(n) + for i in range(n): + p = max(abs(l[i]), abs(r[i])) + if p > ceiling: + need[i] = ceiling / p + # running minimum over the next `la` samples (simple deque-free two-pass block min) + gmin = np.ones(n) + for i in range(n - 1, -1, -1): + m = need[i] + if i + 1 < n: + # decay toward the earlier future minimum with a linear attack ramp over `la` + nxt = gmin[i + 1] + ramp = nxt + (1.0 - nxt) / la + if ramp < m: + m = ramp + gmin[i] = m + ar = math.exp(-1.0 / (release_s * SR)) + g = 1.0 + ol = np.empty(n) + orr = np.empty(n) + for i in range(n): + t = gmin[i] + if t < g: + g = t + else: + g = ar * g + (1 - ar) * t + ol[i] = l[i] * g + orr[i] = r[i] * g + return ol, orr + + +def mtof(m): + return 440.0 * 2.0 ** ((np.asarray(m, dtype=np.float64) - 69.0) / 12.0) + + +def adsr(n, a, d, s, r_start, r, sr=SR): + """Envelope of n samples; release begins at sample r_start and lasts r seconds.""" + t = np.arange(n) / sr + e = np.where(t < a, t / max(a, 1e-6), s + (1 - s) * np.exp(-(t - a) / max(d, 1e-6))) + rs = r_start / sr + if r_start < n: + lvl = e[min(r_start, n - 1)] + tail = t >= rs + e[tail] = lvl * np.exp(-(t[tail] - rs) / max(r / 4.0, 1e-6)) + return e diff --git a/intro-video/hype/engine.py b/intro-video/hype/engine.py new file mode 100644 index 00000000..d39a3a43 --- /dev/null +++ b/intro-video/hype/engine.py @@ -0,0 +1,732 @@ +"""GPU motion-graphics engine for the MobileGL showreel (moderngl, OpenGL 4.3+ core). + +Everything is laid out in logical 1920x1080 pixels, y down. A frame is the average of `samples` +sub-frames across a shutter (motion blur), then bloom / aberration / grain / vignette in post. +Set MGL_LIBGL to an opengl32.dll to render through another GL implementation (MobileGL).""" +import math +import os +import functools +from collections import OrderedDict + +import numpy as np +import moderngl +import freetype +import uharfbuzz as hb + +HERE = os.path.dirname(os.path.abspath(__file__)) +LW, LH = 1920, 1080 + + +# --- colour ---------------------------------------------------------------------------------------- +def srgb2lin(c): + c = c / 255.0 + return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4 + + +def hexc(h, k=1.0, a=1.0): + """'#rrggbb' -> linear (r, g, b, a), rgb scaled by k (k > 1 for emissive glow).""" + h = h.lstrip('#') + return (srgb2lin(int(h[0:2], 16)) * k, srgb2lin(int(h[2:4], 16)) * k, srgb2lin(int(h[4:6], 16)) * k, a) + + +def mix(a, b, u): + return tuple(x + (y - x) * u for x, y in zip(a, b)) + + +def with_alpha(c, a): + return (c[0], c[1], c[2], c[3] * a) + + +def scale_rgb(c, k): + return (c[0] * k, c[1] * k, c[2] * k, c[3]) + + +# --- fonts ----------------------------------------------------------------------------------------- +class Face: + def __init__(self, path): + self.path = path + self.hbface = hb.Face(hb.Blob.from_file_path(path)) + self.hbfont = hb.Font(self.hbface) + self.upem = self.hbface.upem + self.ft = freetype.Face(path) + self.axes = [a.tag for a in self.ft.get_variation_info().axes] if self.ft.has_multiple_masters else [] + self.ft.set_char_size(1000 * 64) + self.ft.load_char('H', freetype.FT_LOAD_NO_HINTING) + self.cap = self.ft.glyph.metrics.horiBearingY / 64 / 1000 # cap height / em + self.ft.load_char('x', freetype.FT_LOAD_NO_HINTING) + self.xh = self.ft.glyph.metrics.horiBearingY / 64 / 1000 + + def _vary(self, var): + coords = tuple(var.get(a, None) for a in self.axes) + if self.axes: + info = {a.tag: a for a in self.ft.get_variation_info().axes} + full = tuple(v if v is not None else info[a].default for a, v in zip(self.axes, coords)) + self.ft.set_var_design_coords(full) + self.hbfont.set_variations({a: v for a, v in zip(self.axes, full)}) + + def shape(self, s, size, var, tracking=0.0): + """-> list of (gid, x, y, cluster) pen positions in px, and total advance.""" + self._vary(var) + buf = hb.Buffer() + buf.add_str(s) + buf.guess_segment_properties() + hb.shape(self.hbfont, buf, {'kern': True, 'liga': True}) + k = size / self.upem + out = [] + x = 0.0 + for info, pos in zip(buf.glyph_infos, buf.glyph_positions): + out.append((info.codepoint, x + pos.x_offset * k, -pos.y_offset * k, info.cluster)) + x += pos.x_advance * k + tracking + return out, x - (tracking if out else 0) + + def raster(self, gid, size, var): + self._vary(var) + self.ft.set_char_size(int(round(size * 64))) + self.ft.load_glyph(gid, freetype.FT_LOAD_RENDER | freetype.FT_LOAD_NO_HINTING) + g = self.ft.glyph + b = g.bitmap + arr = np.array(b.buffer, dtype=np.uint8).reshape(b.rows, b.width) if b.rows else np.zeros((0, 0), np.uint8) + return arr, g.bitmap_left, g.bitmap_top + + +FONT_FILES = { + 'archivo': 'Archivo[wdth,wght].ttf', + 'archivo-i': 'Archivo-Italic[wdth,wght].ttf', + 'mono': 'JetBrainsMono[wght].ttf', +} + + +@functools.lru_cache(None) +def face(name): + return Face(os.path.join(HERE, 'fonts', FONT_FILES[name])) + + +SIZE_LADDER = 2 ** (1 / 10) + + +def quant_size(px): + """Raster size ladder (~7% steps) so animated sizes reuse textures.""" + k = math.ceil(math.log(max(px, 2)) / math.log(SIZE_LADDER) - 1e-6) + return SIZE_LADDER ** k + + +# --- shaders --------------------------------------------------------------------------------------- +def _read(name): + with open(os.path.join(HERE, 'shaders', name), encoding='utf-8') as f: + return f.read() + + +def preprocess(src): + out = [] + for line in src.splitlines(): + if line.strip().startswith('#include'): + name = line.split('"')[1] + out.append(preprocess(COMMON if name == 'common.glsl' else _read(name))) + else: + out.append(line) + return '\n'.join(out) + + +VS_FULL = '''#version 430 +out vec2 v_uv; +void main(){ vec2 p = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2); v_uv = p; gl_Position = vec4(p * 2.0 - 1.0, 0, 1); } +''' + +SHAPE_VS = '''#version 430 +#include "common.glsl" +in vec2 a_corner; +in vec4 i_rect; in vec4 i_xf; in vec4 i_kind; in vec4 i_p0; in vec4 i_p1; in vec4 i_p2; in vec4 i_p3; +in vec4 i_fill; in vec4 i_stroke; in vec4 i_sw; +out vec2 v_lp; +flat out vec4 v_kind; flat out vec4 v_p0; flat out vec4 v_p1; flat out vec4 v_p2; flat out vec4 v_p3; +flat out vec4 v_fill; flat out vec4 v_stroke; flat out vec4 v_sw; +void main(){ + float pad = i_sw.x + 4.0 * i_sw.y + 2.0; + vec2 lp = mix(i_rect.xy - pad, i_rect.zw + pad, a_corner); + float c = cos(i_xf.z), s = sin(i_xf.z); + vec2 wp = i_xf.xy + i_xf.w * vec2(c * lp.x - s * lp.y, s * lp.x + c * lp.y); + gl_Position = vec4(to_ndc(cam(wp)), 0, 1); + v_lp = lp; v_kind = i_kind; v_p0 = i_p0; v_p1 = i_p1; v_p2 = i_p2; v_p3 = i_p3; + v_fill = i_fill; v_stroke = i_stroke; v_sw = i_sw; +} +''' + +SHAPE_FS = '''#version 430 +#include "common.glsl" +in vec2 v_lp; +flat in vec4 v_kind; flat in vec4 v_p0; flat in vec4 v_p1; flat in vec4 v_p2; flat in vec4 v_p3; +flat in vec4 v_fill; flat in vec4 v_stroke; flat in vec4 v_sw; +out vec4 o; +vec2 vtx(int i){ + vec4 q = i < 2 ? v_p0 : i < 4 ? v_p1 : i < 6 ? v_p2 : v_p3; + return (i % 2 == 0) ? q.xy : q.zw; +} +float sd_poly(vec2 p, int n){ + vec2 v0 = vtx(0); + float d = dot(p - v0, p - v0), sg = 1.0; + for (int i = 0, j = n - 1; i < n; j = i, i++){ + vec2 vi = vtx(i), vj = vtx(j); + vec2 e = vj - vi, w = p - vi; + vec2 b = w - e * clamp(dot(w, e) / dot(e, e), 0.0, 1.0); + d = min(d, dot(b, b)); + bvec3 cnd = bvec3(p.y >= vi.y, p.y < vj.y, e.x * w.y > e.y * w.x); + if (all(cnd) || all(not(cnd))) sg *= -1.0; + } + return sg * sqrt(d); +} +float sd_arc(vec2 p, float r, float th, float a0, float a1){ + float a = atan(p.y, p.x); + float mid = 0.5 * (a0 + a1), half_ = 0.5 * (a1 - a0); + float da = mod(a - mid + PI, 2.0 * PI) - PI; + if (abs(da) <= half_) return abs(length(p) - r) - th * 0.5; + vec2 e0 = r * vec2(cos(a0), sin(a0)), e1 = r * vec2(cos(a1), sin(a1)); + return min(length(p - e0), length(p - e1)) - th * 0.5; +} +void main(){ + int kind = int(v_kind.x + 0.5); + vec2 p = v_lp; + float d; + if (kind == 0) d = length(p) - v_p0.x; + else if (kind == 1) { vec2 q = abs(p) - v_p0.xy + v_p0.z; d = length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - v_p0.z; } + else if (kind == 2) { vec2 a = v_p0.xy, b = v_p0.zw; vec2 pa = p - a, ba = b - a; + float h = clamp(dot(pa, ba) / max(dot(ba, ba), 1e-6), 0.0, 1.0); + d = length(pa - ba * h) - mix(v_p1.x, v_p1.y, h); } + else if (kind == 5) d = sd_arc(p, v_p0.x, v_p0.y, v_p0.z, v_p0.w); + else d = sd_poly(p, int(v_kind.y + 0.5)) - v_kind.z; + float aa = max(length(fwidth(p)) * 0.75, 1e-4); + vec4 col = vec4(0); + float af = clamp(0.5 - d / aa, 0.0, 1.0) * v_fill.a; + col = vec4(v_fill.rgb * af, af); + if (v_sw.x > 0.0) { + float as_ = clamp(0.5 - (abs(d) - v_sw.x * 0.5) / aa, 0.0, 1.0) * v_stroke.a; + col = vec4(v_stroke.rgb * as_, as_) + col * (1.0 - as_); + } + if (v_sw.y > 0.0) { // emissive halo, additive (alpha untouched) + float q = max(d, 0.0) / v_sw.y; + float g = exp(-0.5 * q * q) * v_sw.z * (d > 0.0 ? 1.0 : 0.0); + col.rgb += v_fill.rgb * g; + } + o = col * u_alpha; +} +''' + +TEX_VS = '''#version 430 +#include "common.glsl" +in vec2 a_corner; +uniform vec4 u_rect; // local quad x0,y0,x1,y1 +uniform vec2 u_t; // translation +uniform vec4 u_m; // 2x2 matrix (m00, m10, m01, m11) +out vec2 v_uv; +void main(){ + vec2 lp = mix(u_rect.xy, u_rect.zw, a_corner); + vec2 wp = u_t + vec2(u_m.x * lp.x + u_m.z * lp.y, u_m.y * lp.x + u_m.w * lp.y); + gl_Position = vec4(to_ndc(cam(wp)), 0, 1); + v_uv = a_corner; +} +''' + +TEX_FS = '''#version 430 +#include "common.glsl" +in vec2 v_uv; +uniform sampler2D u_tex; +uniform vec4 u_color; +uniform vec4 u_reveal; // u0, u1, softness, mode (0 = horizontal, 1 = vertical) +uniform int u_mode; // 0 = mask (R channel) coloured, 1 = premultiplied rgba image +out vec4 o; +void main(){ + vec4 c; + if (u_mode == 0) { float m = texture(u_tex, v_uv).r; c = vec4(u_color.rgb, 1.0) * m * u_color.a; } + else c = texture(u_tex, v_uv) * u_color; + float x = u_reveal.w > 0.5 ? v_uv.y : v_uv.x; + float sft = max(u_reveal.z, 1e-4); + float r = smoothstep(u_reveal.x - sft, u_reveal.x, x) * (1.0 - smoothstep(u_reveal.y, u_reveal.y + sft, x)); + o = c * r * u_alpha; +} +''' + +COMMON = ''' +uniform vec4 u_cam; // centre x, y, zoom, rotation +uniform vec2 u_shake; +uniform vec2 u_logical; // 1920, 1080 +uniform float u_alpha; +const float PI = 3.14159265358979; +vec2 cam(vec2 p){ + vec2 c = u_logical * 0.5; + vec2 q = p - u_cam.xy; + float cs = cos(u_cam.w), sn = sin(u_cam.w); + q = vec2(cs * q.x - sn * q.y, sn * q.x + cs * q.y) * u_cam.z; + return q + c + u_shake; +} +vec2 uncam(vec2 s){ + vec2 c = u_logical * 0.5; + vec2 q = (s - c - u_shake) / u_cam.z; + float cs = cos(-u_cam.w), sn = sin(-u_cam.w); + return vec2(cs * q.x - sn * q.y, sn * q.x + cs * q.y) + u_cam.xy; +} +vec2 to_ndc(vec2 p){ return vec2(p.x / u_logical.x * 2.0 - 1.0, 1.0 - p.y / u_logical.y * 2.0); } +''' + + +def _mat(xf, skew): + """xf = (tx, ty, rot, scale) or (tx, ty, (m00, m01, m10, m11)); skew = (x shear, y scale).""" + if len(xf) == 3: + m00, m01, m10, m11 = xf[2] + else: + c, s_ = math.cos(xf[2]) * xf[3], math.sin(xf[2]) * xf[3] + m00, m01, m10, m11 = c, -s_, s_, c + sh, sy = skew + # M * [[1, sh], [0, sy]] + a, b, c2, d = m00, m00 * sh + m01 * sy, m10, m10 * sh + m11 * sy + return (a, c2, b, d) + + +class Engine: + def __init__(self, scale=1.0, samples=1, shutter=0.5, fps=60, libgl=None): + libgl = libgl or os.environ.get('MGL_LIBGL') or 'opengl32.dll' + self.ctx = moderngl.create_context(standalone=True, require=430, libgl=libgl) + self.renderer = self.ctx.info['GL_RENDERER'] + self.scale = scale + self.W, self.H = int(LW * scale), int(LH * scale) + self.samples, self.shutter, self.fps = samples, shutter, fps + ctx = self.ctx + self.progs = {} + self.p_shape = self.program(SHAPE_VS, SHAPE_FS) + self.p_tex = self.program(TEX_VS, TEX_FS) + self.quad = ctx.buffer(np.array([0, 0, 1, 0, 0, 1, 1, 1], dtype='f4').tobytes()) + self.inst = ctx.buffer(reserve=4096 * 40 * 4, dynamic=True) + self.vao_shape = ctx.vertex_array(self.p_shape, [ + (self.quad, '2f', 'a_corner'), + (self.inst, '4f 4f 4f 4f 4f 4f 4f 4f 4f 4f/i', + 'i_rect', 'i_xf', 'i_kind', 'i_p0', 'i_p1', 'i_p2', 'i_p3', 'i_fill', 'i_stroke', 'i_sw')]) + self.vao_tex = ctx.vertex_array(self.p_tex, [(self.quad, '2f', 'a_corner')]) + self.empty_vao = {} + # targets + self.t_scene = ctx.texture((self.W, self.H), 4, dtype='f2') + self.f_scene = ctx.framebuffer([self.t_scene]) + self.t_acc = ctx.texture((self.W, self.H), 4, dtype='f4') + self.f_acc = ctx.framebuffer([self.t_acc]) + self.t_out = ctx.texture((self.W, self.H), 4, dtype='f1') + self.f_out = ctx.framebuffer([self.t_out]) + self.bloom = [] + w, h = self.W, self.H + for i in range(7): + w, h = max(w // 2, 1), max(h // 2, 1) + t = ctx.texture((w, h), 4, dtype='f2') + t.repeat_x = t.repeat_y = False + self.bloom.append((t, ctx.framebuffer([t]))) + for t in (self.t_scene, self.t_acc): + t.repeat_x = t.repeat_y = False + self.p_acc = self.program(VS_FULL, ACC_FS) + self.p_bright = self.program(VS_FULL, BRIGHT_FS) + self.p_down = self.program(VS_FULL, DOWN_FS) + self.p_up = self.program(VS_FULL, UP_FS) + self.p_final = self.program(VS_FULL, FINAL_FS) + self.text_cache = OrderedDict() + self.img_cache = {} + self.rt_cache = {} + self.batch = [] + self.cam = (LW / 2, LH / 2, 1.0, 0.0) + self.shake = (0.0, 0.0) + self.alpha = 1.0 + self.logical = (LW, LH) + self.target_stack = [] + + # ---- programs + def program(self, vs, fs): + key = (vs, fs) + if key not in self.progs: + self.progs[key] = self.ctx.program(vertex_shader=preprocess(vs), fragment_shader=preprocess(fs)) + return self.progs[key] + + def fs_program(self, src): + return self.program(VS_FULL, src) + + def _set(self, prog, **u): + for k, v in u.items(): + if k in prog: + prog[k].value = v + + def _common(self, prog): + self._set(prog, u_cam=self.cam, u_shake=self.shake, u_logical=self.logical, u_alpha=self.alpha) + + # ---- render targets (offscreen layers in logical space) + def layer(self, name, w=LW, h=LH): + key = (name, w, h) + if key not in self.rt_cache: + t = self.ctx.texture((int(w * self.scale), int(h * self.scale)), 4, dtype='f2') + t.build_mipmaps() + t.filter = (moderngl.LINEAR_MIPMAP_LINEAR, moderngl.LINEAR) + self.rt_cache[key] = (t, self.ctx.framebuffer([t])) + return self.rt_cache[key] + + def push_target(self, name, w=LW, h=LH, clear=(0, 0, 0, 0)): + """Draw into an offscreen layer of logical size w x h until pop_target(); returns its texture.""" + self.flush() + t, fb = self.layer(name, w, h) + self.target_stack.append((fb, t, self.cam, self.shake, self.logical, self.alpha)) + fb.use() + fb.clear(*clear) + self.logical = (w, h) + self.cam = (w / 2, h / 2, 1.0, 0.0) + self.shake = (0.0, 0.0) + self.alpha = 1.0 + return t + + def pop_target(self): + self.flush() + fb, t, self.cam, self.shake, self.logical, self.alpha = self.target_stack.pop() + t.build_mipmaps() + prev = self.target_stack[-1][0] if self.target_stack else self.f_scene + prev.use() + return t + + # ---- shapes + def _shape(self, kind, rect, xf, p, fill, stroke=(0, 0, 0, 0), sw=0.0, glow=0.0, glow_k=0.0, extra=(0, 0)): + pp = list(p) + [0.0] * (16 - len(p)) + self.batch.append(('s', (rect, xf, (kind, extra[0], extra[1], 0), pp, fill, stroke, (sw, glow, glow_k, 0)))) + + def circle(self, x, y, r, fill, stroke=(0, 0, 0, 0), sw=0.0, glow=0.0, glow_k=0.0): + self._shape(0, (-r, -r, r, r), (x, y, 0, 1), (r,), fill, stroke, sw, glow, glow_k) + + def rect(self, x, y, w, h, fill, r=0.0, rot=0.0, stroke=(0, 0, 0, 0), sw=0.0, glow=0.0, glow_k=0.0, s=1.0): + """Centred rectangle (x, y = centre) with corner radius r.""" + hw, hh = abs(w) / 2, abs(h) / 2 + r = min(r, hw, hh) + self._shape(1, (-hw, -hh, hw, hh), (x, y, rot, s), (hw, hh, r), fill, stroke, sw, glow, glow_k) + + def line(self, x0, y0, x1, y1, width, fill, w1=None, glow=0.0, glow_k=0.0): + r0 = width / 2 + r1 = (w1 if w1 is not None else width) / 2 + rr = max(r0, r1) + self._shape(2, (min(x0, x1) - rr, min(y0, y1) - rr, max(x0, x1) + rr, max(y0, y1) + rr), (0, 0, 0, 1), + (x0, y0, x1, y1, r0, r1), fill, glow=glow, glow_k=glow_k) + + def poly(self, pts, fill, x=0.0, y=0.0, rot=0.0, s=1.0, stroke=(0, 0, 0, 0), sw=0.0, round_=0.0, glow=0.0, + glow_k=0.0): + assert 3 <= len(pts) <= 8 + xs = [p[0] for p in pts] + ys = [p[1] for p in pts] + flat = [c for p in pts for c in p] + self._shape(4, (min(xs) - round_, min(ys) - round_, max(xs) + round_, max(ys) + round_), (x, y, rot, s), + flat, fill, stroke, sw, glow, glow_k, extra=(len(pts), round_)) + + def arc(self, x, y, r, th, a0, a1, fill, glow=0.0, glow_k=0.0): + """Ring segment from angle a0 to a1 (radians, y down -> clockwise).""" + if a1 - a0 >= 2 * math.pi - 1e-4: + a0, a1 = -math.pi - 0.01, math.pi + 0.01 + R = r + th + self._shape(5, (-R, -R, R, R), (x, y, 0, 1), (r, th, a0, a1), fill, glow=glow, glow_k=glow_k) + + def ring(self, x, y, r, th, fill, glow=0.0, glow_k=0.0): + self.arc(x, y, r, th, -math.pi, math.pi, fill, glow, glow_k) + + def polyline(self, pts, width, fill, glow=0.0, glow_k=0.0): + for a, b in zip(pts, pts[1:]): + self.line(a[0], a[1], b[0], b[1], width, fill, glow=glow, glow_k=glow_k) + + # ---- text + def _text_tex(self, s, font, size, var, tracking): + fsize = quant_size(size * self.scale) + k = fsize / (size * self.scale) + var = {a: round(v / 2) * 2 for a, v in var.items()} + key = (s, font, round(fsize, 3), tuple(sorted(var.items())), round(tracking * self.scale * k, 2)) + e = self.text_cache.get(key) + if e is not None: + self.text_cache.move_to_end(key) + return e, fsize + F = face(font) + glyphs, adv = F.shape(s, fsize, var, tracking * self.scale * k) + pad = int(fsize * 0.05) + 4 + asc = int(math.ceil(fsize * 1.05)) + desc = int(math.ceil(fsize * 0.35)) + wpx = int(math.ceil(adv)) + 2 * pad + int(fsize * 0.3) + hpx = asc + desc + 2 * pad + canvas = np.zeros((hpx, wpx), np.uint8) + ox, oy = pad + int(fsize * 0.15), pad + asc + for gid, gx, gy, _ in glyphs: + bm, left, top = F.raster(gid, fsize, var) + if bm.size == 0: + continue + x0 = int(round(ox + gx)) + left + y0 = int(round(oy + gy)) - top + h, w = bm.shape + xa, ya = max(x0, 0), max(y0, 0) + xb, yb = min(x0 + w, wpx), min(y0 + h, hpx) + if xb <= xa or yb <= ya: + continue + sub = canvas[ya:yb, xa:xb] + np.maximum(sub, bm[ya - y0:yb - y0, xa - x0:xb - x0], out=sub) + tex = self.ctx.texture((wpx, hpx), 1, canvas.tobytes()) + tex.build_mipmaps() + tex.filter = (moderngl.LINEAR_MIPMAP_LINEAR, moderngl.LINEAR) + tex.repeat_x = tex.repeat_y = False + e = (tex, wpx, hpx, ox, oy, adv, F.cap * fsize) + self.text_cache[key] = e + if len(self.text_cache) > 900: + _, old = self.text_cache.popitem(last=False) + old[0].release() + return e, fsize + + def measure(self, s, font='archivo', size=100, wght=700, wdth=100, tracking=0.0): + F = face(font) + var = {'wght': wght, 'wdth': wdth} if font != 'mono' else {'wght': wght} + return F.shape(s, size, var, tracking)[1] + + def text(self, s, x, y, size=100, color=(1, 1, 1, 1), font='archivo', wght=700, wdth=100, align='center', + valign='cap', rot=0.0, s_=1.0, tracking=0.0, reveal=(0.0, 1.0, 0.0, 0.0), skew=0.0, sy=1.0, mat=None): + """Draw a line of text. (x, y) is the anchor: align = left/center/right on the advance box; valign = + 'cap' (centre of cap height), 'base' (baseline), 'top' (cap top). Returns the advance width.""" + if not s: + return 0.0 + var = {'wght': wght, 'wdth': wdth} if font != 'mono' else {'wght': wght} + (tex, wpx, hpx, ox, oy, adv, cap), fsize = self._text_tex(s, font, size, var, tracking) + k = size / (fsize / self.scale) / self.scale # texture px -> logical px + adv_l = adv * k + ax = {'left': 0.0, 'center': adv_l / 2, 'right': adv_l}[align] + ay = {'base': 0.0, 'cap': -cap * k / 2, 'top': -cap * k}[valign] + x0 = -ox * k - ax + y0 = -oy * k - ay + xf = (x, y, mat) if mat is not None else (x, y, rot, s_) + self.batch.append(('t', (tex, (x0, y0, x0 + wpx * k, y0 + hpx * k), xf, color, reveal, (skew, sy), 0))) + return adv_l + + def image(self, tex, x, y, w, h, rot=0.0, color=(1, 1, 1, 1), s=1.0, reveal=(0.0, 1.0, 0.0, 0.0), mat=None): + """Premultiplied RGBA texture (e.g. a pushed layer, which is stored bottom-up) centred at x, y.""" + xf = (x, y, mat) if mat is not None else (x, y, rot, s) + self.batch.append(('t', (tex, (-w / 2, h / 2, w / 2, -h / 2), xf, color, reveal, (0, 1), 1))) + + def glyph_runs(self, s, font='archivo', size=100, wght=700, wdth=100, tracking=0.0): + """Per-character x offsets (logical px, pen start = 0) and total advance, for per-letter animation.""" + F = face(font) + var = {'wght': wght, 'wdth': wdth} if font != 'mono' else {'wght': wght} + glyphs, adv = F.shape(s, size, var, tracking) + xs = [0.0] * len(s) + for gid, gx, gy, cl in glyphs: + xs[cl] = gx + ends = [] + for i in range(len(s)): + ends.append(xs[i + 1] if i + 1 < len(s) else adv) + return xs, ends, adv + + # ---- fullscreen shader layer + def fullscreen(self, prog, **uniforms): + self.flush() + self._common(prog) + self._set(prog, u_res=(self.W, self.H), **uniforms) + vao = self.empty_vao.get(id(prog)) + if vao is None: + vao = self.empty_vao[id(prog)] = self.ctx.vertex_array(prog, []) + vao.render(moderngl.TRIANGLES, vertices=3) + + def bind_textures(self, prog, **tex): + for i, (name, t) in enumerate(tex.items()): + t.use(location=i + 1) + if name in prog: + prog[name].value = i + 1 + + # ---- batching + def flush(self): + if not self.batch: + return + ctx = self.ctx + ctx.enable(moderngl.BLEND) + ctx.blend_func = (moderngl.ONE, moderngl.ONE_MINUS_SRC_ALPHA) + i = 0 + while i < len(self.batch): + kind = self.batch[i][0] + j = i + while j < len(self.batch) and self.batch[j][0] == kind and (kind == 's' or j == i): + j += 1 + if kind == 's': + rows = [] + for _, (rect, xf, kd, pp, fill, stroke, sw) in self.batch[i:j]: + rows.append(list(rect) + list(xf) + list(kd) + pp + list(fill) + list(stroke) + list(sw)) + data = np.array(rows, dtype='f4') + if data.nbytes > self.inst.size: + self.inst.orphan(data.nbytes * 2) + self.inst.write(data.tobytes()) + self._common(self.p_shape) + self.vao_shape.render(moderngl.TRIANGLE_STRIP, vertices=4, instances=len(rows)) + else: + tex, rect, xf, color, reveal, skew, mode = self.batch[i][1] + p = self.p_tex + self._common(p) + tex.use(location=0) + p['u_tex'].value = 0 + p['u_rect'].value = rect + p['u_t'].value = xf[:2] + p['u_m'].value = _mat(xf, skew) + p['u_color'].value = color + p['u_reveal'].value = reveal + p['u_mode'].value = mode + self.vao_tex.render(moderngl.TRIANGLE_STRIP, vertices=4) + i = j + self.batch = [] + + # ---- frame + def begin_sub(self, clear): + self.f_scene.use() + self.f_scene.clear(*clear) + self.logical = (LW, LH) + self.cam = (LW / 2, LH / 2, 1.0, 0.0) + self.shake = (0.0, 0.0) + self.alpha = 1.0 + + def accumulate(self, weight, first): + self.flush() + ctx = self.ctx + self.f_acc.use() + if first: + self.f_acc.clear(0, 0, 0, 0) + ctx.enable(moderngl.BLEND) + ctx.blend_func = (moderngl.ONE, moderngl.ONE) + self.t_scene.use(location=0) + self.p_acc['u_src'].value = 0 + self.p_acc['u_w'].value = weight + self._fs(self.p_acc) + + def _fs(self, prog): + vao = self.empty_vao.get(id(prog)) + if vao is None: + vao = self.empty_vao[id(prog)] = self.ctx.vertex_array(prog, []) + vao.render(moderngl.TRIANGLES, vertices=3) + + def post(self, P, frame_idx): + ctx = self.ctx + ctx.disable(moderngl.BLEND) + # bloom + t0, f0 = self.bloom[0] + f0.use() + self.t_acc.use(location=0) + self.p_bright['u_src'].value = 0 + self.p_bright['u_thresh'].value = P.get('bloom_thresh', 0.9) + self.p_bright['u_texel'].value = (1 / self.W, 1 / self.H) + self._fs(self.p_bright) + for i in range(1, len(self.bloom)): + src, _ = self.bloom[i - 1] + dst, fb = self.bloom[i] + fb.use() + src.use(location=0) + self.p_down['u_src'].value = 0 + self.p_down['u_texel'].value = (1 / src.width, 1 / src.height) + self._fs(self.p_down) + ctx.enable(moderngl.BLEND) + ctx.blend_func = (moderngl.ONE, moderngl.ONE) + for i in range(len(self.bloom) - 1, 0, -1): + src, _ = self.bloom[i] + dst, fb = self.bloom[i - 1] + fb.use() + src.use(location=0) + self.p_up['u_src'].value = 0 + self.p_up['u_texel'].value = (1 / src.width, 1 / src.height) + self._fs(self.p_up) + ctx.disable(moderngl.BLEND) + self.f_out.use() + p = self.p_final + self.t_acc.use(location=0) + self.bloom[0][0].use(location=1) + p['u_src'].value = 0 + p['u_bloom'].value = 1 + self._set(p, u_res=(self.W, self.H), u_scale=self.scale, + u_bloom_k=P.get('bloom', 0.6), u_ca=P.get('ca', 0.0), u_vig=P.get('vignette', 0.35), + u_grain=P.get('grain', 0.045) * float(os.environ.get('GRAIN_K', '0.6')), u_flash=P.get('flash', (0, 0, 0, 0)), u_invert=P.get('invert', 0.0), + u_frame=float(frame_idx), u_exposure=P.get('exposure', 1.0), u_fade=P.get('fade', 0.0), + u_scan=P.get('scan', 0.0), u_grain_rate=float(os.environ.get('GRAIN_RATE', '0'))) + self._fs(p) + + def read(self): + return self.t_out.read() + + +ACC_FS = '''#version 430 +in vec2 v_uv; uniform sampler2D u_src; uniform float u_w; out vec4 o; +void main(){ o = texture(u_src, v_uv) * u_w; } +''' + +BRIGHT_FS = '''#version 430 +in vec2 v_uv; uniform sampler2D u_src; uniform float u_thresh; uniform vec2 u_texel; out vec4 o; +void main(){ + vec3 c = vec3(0); + for (int i = 0; i < 4; i++){ vec2 d = vec2((i & 1) == 0 ? -0.5 : 0.5, (i & 2) == 0 ? -0.5 : 0.5) * u_texel; + c += texture(u_src, v_uv + d).rgb; } + c *= 0.25; + float l = max(c.r, max(c.g, c.b)); + float knee = 0.25; + float s = clamp(l - u_thresh + knee, 0.0, 2.0 * knee); + s = s * s / (4.0 * knee + 1e-4); + float w = max(s, l - u_thresh) / max(l, 1e-4); + o = vec4(min(c * w, vec3(40.0)), 1); +} +''' + +DOWN_FS = '''#version 430 +in vec2 v_uv; uniform sampler2D u_src; uniform vec2 u_texel; out vec4 o; +void main(){ + vec2 t = u_texel; + vec3 a = texture(u_src, v_uv + t * vec2(-2, -2)).rgb, b = texture(u_src, v_uv + t * vec2(0, -2)).rgb, + c = texture(u_src, v_uv + t * vec2(2, -2)).rgb, d = texture(u_src, v_uv + t * vec2(-2, 0)).rgb, + e = texture(u_src, v_uv).rgb, f = texture(u_src, v_uv + t * vec2(2, 0)).rgb, + g = texture(u_src, v_uv + t * vec2(-2, 2)).rgb, h = texture(u_src, v_uv + t * vec2(0, 2)).rgb, + i = texture(u_src, v_uv + t * vec2(2, 2)).rgb, j = texture(u_src, v_uv + t * vec2(-1, -1)).rgb, + k = texture(u_src, v_uv + t * vec2(1, -1)).rgb, l = texture(u_src, v_uv + t * vec2(-1, 1)).rgb, + m = texture(u_src, v_uv + t * vec2(1, 1)).rgb; + vec3 r = e * 0.125 + (a + c + g + i) * 0.03125 + (b + d + f + h) * 0.0625 + (j + k + l + m) * 0.125; + o = vec4(r, 1); +} +''' + +UP_FS = '''#version 430 +in vec2 v_uv; uniform sampler2D u_src; uniform vec2 u_texel; out vec4 o; +void main(){ + vec2 t = u_texel; + vec3 r = texture(u_src, v_uv).rgb * 4.0; + r += (texture(u_src, v_uv + vec2(-t.x, 0)).rgb + texture(u_src, v_uv + vec2(t.x, 0)).rgb + + texture(u_src, v_uv + vec2(0, -t.y)).rgb + texture(u_src, v_uv + vec2(0, t.y)).rgb) * 2.0; + r += texture(u_src, v_uv + vec2(-t.x, -t.y)).rgb + texture(u_src, v_uv + vec2(t.x, -t.y)).rgb + + texture(u_src, v_uv + vec2(-t.x, t.y)).rgb + texture(u_src, v_uv + vec2(t.x, t.y)).rgb; + o = vec4(r / 16.0 * 0.85, 1); +} +''' + +FINAL_FS = '''#version 430 +in vec2 v_uv; uniform sampler2D u_src; uniform sampler2D u_bloom; uniform vec2 u_res; uniform float u_scale; +uniform float u_grain_rate, u_bloom_k, u_ca, u_vig, u_grain, u_invert, u_frame, u_exposure, u_fade, u_scan; uniform vec4 u_flash; +out vec4 o; +uint pcg(uint v){ uint s = v * 747796405u + 2891336453u; uint w = ((s >> ((s >> 28u) + 4u)) ^ s) * 277803737u; + return (w >> 22u) ^ w; } +float hash(vec3 p){ // integer hash: bit-identical on every GL implementation + uvec3 q = uvec3(ivec3(p) + 65536); + return float(pcg(q.x + pcg(q.y + pcg(q.z))) & 0xffffffu) / 16777216.0; } +vec3 tonemap(vec3 c){ + // linear below 0.8, smooth shoulder to 1 + vec3 k = max(c - 0.8, 0.0); + return min(c, 0.8) + 0.2 * (1.0 - exp(-k / 0.2)); +} +void main(){ + vec2 uv = vec2(v_uv.x, 1.0 - v_uv.y); // flip so the readback is top row first + vec2 d = uv - 0.5; + vec3 c; + if (u_ca > 0.0) { + vec2 off = d * u_ca * 0.012; + c = vec3(texture(u_src, uv + off).r, texture(u_src, uv).g, texture(u_src, uv - off).b); + } else c = texture(u_src, uv).rgb; + c += texture(u_bloom, uv).rgb * u_bloom_k; + c *= u_exposure; + c = tonemap(c); + c = mix(c, vec3(1.0) - c, u_invert); + c = mix(c, u_flash.rgb, u_flash.a); + float v = 1.0 - u_vig * pow(length(d * vec2(1.0, 0.75)) * 1.25, 2.4); + c *= clamp(v, 0.0, 1.0); + if (u_scan > 0.0) c *= 1.0 - u_scan * (0.5 + 0.5 * sin(uv.y * u_res.y * 3.14159 / max(u_scale * 2.0, 1.0))); + c *= 1.0 - u_fade; + vec2 px = floor(vec2(gl_FragCoord.x, u_res.y - gl_FragCoord.y) / max(u_scale, 0.5)); + float gf = u_grain_rate > 0.0 ? floor(u_frame / u_grain_rate) : 0.0; // 0 = a fixed print texture + float g = hash(vec3(px, mod(gf, 97.0))) + hash(vec3(px + 17.0, mod(gf, 89.0))) - 1.0; + float lum = dot(c, vec3(0.2126, 0.7152, 0.0722)); + c += g * u_grain * (0.35 + 0.65 * (1.0 - lum)) * (0.6 + 0.4 * lum); + c = clamp(c, 0.0, 1.0); + vec3 s = mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(0.0031308, c)); + s += (hash(vec3(px, mod(gf, 61.0) + 3.0)) - 0.5) / 255.0; + o = vec4(s, 1); +} +''' diff --git a/intro-video/hype/fetch_fonts.py b/intro-video/hype/fetch_fonts.py new file mode 100644 index 00000000..b5b144cd --- /dev/null +++ b/intro-video/hype/fetch_fonts.py @@ -0,0 +1,22 @@ +"""Download the fonts the hype reel uses into ./fonts (SIL OFL 1.1, Google Fonts). Run once before rendering.""" +import os +import urllib.request + +HERE = os.path.dirname(os.path.abspath(__file__)) +GF = 'https://github.com/google/fonts/raw/main/ofl/' +FONTS = { + 'Archivo[wdth,wght].ttf': GF + 'archivo/Archivo%5Bwdth,wght%5D.ttf', + 'Archivo-Italic[wdth,wght].ttf': GF + 'archivo/Archivo-Italic%5Bwdth,wght%5D.ttf', + 'JetBrainsMono[wght].ttf': GF + 'jetbrainsmono/JetBrainsMono%5Bwght%5D.ttf', +} + +if __name__ == '__main__': + out = os.path.join(HERE, 'fonts') + os.makedirs(out, exist_ok=True) + for name, url in FONTS.items(): + path = os.path.join(out, name) + if not os.path.exists(path): + print('fetching', name) + with urllib.request.urlopen(url) as r, open(path, 'wb') as f: + f.write(r.read()) + print('fonts ready in', out) diff --git a/intro-video/hype/kit.py b/intro-video/hype/kit.py new file mode 100644 index 00000000..3f91af6b --- /dev/null +++ b/intro-video/hype/kit.py @@ -0,0 +1,377 @@ +"""Reusable visual pieces: backgrounds, HUD, glue blobs, jelly cubes, isometric boxes, code panels, slams.""" +import math +from anim import * +from engine import LW, LH, _read, hexc + +COS30, SIN30 = math.cos(math.pi / 6), 0.5 + + +# --- background ------------------------------------------------------------------------------------ +def bg(E, c): + E.rect(LW / 2, LH / 2, LW * 4, LH * 4, c) + + +def dot_grid(E, f, c, step=60, r=2.2, drift=(0.0, 0.0), pulse=0.0): + ox = (drift[0] * f.lt) % step + oy = (drift[1] * f.lt) % step + rr = r * (1 + pulse * f.kick) + y = -step + oy + while y < LH + step: + x = -step + ox + while x < LW + step: + E.circle(x, y, rr, c) + x += step + y += step + + +def line_grid(E, f, c, step=120, w=1.5, drift=(0.0, 0.0)): + ox = (drift[0] * f.lt) % step + oy = (drift[1] * f.lt) % step + x = -step + ox + while x < LW + step: + E.line(x, -10, x, LH + 10, w, c) + x += step + y = -step + oy + while y < LH + step: + E.line(-10, y, LW + 10, y, w, c) + y += step + + +# --- HUD ------------------------------------------------------------------------------------------- +def hud(E, f, left='', right='', col=None, alpha=1.0): + """Crop marks + tiny mono annotations; drawn in screen space.""" + col = col or a(BONE, 0.55) + col = a(col, alpha) + cam, sh = E.cam, E.shake + E.cam, E.shake = (LW / 2, LH / 2, 1.0, 0.0), (0.0, 0.0) + m, L = 44, 34 + for sx, sy in ((m, m), (LW - m, m), (m, LH - m), (LW - m, LH - m)): + dx = 1 if sx < LW / 2 else -1 + dy = 1 if sy < LH / 2 else -1 + E.line(sx, sy, sx + dx * L, sy, 2.5, col) + E.line(sx, sy, sx, sy + dy * L, 2.5, col) + if left: + E.text(left, m + 52, m + 6, 17, col, font='mono', wght=500, align='left', valign='cap') + if right: + E.text(right, LW - m - 52, m + 6, 17, col, font='mono', wght=500, align='right', valign='cap') + E.text(f'{int(f.bar):03d}.{int(f.beat) % 4 + 1} ', LW - m - 52, LH - m - 6, 17, col, font='mono', wght=500, + align='right') + E.text('MOBILEGL', m + 52, LH - m - 6, 17, col, font='mono', wght=700, align='left') + E.flush() + E.cam, E.shake = cam, sh + + +# --- glue ------------------------------------------------------------------------------------------ +_goo = {} + + +def goo(E, balls, caps=(), k=40.0, col=GLUE, rim=hexc('#2f8a2b'), hi=(1, 1, 1, 1), outline=5.0, bevel=0.0, glow=0.0): + """balls: [(x, y, r)], caps: [(ax, ay, bx, by, r0, r1)] in logical px (camera applies).""" + prog = _goo.get('p') + if prog is None: + prog = _goo['p'] = E.fs_program(_read('goo.glsl')) + bl = list(balls)[:64] + if bevel <= 0: # dome over the whole blob: bevel ~ the biggest radius + bevel = max([r for _, _, r in bl] + [max(c[4], c[5]) * 1.5 for c in caps] + [10.0]) + cp = list(caps)[:24] + bv = [(x, y, r, 0.0) for x, y, r in bl] + [(0, 0, 0, 0)] * (64 - len(bl)) + cv = [(c[0], c[1], c[2], c[3]) for c in cp] + [(0, 0, 0, 0)] * (24 - len(cp)) + cr = [(c[4], c[5], 0, 0) for c in cp] + [(0, 0, 0, 0)] * (24 - len(cp)) + prog['u_balls'].value = bv + if 'u_caps' in prog: + prog['u_caps'].value = cv + prog['u_capr'].value = cr + E.fullscreen(prog, u_nb=len(bl), u_nc=len(cp), u_k=k, u_col=col, u_rim=rim, u_hi=hi, u_outline=outline, + u_bevel=bevel, u_glow=glow) + + +def drop_shape(x, y, r, stretch=0.0): + """A falling droplet: ball + tapered tail upward (stretch in px).""" + if stretch <= 1: + return [(x, y, r)], [] + return [(x, y, r)], [(x, y - stretch, x, y, r * 0.18, r * 0.85)] + + +# --- jelly cubes ----------------------------------------------------------------------------------- +_cube = {} + + +GLUE_BODY = hexc('#5cb846') +GLUE_DEEP = hexc('#1f5e22') + + +def cubes(E, f, items, body=GLUE_BODY, alt=hexc('#2c6e30'), deep=GLUE_DEEP, speckle=1.0, pitch=0.6155): + """The MobileGlues mark: a block floating in glue. + items: [(x, y, half_size_px, yaw, squash, wobble, tint, alpha[, seconds_since_impact])]; a negative last value + means the block is still falling towards the glue, 0 is the hit, large (default) is settled.""" + prog = _cube.get('p') + if prog is None: + prog = _cube['p'] = E.fs_program(_read('block.glsl')) + it = list(items)[:16] + c1 = [(i[0], i[1], i[2], i[3]) for i in it] + [(0, 0, 1, 0)] * (16 - len(it)) + c2 = [(i[4], i[5], i[6], i[7]) for i in it] + [(0, 0, 0, 0)] * (16 - len(it)) + c3 = [((i[8] if len(i) > 8 else 100.0), 0, 0, 0) for i in it] + [(100.0, 0, 0, 0)] * (16 - len(it)) + prog['u_cubes'].value = c1 + prog['u_cubes2'].value = c2 + prog['u_cubes3'].value = c3 + E.fullscreen(prog, u_n=len(it), u_time=f.t, u_body=body[:3], u_alt=alt[:3], u_deep=deep[:3], + u_speckle=speckle, u_pitch=pitch) + + +# --- isometric ------------------------------------------------------------------------------------- +class Iso: + def __init__(self, cx, cy, s): + self.cx, self.cy, self.s = cx, cy, s + + def p(self, x, y, z): + return (self.cx + (x - y) * COS30 * self.s, self.cy + (x + y) * SIN30 * self.s - z * self.s) + + def box(self, E, x0, y0, z0, dx, dy, dz, top, left, right, line=None, sw=0.0): + x1, y1, z1 = x0 + dx, y0 + dy, z0 + dz + P = self.p + st = line or (0, 0, 0, 0) + E.poly([P(x0, y1, z0), P(x1, y1, z0), P(x1, y1, z1), P(x0, y1, z1)], left, stroke=st, sw=sw) + E.poly([P(x1, y0, z0), P(x1, y1, z0), P(x1, y1, z1), P(x1, y0, z1)], right, stroke=st, sw=sw) + E.poly([P(x0, y0, z1), P(x1, y0, z1), P(x1, y1, z1), P(x0, y1, z1)], top, stroke=st, sw=sw) + + def quad_top(self, E, x0, y0, z, dx, dy, c, line=None, sw=0.0): + P = self.p + E.poly([P(x0, y0, z), P(x0 + dx, y0, z), P(x0 + dx, y0 + dy, z), P(x0, y0 + dy, z)], c, + stroke=line or (0, 0, 0, 0), sw=sw) + + def mat_top(self, s=1.0): + return (COS30 * s, -COS30 * s, SIN30 * s, SIN30 * s) + + def mat_left(self, s=1.0): # face y = const, u along +x, v down + return (COS30 * s, 0.0, SIN30 * s, s) + + def mat_right(self, s=1.0): # face x = const, u along -y, v down + return (COS30 * s, 0.0, -SIN30 * s, s) + + +# --- type pieces ----------------------------------------------------------------------------------- +def chip(E, x, y, s, size=26, fg=INK, bg_=GLUE, font='mono', wght=700, align='center', pad=(18, 10), rot=0.0, + alpha=1.0, sc=1.0): + w = E.measure(s, font, size, wght) + ax = {'left': w / 2 + pad[0], 'center': 0, 'right': -w / 2 - pad[0]}[align] + c, sn = math.cos(rot), math.sin(rot) + cx, cy = x + ax * c * sc, y + ax * sn * sc + E.rect(cx, cy, (w + 2 * pad[0]) * sc, (size * 0.75 + 2 * pad[1]) * sc, a(bg_, alpha), r=10 * sc, rot=rot) + E.text(s, cx, cy, size * sc, a(fg, alpha), font=font, wght=wght, rot=rot) + return w + 2 * pad[0] + + +SYNTAX = {'kw': hexc('#8cff62'), 'fn': hexc('#f1f7ec'), 'num': hexc('#e7a94f'), 'str': hexc('#a0e28c'), + 'cm': hexc('#5f8f63'), 'p': hexc('#a9c4a6'), 'const': hexc('#d4f0c9')} + + +def tokenize(line): + """Very small GL/GLSL-ish highlighter -> [(text, kind)].""" + import re + out = [] + for m in re.finditer(r'(//.*$)|("[^"]*")|(\b\d[\w.]*\b)|(\bGL_\w+|\bMOBILEGL_\w+)|(\bgl[A-Z]\w*|\bwgl\w*|\begl\w*)|' + r'(\b(?:void|in|out|uniform|vec[234]|float|int|return|layout|#version|core|export)\b)|(\w+)|(\s+)|(.)', + line): + g = m.lastindex + kind = {1: 'cm', 2: 'str', 3: 'num', 4: 'const', 5: 'kw', 6: 'kw', 7: 'fn', 8: 'p', 9: 'p'}[g] + out.append((m.group(0), kind)) + return out + + +def code_line(E, s, x, y, size=30, alpha=1.0, chars=None, cursor=False, f=None): + """Monospace syntax-coloured line, left-aligned at x (baseline-ish centre y). chars = typed so far.""" + cw = size * 0.6 + n = len(s) if chars is None else max(0, min(len(s), int(chars))) + col = 0 + for tok, kind in tokenize(s): + if col >= n: + break + part = tok[:n - col] + if part.strip(): + E.text(part, x + col * cw, y, size, a(SYNTAX[kind], alpha), font='mono', wght=500, align='left') + col += len(tok) + if cursor and (f is None or (f.t * 2.2) % 1 < 0.6): + E.rect(x + n * cw + cw * 0.5, y, cw * 0.9, size * 1.05, a(GLUE, alpha)) + return n * cw + + +def slam(E, f, word, x, y, t_hit, size=260, col=BONE, wght=900, wdth=100, rot=0.0, punch=0.18, align='center', + font='archivo', tracking=0.0, hold=None): + """A word that lands at t_hit: scales down from (1 + punch) with a quick fade-in, then holds.""" + u = f.t - t_hit + if u < -0.02: + return 0.0 + k = 1 + punch * math.exp(-max(u, 0) / 0.07) + al = clamp01((u + 0.02) / 0.03) + if hold is not None and u > hold: + al *= 1 - clamp01((u - hold) / 0.06) + return E.text(word, x, y, size, a(col, al * col[3]), font=font, wght=wght, wdth=wdth, rot=rot, s_=k, align=align, + tracking=tracking) + + +def stamp(E, f, text, x, y, t_hit, size=70, col=CORAL, rot=-0.12, w_pad=34): + u = f.t - t_hit + if u < 0: + return + k = 1 + 0.6 * math.exp(-u / 0.05) + w = E.measure(text, 'archivo', size, 900, 80) + E.rect(x, y, (w + 2 * w_pad) * k, size * 1.2 * k, (0, 0, 0, 0), r=10, rot=rot, stroke=col, sw=8 * k) + E.text(text, x, y, size * k, col, wght=900, wdth=80, rot=rot) + + +def cross(E, x, y, r, col=CORAL, w=18, u=1.0): + u = clamp01(u) + if u <= 0: + return + d = r * 0.7071 + u1 = clamp01(u * 2) + u2 = clamp01(u * 2 - 1) + E.line(x - d, y - d, x - d + 2 * d * u1, y - d + 2 * d * u1, w, col) + if u2 > 0: + E.line(x + d, y - d, x + d - 2 * d * u2, y - d + 2 * d * u2, w, col) + + +def check(E, x, y, r, col=GLUE, w=12, u=1.0): + u = clamp01(u) + pts = [(x - r * 0.55, y), (x - r * 0.15, y + r * 0.42), (x + r * 0.6, y - r * 0.45)] + l1 = math.dist(pts[0], pts[1]) + l2 = math.dist(pts[1], pts[2]) + L = (l1 + l2) * u + if L <= 0: + return + if L <= l1: + q = L / l1 + E.line(pts[0][0], pts[0][1], lerp(pts[0][0], pts[1][0], q), lerp(pts[0][1], pts[1][1], q), w, col) + else: + E.line(pts[0][0], pts[0][1], pts[1][0], pts[1][1], w, col) + q = (L - l1) / l2 + E.line(pts[1][0], pts[1][1], lerp(pts[1][0], pts[2][0], q), lerp(pts[1][1], pts[2][1], q), w, col) + + +def arrow(E, x0, y0, x1, y1, w, col, head=None, u=1.0): + u = clamp01(u) + if u <= 0: + return + xe, ye = lerp(x0, x1, u), lerp(y0, y1, u) + ang = math.atan2(ye - y0, xe - x0) + head = head or w * 3.2 + bx, by = xe - math.cos(ang) * head * 0.8, ye - math.sin(ang) * head * 0.8 + E.line(x0, y0, bx, by, w, col) + E.poly([(xe, ye), (xe - math.cos(ang - 0.5) * head, ye - math.sin(ang - 0.5) * head), + (xe - math.cos(ang + 0.5) * head, ye - math.sin(ang + 0.5) * head)], col) + + +def screen_cam(E, f, zoom=1.0, rot=0.0, cx=LW / 2, cy=LH / 2, shake=0.0, seed=0): + E.cam = (cx, cy, zoom, rot) + if shake > 0: + fi = int(f.t * 60) + E.shake = ((rnd(seed, fi, 1) - 0.5) * 2 * shake, (rnd(seed, fi, 2) - 0.5) * 2 * shake) + else: + E.shake = (0.0, 0.0) + + +# --- plates / words -------------------------------------------------------------------------------- +PLATES = { + 'ink': (INK, BONE, a(MOSS, 0.55)), + 'mint': (MINT, INK, a(LEAF, 0.35)), + 'glue': (GLUE, INK, a(LEAF, 0.45)), + 'pale': (PALE, INK, a(LEAF, 0.3)), + 'black': (hexc('#000000'), BONE, None), +} + + +def plate(E, f, kind='ink', grid=True, drift=(0.0, 0.0)): + """Background colour + dot grid; returns (bg, fg).""" + b, fg, g = PLATES[kind] + bg(E, b) + if grid and g is not None: + dot_grid(E, f, g, 60, 2.2, drift) + return b, fg + + +def fit_size(E, word, width, wght=900, wdth=100, font='archivo', cap=640): + w = E.measure(word, font, 100, wght, wdth) + return min(cap, 100 * width / max(w, 1)) + + +TRI_FS = '''#version 430 +#include "common.glsl" +in vec2 v_uv; out vec4 o; +uniform vec2 u_res; uniform vec2 u_p[3]; uniform vec4 u_c[3]; +float cross2(vec2 a, vec2 b){ return a.x * b.y - a.y * b.x; } +void main(){ + vec2 p = uncam(vec2(v_uv.x, 1.0 - v_uv.y) * u_logical); + vec2 a = u_p[0], b = u_p[1], c = u_p[2]; + float A = cross2(b - a, c - a); + float w0 = cross2(b - p, c - p) / A, w1 = cross2(c - p, a - p) / A, w2 = 1.0 - w0 - w1; + // signed distance to the triangle (negative inside) + float d = 1e5; + vec2 e[3] = vec2[3](b - a, c - b, a - c), v[3] = vec2[3](a, b, c); + for (int i = 0; i < 3; i++){ vec2 pv = p - v[i]; vec2 q = pv - e[i] * clamp(dot(pv, e[i]) / dot(e[i], e[i]), 0.0, 1.0); + d = min(d, length(q)); } + bool inside = w0 >= 0.0 && w1 >= 0.0 && w2 >= 0.0; + float sd = inside ? -d : d; + float px = u_logical.x / u_res.x / u_cam.z; + float al = clamp(0.5 - sd / px, 0.0, 1.0); + vec4 col = u_c[0] * clamp(w0, 0.0, 1.0) + u_c[1] * clamp(w1, 0.0, 1.0) + u_c[2] * clamp(w2, 0.0, 1.0); + o = vec4(col.rgb, 1.0) * al * col.a * u_alpha; +} +''' +_tri = {} + + +def tri_rgb(E, pts, cols): + prog = _tri.get('p') + if prog is None: + prog = _tri['p'] = E.fs_program(TRI_FS) + prog['u_p'].value = [tuple(p) for p in pts] + prog['u_c'].value = [tuple(c) for c in cols] + E.fullscreen(prog) + + +def typed(f, s, b0, b1): + """Substring of s typed between local beats b0 and b1.""" + n = int(len(s) * clamp01((f.lbeat - b0) / (b1 - b0)) + 1e-6) + return s[:n] + + +def word_seq(E, f, words, b0, step, x, y, size, col, **kw): + """Words that replace each other, one per `step` beats from b0; returns the active index.""" + k = int(math.floor((f.lbeat - b0) / step + 1e-6)) + if 0 <= k < len(words): + slam(E, f, words[k], x, y, f.t - (f.lbeat - (b0 + k * step)) * S.BEAT, size, col, **kw) + return k + + +def speedlines(E, f, k, col=None, n=70, cx=LW / 2, cy=LH / 2, seed=0): + """Radial streaks rushing outward; k = intensity 0..1 (speed, length and count all scale).""" + if k <= 0.01: + return + col = col or BONE + for i in range(int(n * min(1.0, 0.3 + k))): + ang = rnd('sl', seed, i) * 2 * math.pi + sp = 0.4 + 0.6 * rnd('sp', seed, i) + ph = (rnd('ph', seed, i) + f.t * sp * (0.6 + 2.4 * k)) % 1.0 + r0 = 120 + ph * ph * 1400 + ln = (40 + 360 * k) * (0.3 + ph) + x0, y0 = cx + math.cos(ang) * r0, cy + math.sin(ang) * r0 + x1, y1 = cx + math.cos(ang) * (r0 + ln), cy + math.sin(ang) * (r0 + ln) + E.line(x0, y0, x1, y1, 2 + 5 * k * ph, a(col, (0.15 + 0.5 * k) * min(1, ph * 3))) + + +def beat_plate(f, kinds, b0=0.0): + """Plate kind for the current beat (never changes faster than once a beat).""" + k = int(math.floor(f.lbeat - b0 + 1e-6)) + return kinds[k % len(kinds)], k + + +def climax(f, b0, b1): + """0 -> 1 ramp (accelerating) over local beats [b0, b1].""" + return in_cubic(clamp01((f.lbeat - b0) / (b1 - b0))) + + +def pre_drop(E, f, cx, cy, size, t_hit, scale=1.0): + """The silent beat before a drop: a spotlit blob of glue waits, the block falls into it on the downbeat.""" + bg(E, hexc('#000000')) + E.circle(cx, cy + 40 * scale, 520 * scale, a(INK2, 0.9)) + cubes(E, f, [(cx, cy, size, 0.0, 0.0, 0.0, 0.0, 1.0, f.t - t_hit)]) diff --git a/intro-video/hype/music.py b/intro-video/hype/music.py new file mode 100644 index 00000000..19033b4e --- /dev/null +++ b/intro-video/hype/music.py @@ -0,0 +1,576 @@ +"""Hype electro-pop instrumental for the MobileGL showreel, synthesised from score.py. +usage: python music.py out.wav""" +import sys +import numpy as np +from scipy.signal import fftconvolve, butter, sosfiltfilt +from dsp import SR, saw, pulse, sine, svf, onepole_hp, pingpong, compress, limit, mtof, adsr +import score as S + +N = int(S.END * SR) +rng = np.random.default_rng(1234) + + +def idx(t): + return int(round(t * SR)) + + +class Bus: + def __init__(self, name): + self.name = name + self.l = np.zeros(N) + self.r = np.zeros(N) + + def add(self, t, sig, pan=0.0, gain=1.0): + """sig: mono array or (l, r) tuple; pan -1..1 (equal-power).""" + i0 = idx(t) + if isinstance(sig, tuple): + l, r = sig + else: + a = (pan + 1) * np.pi / 4 + l, r = sig * np.cos(a) * 1.4142, sig * np.sin(a) * 1.4142 + if i0 < 0: + l, r, i0 = l[-i0:], r[-i0:], 0 + n = min(len(l), N - i0) + if n <= 0: + return + self.l[i0:i0 + n] += gain * l[:n] + self.r[i0:i0 + n] += gain * r[:n] + + def apply(self, g): + self.l *= g + self.r *= g + + +def noise(n): + return rng.standard_normal(n) + + +def tarr(n): + return np.arange(n) / SR + + +# -------------------------------------------------------------------------------------------------- +# automation helpers +def curve(points): + """Piecewise-exponential-ish automation over the whole song from [(time, value)] (log interp).""" + ts = np.array([p[0] for p in points]) + vs = np.log(np.array([p[1] for p in points])) + return np.exp(np.interp(np.arange(N) / SR, ts, vs)) + + +def lin_curve(points): + ts = np.array([p[0] for p in points]) + vs = np.array([p[1] for p in points]) + return np.interp(np.arange(N) / SR, ts, vs) + + +def sidechain(depth, rel=0.16): + """Gain curve ducking on every kick.""" + g = np.ones(N) + kn = int(0.6 * SR) + t = tarr(kn) + shape = 1 - depth * np.where(t < 0.004, t / 0.004, np.exp(-(np.maximum(t - 0.004, 0) / rel) ** 1.6)) + for tk, v in S.events('kick'): + i0 = idx(tk) + n = min(kn, N - i0) + g[i0:i0 + n] = np.minimum(g[i0:i0 + n], 1 - (1 - shape[:n]) * min(v, 1.0)) + return g + + +def gaps(): + """Mute window (one beat of silence) before each drop.""" + g = np.ones(N) + for b in (16, 56, 88): + a0, a1 = idx(S.tb(b - 1, 3)), idx(S.tb(b)) + f = int(0.006 * SR) + g[a0:a1] = 0 + g[a0 - f:a0] = np.linspace(1, 0, f) + g[a1 - f // 2:a1] = 0 + return g + + +# -------------------------------------------------------------------------------------------------- +# drums +def kick(v): + n = int(0.5 * SR) + t = tarr(n) + f = 46 + 130 * np.exp(-t / 0.032) + 40 * np.exp(-t / 0.006) + body = sine(f, 0.0) * (np.exp(-t / 0.32) * np.minimum(t / 0.002, 1)) + click = svf(noise(n), np.full(n, 3500.0), 0.2, 1) * np.exp(-t / 0.004) * 0.6 + x = np.tanh(1.6 * (body + click)) * 0.95 + return x * v + + +def clap(v): + n = int(0.35 * SR) + t = tarr(n) + nz = svf(noise(n), np.full(n, 1300.0), 0.35, 1) * 2.2 + env = np.zeros(n) + for k, d in enumerate((0.0, 0.009, 0.018, 0.027)): + m = t >= d + env[m] += np.exp(-(t[m] - d) / (0.006 if k < 3 else 0.11)) * (0.8 if k < 3 else 1.0) + body = sine(np.full(n, 190.0) + 40 * np.exp(-t / 0.01), 0.25) * np.exp(-t / 0.05) * 0.35 + return (nz * env + body) * v * 0.8 + + +def snare(v, pitch=1.0): + n = int(0.25 * SR) + t = tarr(n) + nz = svf(noise(n), np.full(n, 4200.0 * pitch), 0.1, 1) * 1.8 * np.exp(-t / 0.07) + body = sine(np.full(n, 200.0 * pitch) + 60 * np.exp(-t / 0.008), 0.0) * np.exp(-t / 0.06) + return (nz * 0.7 + body * 0.6) * v + + +METAL = np.array([205.3, 304.4, 369.6, 522.7, 540.0, 800.0]) * 1.7 + + +def metal(n): + x = np.zeros(n) + for f in METAL: + x += pulse(np.full(n, f), 0.5, rng.random()) + return x / len(METAL) + + +def hat(v, decay=0.035, n_s=0.12): + n = int(n_s * SR) + t = tarr(n) + x = 0.55 * metal(n) + 0.6 * noise(n) + x = svf(x, np.full(n, 8500.0), 0.2, 2) + return x * np.exp(-t / decay) * v * 0.55 + + +def shaker(v): + n = int(0.09 * SR) + t = tarr(n) + x = svf(noise(n), np.full(n, 6500.0), 0.3, 1) + env = np.minimum(t / 0.012, 1) * np.exp(-t / 0.03) + return x * env * v * 0.5 + + +def crash(v): + n = int(2.6 * SR) + t = tarr(n) + x = 0.5 * metal(n) + noise(n) + x = svf(x, np.full(n, 5200.0), 0.1, 2) + xl = x * np.exp(-t / 0.75) + xr = np.roll(x, 37) * np.exp(-t / 0.7) + return (xl * v * 0.3, xr * v * 0.3) + + +def impact(v): + n = int(3.0 * SR) + t = tarr(n) + boom = sine(30 + 45 * np.exp(-t / 0.35), 0.0) * np.exp(-t / 0.9) * np.minimum(t / 0.003, 1) + crack = svf(noise(n), 6000 * np.exp(-t / 0.25) + 120, 0.2, 0) * np.exp(-t / 0.5) + x = np.tanh(1.3 * boom) * 0.9 + crack * 0.35 + return x * v + + +def riser(dur, v): + n = int(dur * SR) + t = tarr(n) + u = t / dur + nz = svf(noise(n), 300 * (9000 / 300) ** (u ** 1.4), 0.55, 1) + tone = saw(110 * 2 ** (u ** 1.3 * 2), 0.0) * 0.15 + tone = svf(tone, 400 + 5000 * u ** 2, 0.3, 0) + env = u ** 2.2 + x = (nz * 0.9 + tone) * env * v * 0.55 + return (x, np.roll(x, 211)) + + +def reverse_swell(v, dur): + """Reversed crash-ish swell that ends exactly on the drop.""" + l, r = crash(1.0) + n = int(dur * SR) + return (l[:n][::-1] * v * 1.4, r[:n][::-1] * v * 1.4) + + +# -------------------------------------------------------------------------------------------------- +# tonal voices +DETUNE = np.array([-0.24, -0.15, -0.06, 0.0, 0.06, 0.15, 0.24]) # semitones +PANS = np.array([-0.9, -0.6, -0.3, 0.0, 0.3, 0.6, 0.9]) + + +def supersaw(midi, dur, v, att=0.006, rel=0.25, spread=1.0): + n = int((dur + rel) * SR) + l = np.zeros(n) + r = np.zeros(n) + for d, p in zip(DETUNE * spread, PANS): + x = saw(np.full(n, float(mtof(midi + d))), rng.random()) + a = (p + 1) * np.pi / 4 + l += x * np.cos(a) + r += x * np.sin(a) + env = adsr(n, att, 0.3, 0.75, int(dur * SR), rel) + return (l * env * v / 3.2, r * env * v / 3.2) + + +def pluck(midi, dur, v, bright=1.0): + n = int((dur + 0.35) * SR) + t = tarr(n) + f = np.full(n, float(mtof(midi))) + x = 0.6 * saw(f, rng.random()) + 0.4 * pulse(f * 1.003, 0.35, rng.random()) + cut = 250 + bright * 4200 * np.exp(-t / 0.09) + x = svf(x, cut, 0.25, 0) + return x * np.exp(-t / 0.22) * np.minimum(t / 0.002, 1) * v + + +def keys(midi, dur, v): + """FM electric piano stab.""" + n = int((dur + 0.4) * SR) + t = tarr(n) + f = float(mtof(midi)) + idx_env = 2.2 * np.exp(-t / 0.15) + 0.4 + mod = sine(np.full(n, f), 0.0) * idx_env + car = np.sin(2 * np.pi * f * t + mod) + tine = sine(np.full(n, f * 14.0), 0.0) * np.exp(-t / 0.03) * 0.15 + env = np.exp(-t / 0.6) * np.minimum(t / 0.002, 1) + rs = int(dur * SR) + if rs < n: + env[rs:] *= np.exp(-(t[rs:] - t[rs]) / 0.06) + return (car + tine) * env * v + + +def bell(midi, dur, v): + n = int((dur + 1.6) * SR) + t = tarr(n) + f = float(mtof(midi)) + mod = sine(np.full(n, f * 3.5), 0.0) * (1.4 * np.exp(-t / 0.4) + 0.1) + car = np.sin(2 * np.pi * f * t + mod) + sub = np.sin(2 * np.pi * f * t) * 0.5 + env = np.exp(-t / 1.1) * np.minimum(t / 0.004, 1) + return (car * 0.6 + sub) * env * v + + +def mono_line(notes, kind, glide=0.035, vib=0.12): + """Monophonic lead over a whole note list [(t, dur, midi, vel)] -> (l, r) full-length arrays (sparse + regions zero). Glides between notes that touch; vibrato fades in on held notes.""" + if not notes: + return np.zeros(N), np.zeros(N) + notes = sorted(notes) + t0 = notes[0][0] - 0.05 + t1 = notes[-1][0] + notes[-1][1] + 0.6 + i0, i1 = idx(t0), min(idx(t1), N) + n = i1 - i0 + tt = (np.arange(n) + i0) / SR + pitch = np.full(n, float(notes[0][2])) + gate = np.zeros(n) + vel = np.zeros(n) + since = np.full(n, 10.0) + for k, (ts, d, m, v) in enumerate(notes): + a, b = idx(ts) - i0, min(idx(ts + d) - i0, n) + nxt = idx(notes[k + 1][0]) - i0 if k + 1 < len(notes) else n + seg = slice(a, min(nxt, n)) + prev = pitch[a - 1] if a > 0 else m + u = (tt[seg] - ts) + pitch[seg] = m + (prev - m) * np.exp(-u / glide) if k > 0 and notes[k - 1][0] + notes[k - 1][1] >= ts - 0.02 else m + gate[a:b] = 1 + vel[a:min(nxt, n)] = v + since[seg] = u + vibr = vib * np.sin(2 * np.pi * 5.6 * tt) * np.clip((since - 0.18) / 0.25, 0, 1) + f = mtof(pitch + vibr) + # amp: attack 4ms, release 70ms (one-pole on gate) + up, dn = np.exp(-1 / (0.004 * SR)), np.exp(-1 / (0.07 * SR)) + from scipy.signal import lfilter + att = lfilter([1 - up], [1, -up], gate) + rel = lfilter([1 - dn], [1, -dn], gate) + env = np.where(gate > 0, att, rel) * vel + if kind == 'lead': + x = 0.45 * saw(f * 2 ** (0.08 / 12), 0.1) + 0.45 * saw(f * 2 ** (-0.08 / 12), 0.6) + 0.35 * pulse(f, 0.3, 0.3) + cut = 900 + 5200 * np.clip(env, 0, 1) * (0.55 + 0.45 * np.exp(-np.minimum(since, 5) / 0.15)) + x = svf(x, cut, 0.3, 0) + sub = sine(f / 2, 0.0) * 0.25 + x = (x + sub) * env + l = x + 0.0 + r = 0.45 * saw(f * 2 ** (0.12 / 12), 0.9) + r = svf(r, cut, 0.3, 0) * env * 0.6 + x * 0.7 + l = l * 1.0 + out = (l, r) + else: # 'chop': vowel-ish formant voice + src = saw(f, 0.2) * 0.7 + pulse(f, 0.5, 0.0) * 0.3 + vowel = 0.5 + 0.5 * np.sin(2 * np.pi * tt * S.BPM / 60 / 2) + f1 = 700 - 300 * vowel + f2 = 1220 - 400 * vowel + x = svf(src, f1, 0.85, 1) + 0.7 * svf(src, f2, 0.85, 1) + 0.25 * svf(src, np.full(n, 2600.0), 0.8, 1) + x = x * env * 1.6 + out = (x, x) + L, R = np.zeros(N), np.zeros(N) + L[i0:i1] = out[0][:n] + R[i0:i1] = out[1][:n] + return L, R + + +# -------------------------------------------------------------------------------------------------- +def build(): + B = {k: Bus(k) for k in ('kick', 'drums', 'bass', 'chords', 'pad', 'arp', 'lead', 'keys', 'fx')} + + # --- drums / fx from the shared event list + for t, k, v in S.EVENTS: + if k == 'kick': + B['kick'].add(t, kick(v)) + elif k == 'clap': + B['drums'].add(t, clap(v), 0.0, 0.7) + elif k == 'snare': + B['drums'].add(t, snare(v), 0.1, 0.6) + elif k == 'roll': + B['drums'].add(t, snare(v, 1.0 + 0.5 * v), 0.0, 0.38) + elif k == 'hat': + B['drums'].add(t, hat(v), 0.25, 0.75) + elif k == 'ohat': + B['drums'].add(t, hat(v, 0.12, 0.35), -0.2, 0.6) + elif k == 'shaker': + B['drums'].add(t, shaker(v), -0.35, 0.8) + elif k == 'crash': + B['fx'].add(t, crash(v), gain=0.9) + elif k == 'impact': + B['fx'].add(t, impact(v), gain=0.8) + elif k == 'heart': + B['kick'].add(t, kick(v) * 0.6) + B['kick'].add(t + 0.16, kick(v) * 0.35) + elif k == 'riser': + B['fx'].add(t, riser(v, 1.0), gain=0.42) + elif k == 'drop': + B['fx'].add(t - S.BAR, reverse_swell(0.5, S.BAR), gain=0.6) + + # --- harmony + for bar in range(S.NBARS): + name, a, b = S.section(bar) + i = bar - a + voicing, root = S.chord(bar) + t = S.tb(bar) + drop = name.startswith('drop') + # pads + if name in ('intro', 'build1', 'verse', 'break', 'build2', 'build3', 'outro') or drop: + pv = {'intro': 1.2, 'build1': 0.45, 'verse': 0.3, 'break': 0.6, 'build2': 0.4, 'build3': 0.4, + 'outro': 0.5}.get(name, 0.3) + for m in voicing: + B['pad'].add(t, supersaw(m - 12 if m > 60 else m, S.BAR, pv * 0.35, att=0.35, rel=0.8, + spread=0.6)) + # big chords + if drop or name in ('build2', 'build3') or (name == 'build1' and i >= 4) or (name == 'outro'): + vv = 1.0 if drop else 0.45 + if name == 'outro' and i >= 4: + if i == 4: + for m in voicing + (voicing[0] + 12, voicing[1] + 12): + B['chords'].add(t, supersaw(m, 4 * S.BAR - 0.2, 0.9, rel=1.5)) + else: + if drop: + # rhythmic stabs: the classic pumping offbeat-driven pattern + for s, l in ((0, 3), (3, 3), (6, 4), (10, 2), (12, 4)): + for m in voicing + (voicing[2] + 12,): + B['chords'].add(S.tb(bar, 0, s), supersaw(m, l * S.S16 - 0.01, vv, rel=0.12)) + else: + for m in voicing + (voicing[2] + 12,): + B['chords'].add(t, supersaw(m, S.BAR - 0.02, vv, rel=0.2)) + # bass + if name not in ('intro', 'break') and not (name == 'outro' and i >= 4): + if drop or name == 'outro': + for s in range(0, 16, 2): + m = root + (12 if s in (6, 14) and drop else 0) + B['bass'].add(S.tb(bar, 0, s + 1), bassnote(m, S.S16 * 1.6, 0.9), 0.0) + B['bass'].add(t, subnote(root, S.BAR - 0.03, 0.9)) + elif name == 'verse': + for s, l, o in ((0, 3, 0), (3, 2, 12), (6, 2, 0), (8, 3, 0), (11, 1, 12), (12, 2, 7), (14, 2, 12)): + B['bass'].add(S.tb(bar, 0, s), bassnote(root + o, l * S.S16, 0.6)) + B['bass'].add(S.tb(bar, 0, s), subnote(root + o if o != 12 else root, l * S.S16, 0.5)) + else: # builds: plain quarter-note roots, saving the 8th-note pump for the drops + bv = 0.7 if name == 'build1' else 0.55 + 0.25 * (i / 8) + for q in range(4): + B['bass'].add(S.tb(bar, q), bassnote(root, S.BEAT * 0.8, bv)) + B['bass'].add(S.tb(bar, q), subnote(root, S.BEAT * 0.8, bv * 0.85)) + elif name == 'outro' and i == 4: + B['bass'].add(t, subnote(root, 3 * S.BAR, 0.9)) + # arp + if name in ('intro', 'build1', 'verse', 'build2', 'build3', 'break') or drop: + if name == 'intro' and i < 2: + continue + tones = [voicing[0] + 12, voicing[1] + 12, voicing[2] + 12, voicing[0] + 24] + order = [0, 1, 2, 3, 2, 1, 2, 3, 0, 1, 2, 3, 2, 3, 1, 2] + step = 2 if name == 'break' else 1 + av = {'intro': 0.5, 'build1': 0.4, 'verse': 0.42, 'break': 0.32}.get(name, 0.3) + for s in range(0, 16, step): + if name in ('intro', 'build1', 'build2', 'build3'): + bright = 0.25 + 0.75 * (i + s / 16) / (b - a) + else: + bright = 0.7 + B['arp'].add(S.tb(bar, 0, s), pluck(tones[order[s]], S.S16, av * (1.0 if s % 4 == 0 else 0.75), + bright), pan=0.35 * np.sin(s * 0.9)) + # keys (verse stabs, break bells) + if name == 'verse': + for s in (2, 6, 10, 14) if i % 2 else (2, 6, 7, 10, 14): + for m in voicing: + B['keys'].add(S.tb(bar, 0, s), keys(m + 12, S.S16 * 1.5, 0.22), pan=-0.2) + if name == 'break': + BREAK = [[(0, 4, 72), (4, 4, 69), (8, 8, 72)], [(0, 6, 77), (6, 2, 76), (8, 8, 72)], + [(0, 4, 74), (4, 4, 71), (8, 8, 74)], [(0, 6, 79), (6, 2, 77), (8, 8, 74)], + [(0, 4, 76), (4, 4, 72), (8, 8, 76)], [(0, 6, 81), (6, 2, 79), (8, 8, 76)], + [(0, 8, 77), (8, 8, 76)], [(0, 8, 74), (8, 4, 71), (12, 4, 74)]] + for ts, d, m in S.notes_in(bar, BREAK[i]): + B['keys'].add(ts, bell(m, d, 0.3), pan=0.1) + + # --- lead lines + lead = [] + chop = [] + lead_hi = [] + for bar in range(S.NBARS): + name, a, b = S.section(bar) + i = bar - a + if name.startswith('drop'): + for ts, d, m in S.notes_in(bar, S.hook_bar(i)): + lead.append((ts, d * 0.92, m, 1.0)) + if name != 'drop1' or i >= 8: + chop.append((ts, d * 0.92, m + 12, (0.6 if name == 'drop1' else 0.7 if name == 'drop2' else 0.85) + * (1.0 if i >= 8 else 0.7))) + if i >= 8: + lead_hi.append((ts, d * 0.92, m + 12, 0.5 + 0.03 * (i - 8))) + elif name == 'outro' and i < 4: + for ts, d, m in S.notes_in(bar, S.hook_bar(i if i < 3 else 7)): + lead.append((ts, d * 0.92, m, 1.0 - 0.12 * i)) + elif name == 'build3' and i >= 4: + for ts, d, m in S.notes_in(bar, S.hook_bar(i - 4)): + lead.append((ts, d * 0.9, m, 0.45 + 0.1 * (i - 4))) + lead.append((S.tb(108), S.BAR * 3, 76 + 2, 0.9)) + ll, lr = mono_line(lead, 'lead') + B['lead'].add(0, (ll, lr), gain=0.42) + hl, hr = mono_line(lead_hi, 'lead', vib=0.08) + B['lead'].add(0, (hr, hl), gain=0.16) + cl, cr = mono_line(chop, 'chop', glide=0.02, vib=0.05) + B['lead'].add(0, (cl, cr), gain=0.2) + return B + + +def bassnote(m, dur, v): + n = int((dur + 0.05) * SR) + t = tarr(n) + f = np.full(n, float(mtof(m))) + x = saw(f, 0.0) * 0.7 + saw(f * 1.004, 0.5) * 0.5 + x = svf(x, 180 + 900 * np.exp(-t / 0.07), 0.35, 0) + env = np.minimum(t / 0.003, 1) * np.where(t < dur, 1.0, np.exp(-(t - dur) / 0.012)) + return np.tanh(x * 1.5) * env * v * 0.55 + + +def subnote(m, dur, v): + n = int((dur + 0.05) * SR) + t = tarr(n) + x = sine(np.full(n, float(mtof(m))), 0.0) + env = np.minimum(t / 0.004, 1) * np.where(t < dur, 1.0, np.exp(-(t - dur) / 0.015)) + return x * env * v * 0.6 + + +# -------------------------------------------------------------------------------------------------- +def reverb_ir(seconds, damp, seed): + r = np.random.default_rng(seed) + n = int(seconds * SR) + t = tarr(n) + out = [] + for ch in range(2): + x = r.standard_normal(n) * np.exp(-t / (seconds / 6.9)) + x = svf(x, damp * np.exp(-t / seconds * 1.5) + 400, 0.0, 0) + x[: int(0.012 * SR)] *= np.linspace(0, 1, int(0.012 * SR)) + out.append(x / np.sqrt(np.sum(x ** 2))) + return out + + +def conv(l, r, ir): + return fftconvolve(l, ir[0])[:N], fftconvolve(r, ir[1])[:N] + + +def mix(B): + sc_hard = sidechain(0.85) + sc_mid = sidechain(0.55) + sc_soft = sidechain(0.3) + # section automation of the chord / pad / arp low-pass + T = S.tb + chord_cut = curve([(0, 300), (T(12), 500), (T(16) - 0.01, 9000), (T(16), 11000), (T(32), 11000), + (T(48), 600), (T(55, 3), 8000), (T(56), 12000), (T(72), 12000), (T(80), 500), + (T(87, 3), 8000), (T(88), 13000), (T(104), 13000), (T(108), 6000), (T(112) + 3, 300)]) + pad_cut = curve([(0, 450), (T(8), 2400), (T(16), 2600), (T(72), 2600), (T(73), 1600), (T(80), 2600), + (T(108), 3000), (T(112) + 3, 300)]) + arp_cut = curve([(0, 900), (T(8), 3000), (T(16), 7000), (T(112), 7000)]) + for k in ('chords',): + B[k].l = svf(B[k].l, chord_cut, 0.15, 0) + B[k].r = svf(B[k].r, chord_cut, 0.15, 0) + B['pad'].l = svf(B['pad'].l, pad_cut, 0.1, 0) + B['pad'].r = svf(B['pad'].r, pad_cut, 0.1, 0) + B['arp'].l = svf(B['arp'].l, arp_cut, 0.1, 0) + B['arp'].r = svf(B['arp'].r, arp_cut, 0.1, 0) + + B['chords'].apply(sc_hard) + B['pad'].apply(sc_hard) + B['bass'].apply(sc_hard) + B['arp'].apply(sc_mid) + B['keys'].apply(sc_soft) + B['lead'].apply(sc_soft) + g = gaps() + for k in ('chords', 'pad', 'bass', 'arp', 'keys', 'lead'): + B[k].apply(g) + + gains = {'kick': 0.95, 'drums': 1.0, 'bass': 0.68, 'chords': 0.3, 'pad': 0.4, 'arp': 0.28, 'lead': 1.4, + 'keys': 0.42, 'fx': 0.45} + L = np.zeros(N) + R = np.zeros(N) + for k, b in B.items(): + L += b.l * gains[k] + R += b.r * gains[k] + + # sends + hall = reverb_ir(2.8, 5000, 7) + room = reverb_ir(0.7, 7000, 9) + send_hall_l = B['lead'].l * 0.35 + B['keys'].l * 0.35 + B['pad'].l * 0.3 + B['arp'].l * 0.25 + B['fx'].l * 0.3 + send_hall_r = B['lead'].r * 0.35 + B['keys'].r * 0.35 + B['pad'].r * 0.3 + B['arp'].r * 0.25 + B['fx'].r * 0.3 + send_room_l = B['drums'].l * 0.4 + send_room_r = B['drums'].r * 0.4 + hl, hr = conv(send_hall_l, send_hall_r, hall) + rl, rr = conv(send_room_l, send_room_r, room) + dl, dr = pingpong(B['lead'].l * 0.5 + B['arp'].l * 0.3 + B['keys'].l * 0.3, + B['lead'].r * 0.5 + B['arp'].r * 0.3 + B['keys'].r * 0.3, S.S16 * 3, 0.38, 3500.0) + L += hl * 0.4 + rl * 0.3 + dl * 0.2 + R += hr * 0.4 + rr * 0.3 + dr * 0.2 + + # stutters (glitch repeats of the first 16th of the beat) + for t0, _ in S.events('stutter'): + a = idx(t0) + pattern = [2, 2, 1, 1, 0.5, 0.5, 0.5, 0.5, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25] # in 16ths + src_l, src_r = L[a:a + idx(S.S16 * 2)].copy(), R[a:a + idx(S.S16 * 2)].copy() + p = a + for k, s in enumerate(pattern): + n = idx(S.S16 * s) + f = np.ones(n) + fd = min(64, n // 4) + f[:fd] = np.linspace(0, 1, fd) + f[-fd:] = np.linspace(1, 0, fd) + gain = 1.0 - 0.02 * k + L[p:p + n] = src_l[:n] * f * gain + R[p:p + n] = src_r[:n] * f * gain + p += n + + # master + L = onepole_hp(L, 22.0) + R = onepole_hp(R, 22.0) + L, R = compress(L, R, -9.0, 2.0, 0.01, 0.15, 1.5) + L, R = np.tanh(L * 0.9) / 0.9, np.tanh(R * 0.9) / 0.9 + return L, R + + +def master(L, R, target_lufs=-10.5): + import pyloudnorm as pyln + meter = pyln.Meter(SR) + loud = meter.integrated_loudness(np.stack([L, R], 1)) + g = 10 ** ((target_lufs - loud) / 20) + L, R = L * g, R * g + L, R = limit(L, R, 0.89, 0.005, 0.08) + fade = int(1.5 * SR) + L[-fade:] *= np.linspace(1, 0, fade) ** 2 + R[-fade:] *= np.linspace(1, 0, fade) ** 2 + return L, R, loud + + +if __name__ == '__main__': + import soundfile as sf + B = build() + L, R = mix(B) + L, R, pre = master(L, R) + import pyloudnorm as pyln + after = pyln.Meter(SR).integrated_loudness(np.stack([L, R], 1)) + print(f'pre {pre:.1f} LUFS -> {after:.1f} LUFS, peak {max(abs(L).max(), abs(R).max()):.3f}, ' + f'dc {L.mean():.4f}') + sf.write(sys.argv[1], np.stack([L, R], 1).astype(np.float32), SR, subtype='PCM_24') diff --git a/intro-video/hype/render.py b/intro-video/hype/render.py new file mode 100644 index 00000000..9c3a310d --- /dev/null +++ b/intro-video/hype/render.py @@ -0,0 +1,135 @@ +"""Render driver. + python render.py still T [T ...] [--scale S] [--samples N] [--only name] -> out/still_T.png + python render.py sheet T0 T1 N [--scale S] -> out/sheet_T0_T1.jpg + python render.py video T0 T1 [--scale S] [--samples N] [--out file] -> mp4 (with music) + python render.py final [--samples N] -> out/mobilegl_hype.mp4 +MGL_LIBGL= renders through that GL implementation.""" +import os +import sys +import time +import subprocess +import numpy as np +from PIL import Image + +import score as S +from engine import Engine +from anim import Frame, frame_idx +import importlib +scenes = importlib.import_module(sys.argv[sys.argv.index('--module') + 1] if '--module' in sys.argv else 'scenes') + +HERE = os.path.dirname(os.path.abspath(__file__)) +OUT = os.path.join(HERE, 'out') +FPS = int(sys.argv[sys.argv.index('--fps') + 1]) if '--fps' in sys.argv else 60 + + +def opt(name, default, cast=float): + if name in sys.argv: + return cast(sys.argv[sys.argv.index(name) + 1]) + return default + + +def draw_frame(E, t, fi): + n = E.samples + P = None + for s in range(n): + ts = t + ((s + 0.5) / n - 0.5) * E.shutter / FPS if n > 1 else t + p = scenes.draw(E, ts) + if s == n // 2 or P is None: + P = p + E.accumulate(1.0 / n, s == 0) + E.post(P or {}, fi) + + +def grab(E): + a = np.frombuffer(E.read(), np.uint8).reshape(E.H, E.W, 4) + return a + + +def make_engine(): + E = Engine(scale=opt('--scale', 1.0), samples=opt('--samples', 1, int), shutter=opt('--shutter', 0.5)) + if 'MobileGL' in E.renderer: + import sc_final + gpu = E.renderer.rsplit('(', 1)[1].split(',')[0] if '(' in E.renderer else 'GPU' + backend = 'Magma → Vulkan' if 'Vulkan' in E.renderer else 'Espryt → OpenGL ES' + sc_final.RENDERED_WITH['line'] = f'{backend} · {gpu} · Windows / WGL' + return E + + +def still(times): + E = make_engine() + print('renderer:', E.renderer) + paths = [] + for t in times: + draw_frame(E, t, frame_idx(t)) + p = os.path.join(OUT, f'still_{t:07.2f}.png') + Image.fromarray(grab(E)[:, :, :3]).save(p) + paths.append(p) + print('\n'.join(paths)) + + +def sheet(t0, t1, n, cols=4): + E = make_engine() + imgs = [] + for k in range(n): + t = t0 + (t1 - t0) * k / max(n - 1, 1) + draw_frame(E, t, frame_idx(t)) + im = Image.fromarray(grab(E)[:, :, :3]).resize((480, 270), Image.LANCZOS) + imgs.append((t, im)) + rows = (n + cols - 1) // cols + sh = Image.new('RGB', (cols * 484, rows * 290), (30, 30, 30)) + from PIL import ImageDraw + d = ImageDraw.Draw(sh) + for k, (t, im) in enumerate(imgs): + x, y = (k % cols) * 484 + 2, (k // cols) * 290 + 2 + sh.paste(im, (x, y)) + d.text((x + 4, y + 272), f'{t:.2f}s bar {t / S.BAR:.2f}', fill=(220, 220, 220)) + p = os.path.join(OUT, f'sheet_{t0:.1f}_{t1:.1f}.jpg') + sh.save(p, quality=88) + print(p) + + +def video(t0, t1, out, audio=True, crf=16, preset='slow'): + E = make_engine() + print('renderer:', E.renderer) + f0, f1 = int(round(t0 * FPS)), int(round(t1 * FPS)) + args = ['ffmpeg', '-y', '-loglevel', 'error', '-f', 'rawvideo', '-pix_fmt', 'rgba', '-s', f'{E.W}x{E.H}', + '-r', str(FPS), '-i', '-'] + wav = opt('--audio', os.path.join(OUT, 'music.wav'), str) + if audio and os.path.exists(wav): + args += ['-ss', f'{t0:.4f}', '-t', f'{t1 - t0:.4f}', '-i', wav, '-c:a', 'aac', '-b:a', '256k'] + args += ['-c:v', 'libx264', '-preset', preset, '-crf', str(crf), '-pix_fmt', 'yuv420p', + '-movflags', '+faststart', '-shortest' if audio else '-an', out] + args = [a for a in args if a] + proc = subprocess.Popen(args, stdin=subprocess.PIPE) + start = time.time() + for fi in range(f0, f1): + t = fi / FPS + draw_frame(E, t, fi) + proc.stdin.write(E.read()) + if (fi - f0) % 300 == 0: + el = time.time() - start + done = fi - f0 + 1 + print(f'frame {fi} ({t:.1f}s) {done / max(el, 1e-6):.1f} fps', flush=True) + proc.stdin.close() + proc.wait() + print(out, f'{time.time() - start:.0f}s') + + +if __name__ == '__main__': + os.makedirs(OUT, exist_ok=True) + mode = sys.argv[1] + nums = [] + for a in sys.argv[2:]: + if a.startswith('--'): + break + nums.append(a) + if mode == 'still': + still([float(x) for x in nums]) + elif mode == 'sheet': + sheet(float(nums[0]), float(nums[1]), int(nums[2])) + elif mode == 'video': + video(float(nums[0]), float(nums[1]), opt('--out', os.path.join(OUT, 'clip.mp4'), str), + crf=opt('--crf', 18, int), preset=opt('--preset', 'veryfast', str)) + elif mode == 'final': + video(0.0, S.END, opt('--out', os.path.join(OUT, 'mobilegl_hype.mp4'), str), crf=opt('--crf', 16, int), + preset=opt('--preset', 'slow', str)) diff --git a/intro-video/hype/requirements.txt b/intro-video/hype/requirements.txt new file mode 100644 index 00000000..77846202 --- /dev/null +++ b/intro-video/hype/requirements.txt @@ -0,0 +1,10 @@ +numpy +scipy +numba +pillow +moderngl +glcontext +uharfbuzz +freetype-py +soundfile +pyloudnorm diff --git a/intro-video/hype/sc_break.py b/intro-video/hype/sc_break.py new file mode 100644 index 00000000..46248e7e --- /dev/null +++ b/intro-video/hype/sc_break.py @@ -0,0 +1,135 @@ +"""Bars 72-80: the breakdown. Device lost (not a crash), memory stays yours, zero-copy.""" +import math +from anim import * +from kit import * +from devices import * +from engine import LW, LH, hexc + + +def soft_words(E, f, words, b0, step, x, y, size, col, align='center', font='archivo-i', wght=300): + """Words fade/slide in one per `step` beats, laid out on one line.""" + total = sum(E.measure(w + ' ', font, size, wght) for w in words) + cx = x - total / 2 if align == 'center' else x + for i, w in enumerate(words): + bi = b0 + i * step + u = out_cubic((f.lbeat - bi) / 0.8) + adv = E.measure(w + ' ', font, size, wght) + if u > 0: + E.text(w, cx, y + 24 * (1 - u), size, a(col, u), font=font, wght=wght, align='left') + cx += adv + + +def breakdown(E, f): + lb = f.lbeat + heart = S.pulse(f.t, 'heart', 0.15) + P = {'bloom': 0.65, 'grain': 0.032, 'vignette': 0.5} + plate(E, f, 'ink', drift=(4, 2)) + screen_cam(E, f, zoom=1.0 + 0.02 * math.sin(f.lt * 0.4) + 0.015 * heart) + if lb < 16: + # server + clients orbiting slowly; client 2 fails + apps = ['browser', 'terminal', 'game', 'compositor', 'video', 'editor'] + balls = [(960, 600, 120)] + caps = [] + bad = None + for k in range(6): + ang = k * 2 * math.pi / 6 + f.t * 0.25 + x, y = 960 + math.cos(ang) * 470, 600 + math.sin(ang) * 260 + if k == 2 and lb >= 8: + bad = (x, y) + fall = clamp01((lb - 10) / 4) + x += 60 * fall + y += 500 * in_cubic(fall) + continue + balls.append((x, y, 56)) + caps.append((960, 600, x, y, 10, 10)) + goo(E, balls, caps, k=36, glow=0.15) + if bad is not None: + fall = clamp01((lb - 10) / 4) + jit = 6 * math.sin(f.t * 60) * (lb < 10) + bx, by = bad[0] + 60 * fall + jit, bad[1] + 500 * in_cubic(fall) + if lb < 10: # strand still attached, straining + goo(E, [(bx, by, 56)], [(960, 600, bx, by, 6, 6)], k=20, col=CORAL, rim=hexc('#9c2f22'), glow=0.2) + else: + goo(E, [(bx, by, 56)], k=20, col=a(CORAL, 1 - 0.6 * fall), rim=hexc('#9c2f22')) + if lb >= 11: + stamp(E, f, 'DEVICE LOST', bx + 40, by - 110, f.t - (lb - 11) * S.BEAT, 46, CORAL, rot=0.1) + for k in range(6): + if k == 2 and lb >= 8: + continue + ang = k * 2 * math.pi / 6 + f.t * 0.25 + chip(E, 960 + math.cos(ang) * 470, 600 + math.sin(ang) * 260, apps[k], 20, INK, BONE) + chip(E, 960, 600, 'SERVER', 26, INK, PALE) + if lb < 8: + soft_words(E, f, ['and', 'when', 'things', 'go', 'wrong?'], 0.5, 1.0, 960, 170, 84, BONE) + else: + if lb >= 12: + soft_words(E, f, ['a', 'clean', 'device-lost.', 'not', 'a', 'crash.'], 12, 0.5, 960, 150, 72, BONE) + if lb >= 14: + E.text('everyone else keeps rendering', 960, 1010, 30, a(GLUE, clamp01((lb - 14) / 0.6)), font='mono', + wght=600) + elif lb < 24: + soft_words(E, f, ['your', 'memory', 'stays', 'yours.'], 16, 0.75, 960, 160, 90, BONE) + # client memory grid + E.rect(480, 620, 560, 520, INK2, r=26, stroke=a(GLUE, 0.6), sw=3) + E.text('CLIENT MEMORY', 480, 400, 26, GLUE, font='mono', wght=800) + for r in range(7): + for c in range(8): + on = rnd('mem', r, c, int(f.t * 4)) > 0.6 + E.rect(270 + c * 60, 470 + r * 56, 46, 42, a(GLUE, 0.85 if on else 0.18), r=6) + E.rect(1440, 620, 460, 360, INK2, r=26, stroke=a(MINT, 0.6), sw=3) + E.text('SERVER', 1440, 560, 54, MINT, wght=900) + E.text('backend', 1440, 620, 24, a(MINT, 0.7), font='mono') + # only records travel; reaching in is walled off + for k in range(5): + ph = ((lb - 16) / 2 + k / 5) % 1 + E.rect(lerp(800, 1180, ph), 560, 54, 26, GLUE, r=8) + if lb >= 18: + u = clamp01((lb - 18) / 0.6) + arrow(E, 1200, 720, lerp(1200, 860, u), 720, 8, a(CORAL, 0.9), u=1.0) + E.line(830, 640, 830, 800, 12, a(BONE, u)) + E.text('the backend never reads frontend memory', 960, 1000, 30, a(BONE, 0.8 * u), font='mono', wght=600) + else: + soft_words(E, f, ['zero-copy', 'where', 'it', 'counts.'], 24, 0.75, 960, 150, 90, BONE) + # one window buffer in GPU memory, shared by the app (writes) and the compositor (reads) + k = max(0, int(lb - 25) + 1) if lb >= 25 else 0 # frames rendered so far + bx, by, bw, bh = 960, 560, 420, 280 + + def frame_art(cx, cy, w, h, n, alpha=1.0): + E.rect(cx, cy, w, h, a(PALE, alpha), r=12) + E.rect(cx, cy - h / 2 + 18 * h / 280, w, 36 * h / 280, a(MINT, alpha), r=10) + ang = n * 0.9 + E.circle(cx + math.cos(ang) * w * 0.25, cy + 20 * h / 280 + math.sin(ang) * h * 0.18, 34 * h / 280, + a(GLUE, alpha)) + E.text(f'frame {n}', cx + w * 0.3, cy + h * 0.36, 20 * h / 280, a(INK, 0.7 * alpha), font='mono', wght=700) + # the shared buffer + E.rect(bx, by, bw + 40, bh + 90, INK2, r=20, stroke=a(GLUE, 0.8), sw=4) + E.text('one window buffer · GPU memory', bx, by - bh / 2 - 22, 22, GLUE, font='mono', wght=700) + frame_art(bx, by + 10, bw, bh, max(k, 1) if lb >= 25 else 0) + # app (left) and compositor (right) + E.rect(300, 560, 320, 200, INK2, r=20, stroke=a(BONE, 0.5), sw=3) + E.text('APP', 300, 520, 50, BONE, wght=900) + E.text('draws into it', 300, 585, 22, a(BONE, 0.7), font='mono') + E.rect(1620, 560, 320, 260, INK2, r=20, stroke=a(BONE, 0.5), sw=3) + E.text('COMPOSITOR', 1620, 470, 38, BONE, wght=900) + if lb >= 25.5: + frame_art(1620, 590, 240, 160, max(k, 1), 0.95) # what it shows IS the shared buffer + # both point at the same memory + E.line(460, 560, bx - bw / 2 - 30, 560, 5, a(GLUE, 0.7)) + E.line(bx + bw / 2 + 30, 560, 1460, 560, 5, a(GLUE, 0.7)) + # each beat: only a small handle travels across, the pixels stay where they are + if lb >= 25: + u = clamp01(((lb - 25) % 1) / 0.6) + hx = lerp(460, 1460, in_out_cubic(u)) + hy = 470 - 90 * bump(u) + if u < 1: + chip(E, hx, hy, 'handle', 22, INK, GLUE) + pulse = math.exp(-((lb - 25) % 1) * 5) + E.rect(bx, by, bw + 40 + 30 * pulse, bh + 90 + 30 * pulse, (0, 0, 0, 0), r=24, stroke=a(NEON, 0.6 * pulse), + sw=4) + # counters + E.text(f'frames shown: {k}', 760, 900, 34, BONE, font='mono', wght=800) + E.text('copies made: 0', 1180, 900, 34, NEON, font='mono', wght=800) + if lb >= 27: + E.text('the app and the compositor share one buffer: handed over, never copied', 960, 1000, 28, + a(GLUE, clamp01((lb - 27) / 0.6)), font='mono', wght=600) + return P diff --git a/intro-video/hype/sc_build3.py b/intro-video/hype/sc_build3.py new file mode 100644 index 00000000..9a7df4d0 --- /dev/null +++ b/intro-video/hype/sc_build3.py @@ -0,0 +1,58 @@ +"""Bars 80-88: rapid-fire checklist over the last build.""" +import math +from anim import * +from kit import * +from engine import LW, LH, hexc + +ITEMS = ['OpenGL 4.6 Core', 'GLSL → SPIR-V', 'Vulkan backend', 'OpenGL ES backend', + 'Android', 'Linux', 'Windows', 'macOS', + 'EGL · GLX', 'WGL', 'CGL · NSOpenGL', 'glvnd vendor', + 'same process', 'another process', 'another machine', 'open source'] + + +def checklist(E, f): + lb = f.lbeat + P = {'bloom': 0.55, 'grain': 0.035, 'ca': 0.3 * f.roll} + if lb >= 31: # the silent beat: the block falls into the glue, the last drop lands on the downbeat + pre_drop(E, f, 960, 247, 181.5, f.t - (lb - 32) * S.BEAT) + return P + plate(E, f, 'ink') + c = climax(f, 24, 31) + screen_cam(E, f, zoom=1 + 0.35 * c + 0.01 * f.roll, rot=0.04 * c, shake=2 + 14 * c) + n = min(16, max(0, int(lb) + 1)) + sweep = int(lb * 2) % 16 if lb >= 16 else -1 + if lb >= 24: + speedlines(E, f, 0.3 + 0.7 * c, a(GLUE, 0.7), n=100, seed=31) + for k in range(16): + r, cc = k // 4, k % 4 + x = 270 + cc * 460 + y = 260 + r * 190 + if k >= n: + E.rect(x, y, 420, 150, a(INK2, 0.6), r=20) + continue + # from beat 24 the cards fly out at the camera, one per 8th, accelerating + fly = 0.0 + tf = 24 + 0.44 * k * (1 - 0.35 * k / 16) + if lb >= tf: + fly = in_cubic(clamp01((lb - tf) / 0.6)) + if fly >= 1: + continue + sc_ = 1 + 3.5 * fly + x = 960 + (x - 960) * (1 + 2.8 * fly) + y = 540 + (y - 540) * (1 + 2.8 * fly) + u = out_back((lb - k) / 0.35) + hot = k == sweep or (lb < 16 and int(lb) == k) or fly > 0 + col = scale_rgb(GLUE, 1.5) if hot else INK2 + E.alpha = 1 - fly + E.rect(x, y, 420 * u * sc_, 150 * u * sc_, col, r=20 * sc_, stroke=a(GLUE, 0.6), sw=3 * sc_) + E.text(ITEMS[k], x - 180 * u * sc_, y, 30 * u * sc_, INK if hot else BONE, wght=800, align='left', wdth=90) + check(E, x + 165 * u * sc_, y, 30 * u * sc_, INK if hot else GLUE, 9 * sc_, (lb - k - 0.1) / 0.3) + E.flush() + E.alpha = 1.0 + E.cam, E.shake = (LW / 2, LH / 2, 1.0, 0.0), (0.0, 0.0) + if lb < 24: + E.rect(960, 1000, 380, 80, INK, r=40, stroke=a(GLUE, 0.6), sw=3) + E.text(f'{n:2d} / 16', 960, 1000, 44, NEON, font='mono', wght=800) + P['ca'] = 0.3 * f.roll + 1.5 * c + P['bloom'] = 0.55 + 0.4 * c + return P diff --git a/intro-video/hype/sc_drop1.py b/intro-video/hype/sc_drop1.py new file mode 100644 index 00000000..cc1a36bf --- /dev/null +++ b/intro-video/hype/sc_drop1.py @@ -0,0 +1,215 @@ +"""Bars 16-32: the first drop. Logo hit, the hook, four platforms, the hook again.""" +import math +from anim import * +from kit import * +from devices import * +from engine import LW, LH, hexc + + +def logo(E, f, lb0, cx=960, cy=560, splat=True, scale=1.0, cube_y=None): + """The MobileGlues mark (block dropping into glue, hitting at local beat lb0) + MobileGL wordmark.""" + u = f.lbeat - lb0 + t_hit = f.t - u * S.BEAT + s = scale + cy_cube = cube_y if cube_y is not None else cy - 230 * s + if splat and u >= 0: # shockwave rings off the impact + ts = u * S.BEAT + for k in range(3): + w = out_expo(clamp01((ts - 0.06 * k) / 0.9)) + if 0 < w < 1: + E.ring(cx, cy_cube + 60 * s, (120 + 1100 * w) * s, 14 * (1 - w) + 1, a(GLUE, 0.7 * (1 - w))) + cubes(E, f, [(cx, cy_cube, 165 * s, 0.0, 0.12 * f.kick, 0.4 * f.kick, 0.0, 1.0, u * S.BEAT)]) + k = 1 + 0.03 * f.kick + w1 = E.measure('Mobile', 'archivo', 220 * s, 900, 100) + w2 = E.measure('GL', 'archivo', 220 * s, 900, 100) + x0 = cx - (w1 + w2) / 2 + E.text('Mobile', x0, cy + 150 * s, 220 * s * k, BONE, wght=900, align='left') + E.text('GL', x0 + w1, cy + 150 * s, 220 * s * k, scale_rgb(GLUE, 1.6), wght=900, align='left') + return t_hit + + +def hook_word(E, f, word, kind, t_hit, color=None, wdth=100): + b, fg = plate(E, f, kind, grid=False) + size = fit_size(E, word, 1720, 900, wdth, cap=560) + slam(E, f, word, 960, 560, t_hit, size, color or fg, wdth=wdth, punch=0.12) + + +PLATFORMS = [ + ('ANDROID', 'ink', ['Minecraft: Java Edition', 'via Fold Craft Launcher', 'Vulkan or OpenGL ES underneath']), + ('LINUX', 'mint', ['EGL · GLX · Wayland', 'a glvnd vendor', 'a whole KDE Plasma desktop']), + ('WINDOWS', 'ink', ['WGL', 'drop-in opengl32.dll', 'Vulkan, or GLES via ANGLE']), + ('MACOS', 'glue', ['CGL · NSOpenGL', 'Vulkan via MoltenVK', 'OpenGL 4.6 on a Mac']), +] + + + +def draw_device(E, f, name, kind, u, cx, cy, sc, bounce=0.0): + """The platform's hero device anchored at (cx, cy) with iso scale sc; u = beats since its cut.""" + faces = FACE_LIGHT if kind != 'ink' else FACE_DARK + line = OUTL if kind == 'ink' else hexc('#2f7d2a') + k = sc / 82 + if name == 'ANDROID': + scr = screen_layer(E, 'scr_phone', 265, 550, paint_voxels, f) + iso = Iso(cx, cy - bounce, sc) + phone(E, iso, f, faces=faces, screen=scr, line=line) + drip = 120 * out_cubic((u - 1.5) / 2) + if drip > 2: + px, py = iso.p(0, 0.2, 6.2) + goo(E, [(px + 60 * k, py + 10, 26 * k), (px + 60 * k, py + 10 + drip * k, 18 * k)], + [(px + 60 * k, py + 10, px + 60 * k, py + 10 + drip * k, 16 * k, 12 * k)], k=20, outline=4, bevel=16) + elif name == 'LINUX': + scr = screen_layer(E, 'scr_desk', 760, 440, paint_desktop, f) + monitor(E, Iso(cx, cy - bounce, sc), f, faces=faces, screen=scr, line=line) + elif name == 'WINDOWS': + iso = Iso(cx, cy, sc) + iso.box(E, -3.5, -2.5, 0, 7, 5, 2.4, faces[0], faces[1], faces[2], line, 5) # folder body + iso.box(E, -3.5, -2.5, 2.4, 3.0, 5, 0.4, faces[0], faces[1], faces[2], line, 5) # tab + x, y0 = iso.p(0, 0, 2.4) + E.text('C:\\Games\\YourApp\\', x, y0 + 210 * k, 30 * k, a(BONE if kind == 'ink' else INK, 0.85), + font='mono', wght=600) + fall = clamp01((u - 0.4) / 0.6) + land = u >= 1.0 + if not land: + y = lerp(-200, y0 - 120 * k, in_cubic(fall)) + else: + y = y0 - 120 * k + 26 * k * math.exp(-(u - 1.0) * 6) * math.sin((u - 1.0) * 20) + if u >= 0.4: + file_card(E, x, y - bounce * 0.5, 230, 280, 'opengl32', '.dll', rot=-0.08 if not land else 0.0, s=k) + if land: + ring = out_expo((u - 1.0) / 0.8) + E.ring(x, y0 - 20, 60 + 300 * ring * k, 6 * (1 - ring) + 0.5, a(GLUE, 1 - ring)) + else: # MACOS + scr = screen_layer(E, 'scr_mac', 600, 380, paint_mac, f) + laptop(E, Iso(cx, cy - bounce, sc), f, y=2.0, faces=FACE_DARK, screen=scr, line=hexc('#0d2213'), + open_=out_back(clamp01(u / 0.5))) + + +DEV_POS = {'ANDROID': (1380, 1010, 92), 'LINUX': (1330, 1000, 78), 'WINDOWS': (1360, 880, 82), 'MACOS': (1330, 930, 86)} +DEV_CLOSE = {'ANDROID': (1450, 1180, 120), 'LINUX': (1400, 1200, 104), 'WINDOWS': (1450, 1060, 110), + 'MACOS': (1400, 1120, 116)} + + +def platform(E, f, i, lb0): + """4-beat platform card: the name slams on the cut, its three facts pop on the next 8ths.""" + name, kind, chips_ = PLATFORMS[i] + u = f.lbeat - lb0 + t0 = f.t - u * S.BEAT + b, fg = plate(E, f, kind, drift=(-20, 0)) + screen_cam(E, f, zoom=1.0 + 0.02 * u + 0.012 * f.kick) + E.text(f'0{i + 1} / 04', 130, 170, 26, a(fg, 0.7), font='mono', wght=700, align='left') + slam(E, f, name, 120, 290, t0, 190, fg, align='left', wdth=110 if i != 2 else 95) + for k, c in enumerate(chips_): + bk = 0.5 + 0.5 * k + if u >= bk: + uu = out_back((u - bk) / 0.3) + chip(E, 130, 430 + 82 * k, c, 30, b if kind != 'ink' else INK, fg if kind != 'ink' else GLUE, + align='left', sc=uu, font='mono', wght=700) + draw_device(E, f, name, kind, u, *DEV_POS[name], bounce=18 * f.kick) + return {} + + +def platform_close(E, f, i, lb0): + """2-beat recap: a huge name, the device pushed in close.""" + name, kind, _ = PLATFORMS[i] + u = f.lbeat - lb0 + b, fg = plate(E, f, kind, grid=True, drift=(-80, 0)) + screen_cam(E, f, zoom=1.0 + 0.12 * out_cubic(u / 2), rot=(0.02 if i % 2 else -0.02) * u, + shake=10 * math.exp(-u * 5)) + draw_device(E, f, name, kind, 2.0 + u, *DEV_CLOSE[name], bounce=24 * f.kick) + slam(E, f, name, 960, 300, f.t - u * S.BEAT, fit_size(E, name, 1650, cap=330), fg, punch=0.15) + + +API_WORDS = [('EGL', 'linux · android'), ('GLX', 'x11 apps'), ('WAYLAND', 'linux desktops'), + ('GLVND', 'a system-wide vendor'), ('WGL', 'windows'), ('CGL', 'macos'), ('NSOPENGL', 'macos'), + ('FCL', 'minecraft on android')] +TILES = ['ANDROID', 'LINUX', 'WINDOWS', 'MACOS', 'EGL', 'GLX', 'WAYLAND', 'GLVND', 'WGL', 'CGL', 'NSOPENGL', 'VULKAN', + 'OPENGL ES', '4.6 CORE', 'SPIR-V', 'LGPL-3.0'] + + +def drop1(E, f): + lb = f.lbeat + P = {'bloom': 0.55, 'grain': 0.032, 'ca': 0.5 * f.kick} + tb_ = lambda b: f.t - (lb - b) * S.BEAT + if lb < 4: # LOGO HIT (4 beats) + plate(E, f, 'ink', grid=False) + rot = f.lt * 0.25 + for k in range(18): # rays + ang = rot + k * math.pi / 9 + E.poly([(960, 400), (960 + 2200 * math.cos(ang - 0.06), 400 + 2200 * math.sin(ang - 0.06)), + (960 + 2200 * math.cos(ang + 0.06), 400 + 2200 * math.sin(ang + 0.06))], a(MOSS, 0.35)) + dot_grid(E, f, a(MOSS, 0.5), 60, 2.2, pulse=0.6) + screen_cam(E, f, zoom=1.0 + 0.012 * f.kick + 0.03 * lb / 4, shake=26 * math.exp(-lb * 2.2)) + logo(E, f, 0, 960, 560) + E.text('desktop OpenGL 4.6 Core · on Vulkan & OpenGL ES · anywhere', 960, 880, 30, a(BONE, 0.75), + font='mono', wght=500) + P['flash'] = (*GLUE[:3], 0.9 * math.exp(-lb * S.BEAT / 0.12)) + P['bloom'] = 0.7 + elif lb < 8: # hook, one word per beat + words = [('DESKTOP', 'glue', None), ('OPENGL', 'ink', None), ('ON', 'mint', None), ('ANYTHING', 'ink', NEON)] + k = int(lb - 4) + wd, kind, col = words[k] + hook_word(E, f, wd, kind, tb_(4 + k), col, wdth=125 if k == 2 else 100) + screen_cam(E, f, zoom=1 + 0.03 * f.kick + 0.02 * (lb % 1)) + elif lb < 24: # platforms, 4 beats each + i = int((lb - 8) // 4) + platform(E, f, i, 8 + 4 * i) + E.cam, E.shake = (LW / 2, LH / 2, 1.0, 0.0), (0.0, 0.0) + hud(E, f, 'PLATFORMS', PLATFORMS[i][0], col=a(BONE, 0.5) if i in (0, 2) else a(INK, 0.5)) + elif lb < 32: # recap, 2 beats each, pushed in + i = int((lb - 24) // 2) + platform_close(E, f, i, 24 + 2 * i) + elif lb < 40: # window-system APIs, one per beat + kind, k = beat_plate(f, ['ink', 'glue', 'ink', 'mint'], 32) + b, fg = plate(E, f, kind, grid=False) + word, cap = API_WORDS[k % 8] + screen_cam(E, f, zoom=1 + 0.04 * (lb % 1) + 0.02 * f.kick, rot=(0.015 if k % 2 else -0.015)) + speedlines(E, f, 0.25, a(fg, 0.5), n=40, seed=k) + slam(E, f, word, 960, 500, tb_(32 + k), fit_size(E, word, 1500, cap=420), fg if kind != 'ink' else NEON, + punch=0.2) + E.text(cap, 960, 800, 40, a(fg, 0.8), font='mono', wght=700) + elif lb < 48: # hook in 8ths: the plate changes once a beat, the word twice + kind, k = beat_plate(f, ['glue', 'ink', 'mint', 'ink'], 40) + b, fg = plate(E, f, kind, grid=False) + j = int((lb - 40) * 2) + plat = ['ANDROID', 'LINUX', 'WINDOWS', 'MACOS'][(j // 4) % 4] + word = ['DESKTOP', 'OPENGL', 'ON', plat][j % 4] + screen_cam(E, f, zoom=1 + 0.05 * ((lb * 2) % 1) + 0.03 * f.kick, shake=4 + 4 * climax(f, 40, 48)) + speedlines(E, f, 0.35 + 0.3 * climax(f, 40, 48), a(fg, 0.45), seed=j) + slam(E, f, word, 960, 540, tb_(40 + j * 0.5), fit_size(E, word, 1700, cap=520), + fg if kind != 'ink' else (NEON if word == plat else BONE), punch=0.25) + P['ca'] = 0.6 + 0.6 * f.kick + else: # 4x4 grid fills on 8ths, then the whole grid flashes and shakes up to the climax + plate(E, f, 'ink') + n = min(16, int((lb - 48) * 2) + 1) + c = climax(f, 56, 63) + z = 1.25 - 0.25 * out_cubic((lb - 48) / 4) + 0.08 * in_cubic(clamp01((lb - 52) / 4)) + 0.25 * c + screen_cam(E, f, zoom=z + (0.02 + 0.05 * c) * f.kick, rot=0.03 * c * math.sin(f.lt * 9), shake=3 + 16 * c) + if lb >= 56: + speedlines(E, f, 0.3 + 0.7 * c, a(GLUE, 0.6), n=90, seed=5) + # a highlight that races round the grid: one tile per 8th, then per 16th + if lb >= 56: + rate = 2 if lb < 60 else 4 + hot = int((lb - 56) * rate) % 16 + else: + hot = -1 + order = [0, 1, 2, 3, 7, 11, 15, 14, 13, 12, 8, 4, 5, 6, 10, 9] + all_on = lb >= 62.0 + for k in range(16): + x = 270 + (k % 4) * 460 + y = 200 + (k // 4) * 225 + if k >= n: + E.rect(x, y, 420, 190, a(INK2, 0.7), r=22) + continue + uu = out_back((lb - 48 - k * 0.5) / 0.3) + jit = (8 + 20 * c) * f.kick + x += (rnd('jx', k, int(f.t * 30)) - 0.5) * jit + y += (rnd('jy', k, int(f.t * 30)) - 0.5) * jit + lit = all_on or (hot >= 0 and order[hot] == k) + col = scale_rgb(GLUE, 1.9) if lit else [GLUE, MINT, PALE, GLUE][(k + k // 4) % 4] + sc_ = 1.0 + (0.12 if lit else 0.0) + 0.04 * f.kick + E.rect(x, y, 420 * uu * sc_, 190 * uu * sc_, col, r=22) + E.text(TILES[k], x - 10, y, fit_size(E, TILES[k], 330, cap=70) * uu * sc_, INK, wght=900) + check(E, x + 170 * uu, y - 60 * uu, 22 * uu, INK, 7, (lb - 48 - k * 0.5 - 0.1) / 0.25) + P['ca'] = 0.4 + 1.8 * c + P['bloom'] = 0.55 + 0.35 * c + return P diff --git a/intro-video/hype/sc_final.py b/intro-video/hype/sc_final.py new file mode 100644 index 00000000..f1c6c1fe --- /dev/null +++ b/intro-video/hype/sc_final.py @@ -0,0 +1,215 @@ +"""Bars 88-112: the last drop (key lift) and the outro.""" +import math +import os +from anim import * +from kit import * +from devices import * +from engine import LW, LH, hexc +from sc_drop1 import logo, hook_word + +# what the frames were drawn with; render.py fills this in from the GL renderer string +RENDERED_WITH = {'line': None} + + +def cube_rain(E, f, lb0, n=10, floor=1010, step=0.5): + """A row of little glue blobs; a block drops into each in turn.""" + items = [] + for k in range(n): + land = lb0 + k * step + x = 140 + (k * 173) % 1640 + sz = 50 + 20 * rnd('cs', k) + u = (f.lbeat - land) * S.BEAT + items.append((x, floor - sz, sz, 0.0, 0.1 * f.kick, 0.4, rnd('tint', k) * 0.5, 1.0, u)) + cubes(E, f, items) + + +MONTAGE = [ # local beat, word, plate, painter + (8, 'ANDROID', 'ink'), (12, 'LINUX', 'mint'), (16, 'WINDOWS', 'ink'), (20, 'MACOS', 'glue'), + (24, 'VULKAN', 'ink'), (28, 'OPENGL ES', 'mint'), (32, 'ANY MACHINE', 'ink'), (36, 'OPEN SOURCE', 'glue'), +] + + +def montage(E, f, i, lb0): + word, kind = MONTAGE[i][1], MONTAGE[i][2] + u = f.lbeat - lb0 + b, fg = plate(E, f, kind, grid=True, drift=(40, 0)) + size = fit_size(E, word, 1700, cap=330) + screen_cam(E, f, zoom=1 + 0.02 * f.kick + 0.01 * u, shake=12 * math.exp(-u * 4)) + faces = FACE_DARK if kind == 'ink' else FACE_LIGHT + line = OUTL if kind == 'ink' else hexc('#2f7d2a') + if word == 'ANDROID': + iso = Iso(1450, 1000, 62) + phone(E, iso, f, faces=faces, screen=screen_layer(E, 'f_phone', 265, 550, paint_voxels, f), line=line) + elif word == 'LINUX': + iso = Iso(1380, 1010, 56) + monitor(E, iso, f, faces=faces, screen=screen_layer(E, 'f_desk', 760, 440, paint_desktop, f), line=line) + elif word == 'WINDOWS': + y = lerp(-200, 820, out_back(clamp01(u / 0.6), 1.2)) + file_card(E, 1520, y, 230, 280, 'opengl32', '.dll', rot=0.05) + elif word == 'MACOS': + iso = Iso(1420, 960, 58) + laptop(E, iso, f, y=2.0, faces=FACE_DARK, screen=screen_layer(E, 'f_mac', 600, 380, paint_mac, f), + line=hexc('#0d2213')) + elif word == 'VULKAN': + cubes(E, f, [(1500, 830, 150, f.lt * 1.2, 0.2 * f.kick, 0.4 * f.kick, 0.0, 1.0)], + deep=hexc('#0a2a10')) + E.text('Magma', 1500, 1030, 34, GLUE, font='mono', wght=800) + elif word == 'OPENGL ES': + cubes(E, f, [(1500, 830, 150, -f.lt * 1.2, 0.2 * f.kick, 0.4 * f.kick, 0.0, 1.0)], + deep=hexc('#2e6e2f')) + E.text('Espryt', 1500, 1030, 34, MOSS, font='mono', wght=800) + elif word == 'ANY MACHINE': + pts = [(260 + k * 120, 820 + 40 * math.sin(k * 0.8 + f.lt * 3)) for k in range(13)] + caps = [(p[0], p[1], q[0], q[1], 14, 14) for p, q in zip(pts, pts[1:])] + goo(E, [(pts[0][0], pts[0][1], 50), (pts[-1][0], pts[-1][1], 50)], caps, k=30, bevel=18) + for k in range(6): + ph = (u * 2 + k / 6) % 1 + x = lerp(pts[0][0], pts[-1][0], ph) + E.rect(x, 820 + 40 * math.sin((x - 260) / 120 * 0.8 + f.lt * 3), 30, 22, scale_rgb(PALE, 1.5), r=5) + else: # OPEN SOURCE + chip(E, 960, 820, 'LGPL-3.0', 54, GLUE, INK, font='archivo', wght=900, sc=out_back(u / 0.4)) + chip(E, 960, 940, 'github.com/MobileGL-Dev/MobileGL', 32, INK, BONE, sc=out_back((u - 0.5) / 0.4)) + slam(E, f, word, 960, 330, f.t - u * S.BEAT, size, fg if kind != 'ink' else BONE, punch=0.2) + + +QUICK = [('ANDROID', 'ink'), ('LINUX', 'glue'), ('WINDOWS', 'ink'), ('MACOS', 'mint'), ('VULKAN', 'ink'), + ('OPENGL ES', 'glue'), ('LOCAL', 'ink'), ('REMOTE', 'mint')] +FEATS = ['4.6 CORE', 'SPIR-V', 'VULKAN', 'GLES', 'ANDROID', 'LINUX', 'WINDOWS', 'MACOS', 'EGL', 'GLX', 'WGL', 'CGL', + 'INPROC', 'SPAWN', 'TCP', 'OPEN SOURCE'] + + +def final_drop(E, f): + """Last drop, accelerating: logo, 2-beat montage, 1-beat words, 8th-note features, multiplying hook, collapse.""" + lb = f.lbeat + P = {'bloom': 0.6, 'grain': 0.032, 'ca': 0.5 * f.kick} + tb_ = lambda b: f.t - (lb - b) * S.BEAT + if lb < 4: # biggest logo hit + jelly rain + plate(E, f, 'ink', grid=False) + rot = -f.lt * 0.3 + for k in range(18): + ang = rot + k * math.pi / 9 + E.poly([(960, 330), (960 + 2200 * math.cos(ang - 0.07), 330 + 2200 * math.sin(ang - 0.07)), + (960 + 2200 * math.cos(ang + 0.07), 330 + 2200 * math.sin(ang + 0.07))], a(MOSS, 0.45)) + screen_cam(E, f, zoom=1.0 + 0.015 * f.kick + 0.04 * lb / 4, shake=30 * math.exp(-lb * 2.0)) + cube_rain(E, f, 0.25, 10, step=0.3) + logo(E, f, 0, 960, 500, scale=1.1) + P['flash'] = (*GLUE[:3], 0.95 * math.exp(-lb * S.BEAT / 0.12)) + P['bloom'] = 0.75 + elif lb < 20: # montage, 2 beats each + i = int((lb - 4) // 2) + montage(E, f, i, 4 + 2 * i) + elif lb < 28: # one word a beat + k = int(lb - 20) + word, kind = QUICK[k] + b, fg = plate(E, f, kind, grid=False) + screen_cam(E, f, zoom=1 + 0.06 * (lb % 1) + 0.02 * f.kick, rot=(0.02 if k % 2 else -0.02)) + speedlines(E, f, 0.35, a(fg, 0.45), seed=k + 80) + slam(E, f, word, 960, 540, tb_(20 + k), fit_size(E, word, 1650, cap=460), fg if kind != 'ink' else NEON, + punch=0.25) + elif lb < 36: # sixteen features on 8ths; the plate still changes only once a beat + kind, k = beat_plate(f, ['glue', 'ink', 'mint', 'ink'], 28) + b, fg = plate(E, f, kind, grid=False) + j = int((lb - 28) * 2) + w = FEATS[j % 16] + c = climax(f, 28, 36) + screen_cam(E, f, zoom=1 + 0.06 * ((lb * 2) % 1) + 0.03 * f.kick, shake=3 + 6 * c) + speedlines(E, f, 0.4 + 0.3 * c, a(fg, 0.45), seed=j + 90) + slam(E, f, w, 960, 500, tb_(28 + j * 0.5), fit_size(E, w, 1550, cap=440), fg if kind != 'ink' else NEON, + punch=0.25) + check(E, 960, 820, 60, fg if kind != 'ink' else GLUE, 16, ((lb * 2) % 1) / 0.4) + E.text(f'{j + 1:2d} / 16', 1750, 1010, 30, a(fg, 0.8), font='mono', wght=800, align='right') + elif lb < 52: # hub: MobileGL in the middle, glue strands shoot out to everything it runs on + plate(E, f, 'ink') + SAT = ['ANDROID', 'LINUX', 'WINDOWS', 'MACOS', 'VULKAN', 'OPENGL ES', 'EGL · GLX · WGL · CGL', 'TCP'] + c = climax(f, 44, 52) + spin = 0.15 * (lb - 36) + 2.2 * c * c + screen_cam(E, f, zoom=1.0 + 0.03 * f.kick + 0.12 * c, rot=0.03 * c * math.sin(f.lt * 7), shake=2 + 10 * c) + if lb >= 44: + speedlines(E, f, 0.3 + 0.6 * c, a(GLUE, 0.5), n=80, seed=17) + balls = [(960, 540, 130 + 14 * f.kick)] + caps, pos = [], [] + for k, name in enumerate(SAT): + bk = 36 + k + if lb < bk: + continue + ang = k * 2 * math.pi / 8 + spin - math.pi / 2 + shoot = out_expo(clamp01((lb - bk) / 0.3)) + pull = in_cubic(clamp01((lb - 50) / 2)) # reeled back in at the end + R = (1 - pull) * shoot + x, y = 960 + math.cos(ang) * 640 * R, 540 + math.sin(ang) * 360 * R + caps.append((960, 540, x, y, 14, 10)) + balls.append((x, y, 26)) + pos.append((x, y, name, shoot * (1 - pull), k)) + goo(E, balls, caps, k=40, glow=0.2 + 0.3 * c) + # packets racing out along the strands: 8ths, then 16ths + rate = 2 if lb < 48 else 4 + for x, y, name, sh, k in pos: + if sh < 0.95: + continue + ph = (lb * rate + k * 0.37) % 1 + E.rect(lerp(960, x, ph), lerp(540, y, ph), 22, 16, scale_rgb(PALE, 1.6), r=4) + for x, y, name, sh, k in pos: + if sh <= 0.05: + continue + fresh = math.exp(-max(0.0, lb - (36 + k)) * 4) + chip(E, x, y, name, 40, INK, scale_rgb(GLUE, 1.0 + 0.8 * fresh), font='archivo', wght=900, sc=sh * (1 + 0.3 * fresh)) + E.text('MobileGL', 960, 540, 46 * (1 + 0.05 * f.kick), INK, wght=900) + P['ca'] = 0.4 * f.kick + 1.2 * c + P['bloom'] = 0.6 + 0.3 * c + else: # collapse into one glue drop, faster and faster + plate(E, f, 'ink') + u = in_cubic(clamp01((lb - 52) / 11.0)) + c = climax(f, 52, 63) + speedlines(E, f, 0.3 + 0.7 * c, a(GLUE, 0.8), n=110, seed=13) + screen_cam(E, f, zoom=1 + 0.5 * c + 0.03 * f.kick, rot=0.05 * c, shake=3 + 14 * c) + balls = [] + for k in range(32): + ang = k * 2 * math.pi / 32 + f.lt * (1.5 + 6 * c) + 3.0 * (lb - 52) / 11 + R = 900 * (1 - u) * (0.6 + 0.4 * rnd('cv', k)) + balls.append((960 + math.cos(ang) * R, 540 + math.sin(ang) * R * 0.6, 40 + 30 * u)) + balls.append((960, 540, 60 + 180 * u)) + goo(E, balls, k=60 + 60 * u, glow=0.2 + 0.6 * u) + E.cam = (LW / 2, LH / 2, 1.0 + 0.04 * f.kick, 0.0) + E.text('DESKTOP OPENGL.', 960, 160, 110, a(BONE, 1 - 0.3 * u), wght=900, wdth=100 + 25 * c) + E.text('ANYWHERE.', 960, 930, 130, a(NEON, 1 - 0.3 * u), wght=900, wdth=100 + 25 * c) + P['bloom'] = 0.6 + 0.5 * u + P['ca'] = 0.5 + 1.8 * c + return P + + +def outro(E, f): + lb = f.lbeat + P = {'bloom': 0.55, 'grain': 0.035} + plate(E, f, 'ink', drift=(3, 0)) + fade_out = clamp01((lb - 26) / 5.0) + if lb < 16: + if lb < 0.3: + P['flash'] = (*GLUE[:3], 0.7 * (1 - lb / 0.3)) + logo(E, f, 0, 960, 430, splat=True, scale=0.9) + chips_ = [('OpenGL 4.6 Core', GLUE), ('Vulkan', MINT), ('OpenGL ES', MINT), ('LGPL-3.0', PALE)] + total = sum(E.measure(s_, 'mono', 30, 700) + 36 for s_, _ in chips_) + 24 * (len(chips_) - 1) + x = 960 - total / 2 + for k, (s, c) in enumerate(chips_): + if lb >= 2 + k * 0.5: + w = chip(E, x, 760, s, 30, INK, c, align='left', sc=out_back((lb - 2 - k * 0.5) / 0.35)) + x += w + 24 + if lb >= 5: + u = clamp01((lb - 5) / 0.5) + E.text('github.com/MobileGL-Dev/MobileGL', 960, 880, 44, a(BONE, u), font='mono', wght=700) + else: + u = clamp01((lb - 16) / 0.4) + if lb < 16.3: + P['flash'] = (*BONE[:3], 0.5 * (1 - (lb - 16) / 0.3)) + cubes(E, f, [(960, 360, 120, f.lt * 0.4, 0.0, 0.0, 0.0, 1.0 - fade_out)]) + line = RENDERED_WITH['line'] + if line: + E.text('every frame you just saw', 960, 600, 70, a(BONE, u * (1 - fade_out)), wght=800) + E.text('was rendered through MobileGL.', 960, 690, 70, a(NEON, u * (1 - fade_out)), wght=900) + E.text(line, 960, 800, 28, a(BONE, 0.65 * u * (1 - fade_out)), font='mono', wght=600) + else: + E.text('desktop OpenGL. anywhere.', 960, 640, 80, a(BONE, u * (1 - fade_out)), wght=900) + E.text('github.com/MobileGL-Dev/MobileGL', 960, 920, 34, a(GLUE, u * (1 - fade_out)), font='mono', wght=700) + if lb >= 28: + code_line(E, '', 900, 540, 34, chars=0, cursor=True, f=f, alpha=clamp01((lb - 28) / 0.5)) + P['fade'] = clamp01((lb - 32) / 1.2) + return P diff --git a/intro-video/hype/sc_how.py b/intro-video/hype/sc_how.py new file mode 100644 index 00000000..3addb222 --- /dev/null +++ b/intro-video/hype/sc_how.py @@ -0,0 +1,208 @@ +"""Bars 32-48: how it works. Pipeline, frontend, shaders, two backends.""" +import math +from anim import * +from kit import * +from devices import * +from engine import LW, LH, hexc + +STAGES = [('YOUR APP', 'OpenGL 4.6 calls'), ('FRONTEND', 'state + validation'), ('MGPIPE', 'records'), + ('BACKEND', 'Vulkan · OpenGL ES'), ('GPU', 'pixels')] +SX = [220, 590, 960, 1330, 1700] +TOKENS = ['glDrawElements(...)', 'valid · state Δ', 'record #4217', 'vkCmdDrawIndexed', 'pixels!'] + + +def pipeline(E, f, lb): + plate(E, f, 'ink', drift=(30, 0)) + E.text('THE PIPELINE', 130, 150, 34, a(GLUE, 0.9), font='mono', wght=800, align='left') + y = 520 + for i, (name, sub) in enumerate(STAGES): + bi = 4 + i + if lb < bi: + continue + u = out_back((lb - bi) / 0.35) + hot = 0.0 + hop = (lb - 8) / 2.0 + if 0 <= hop and int(hop) == i and lb >= 8: + hot = 1 - (hop % 1) + iso = Iso(SX[i], y + 40, 54 * u) + top = mix(FACE_DARK[0], scale_rgb(GLUE, 1.3), hot * 0.8) + iso.box(E, -1.6, -1.6, 0, 3.2, 3.2, 1.4, top, FACE_DARK[1], FACE_DARK[2], OUTL, 5) + E.text(name, SX[i], y + 210, 40, a(BONE, u), wght=800, wdth=105) + E.text(sub, SX[i], y + 258, 22, a(BONE, 0.6 * u), font='mono') + if i < 4 and lb >= bi + 0.3: + arrow(E, SX[i] + 120, y + 40, SX[i + 1] - 120, y + 40, 6, a(GLUE, 0.8), u=(lb - bi - 0.3) / 0.4) + if lb >= 8: # the call rides a glue drop through the stages + hop = min((lb - 8) / 2.0, 4.0) + i = int(hop) + fr = hop - i + j = min(i + 1, 4) + x = lerp(SX[i], SX[j], in_out_cubic(fr / 0.6) if fr < 0.6 else 1.0) + yy = y - 120 - 140 * bump(min(fr / 0.6, 1.0)) - 6 * f.kick + stage = j if fr >= 0.6 else i + goo(E, [(x, yy, 34 + 6 * f.kick)], k=20, outline=5, bevel=20, glow=0.3) + chip(E, x, yy - 80, TOKENS[stage], 26, INK, GLUE if stage < 4 else PALE) + if lb >= 16 - 0.3: + for k in range(14): + ang = k * 2 * math.pi / 14 + f.lt + rr = 90 + 260 * out_expo((lb - 15.7) / 0.6) + E.rect(SX[4] + math.cos(ang) * rr, y + 40 + math.sin(ang) * rr * 0.6, 26, 26, + [GLUE, MINT, AMBER, CORAL, PALE][k % 5], r=4, rot=ang) + + +STATE = ['DEPTH_TEST on', 'BLEND off', 'CULL_FACE back', 'VAO 3', 'PROGRAM 12', 'TEX0 2D:7', 'VIEWPORT 1920×1080', + 'STENCIL off', 'FBO 2', 'SCISSOR off', 'UBO0 5', 'SSBO1 9', 'DEPTH_FUNC less', 'COLOR_MASK rgba', + 'BLEND_EQ add', 'FRONT ccw', 'SAMPLER0 4', 'TEX1 cube:2', 'DRAW_BUF 0', 'PRIM_RESTART off', 'POLYGON fill', + 'PATCH_VERTS 3', 'SRGB on', 'LINE 1.0'] +ALT = ['DEPTH_TEST off', 'BLEND on', 'CULL_FACE front', 'VAO 8', 'PROGRAM 4', 'TEX0 2D:11', 'VIEWPORT 512×512', + 'STENCIL on', 'FBO 0', 'SCISSOR on', 'UBO0 2', 'SSBO1 3', 'DEPTH_FUNC lequal', 'COLOR_MASK rgb_', + 'BLEND_EQ max', 'FRONT cw', 'SAMPLER0 9', 'TEX1 cube:6', 'DRAW_BUF 1', 'PRIM_RESTART on', 'POLYGON line', + 'PATCH_VERTS 4', 'SRGB off', 'LINE 2.0'] + + +def frontend(E, f, lb): + plate(E, f, 'mint', drift=(0, 20)) + E.text('FRONTEND', 120, 170, 150, INK, wght=900, align='left', wdth=105) + E.text('MG_State · MG_Impl', 130, 270, 28, a(INK, 0.7), font='mono', wght=700, align='left') + if lb < 24: + slam(E, f, 'TRACKS EVERY BIT OF STATE', 1800, 290, f.t - (lb - 16.5) * S.BEAT, 54, MOSS, align='right') + step = int(lb * 4) + for k in range(24): + r, c = k // 4, k % 4 + x = 300 + c * 440 + yy = 420 + r * 95 + flips = sum(1 for s in range(int(16 * 4), step + 1) if rnd('flip', s) * 24 // 1 == k) + alt = flips % 2 == 1 + recent = any(rnd('flip', s) * 24 // 1 == k for s in range(step - 1, step + 1)) + col = NEON if recent else (MOSS if alt else INK) + E.rect(x, yy, 410, 78, a(PALE, 0.95) if not recent else a(INK, 0.95), r=12) + E.text((ALT if alt else STATE)[k], x, yy, 26, col if recent else INK, font='mono', wght=600) + else: + slam(E, f, 'VALIDATES EVERY CALL', 1800, 290, f.t - (lb - 24) * S.BEAT, 54, MOSS, align='right') + calls = ['glBindBuffer(GL_ARRAY_BUFFER, 4);', 'glBufferSubData(GL_ARRAY_BUFFER, 0, 256, data);', + 'glUseProgram(12);', 'glUniformMatrix4fv(0, 1, GL_FALSE, mvp);', 'glBindVertexArray(3);', + 'glBindVertexArray(999);', 'glBindTexture(GL_TEXTURE_2D, 7);', + 'glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);'] + BAD = 5 + E.rect(960, 680, 1640, 600, INK, r=24) + # the checker steps down the list, one call per 8th; it stops dead on the bad one + step = (lb - 24.5) * 2 + cur = BAD if step >= BAD else int(max(step, -1)) + after = lb - (24.5 + BAD * 0.5) # beats since the bad call was hit + if after >= 2.0: # then carries on past it + cur = min(len(calls) - 1, BAD + 1 + int((after - 2.0) * 2)) + for i, c in enumerate(calls): + yy = 420 + i * 66 + checked = i < cur or (i == cur and (step - int(step)) > 0.5) or (i < BAD + 1 and after >= 0 and i != BAD and i < cur + 1) + if i == BAD and after >= 0: + shake = 10 * math.exp(-after * 6) * math.sin(after * 60) + E.rect(960 + shake, yy, 1612, 56, a(CORAL, 0.35 + 0.35 * math.exp(-after * 3)), r=8) + code_line(E, c, 210 + shake, yy, 30) + cross(E, 172 + shake, yy, 15, CORAL, 5) + continue + if i == cur: + E.rect(960, yy, 1612, 56, a(GLUE, 0.22), r=8, stroke=a(GLUE, 0.9), sw=3) + code_line(E, c, 210, yy, 30, alpha=1.0 if i <= cur else 0.4) + if i < cur or (i > BAD and i <= cur and i != cur): + check(E, 172, yy, 16, GLUE, 5) + if after >= 0.5: + stamp(E, f, 'GL_INVALID_OPERATION', 1250, 420 + BAD * 66 - 8, f.t - (after - 0.5) * S.BEAT, 54, CORAL, + rot=-0.05) + if after >= 1.0: + E.text('caught in the frontend, before the driver ever sees it', 960, 1020, 30, + a(INK, clamp01((after - 1.0) / 0.5)), font='mono', wght=600) + + +GLSL = ['#version 460 core', 'layout(location = 0) in vec3 pos;', 'uniform mat4 mvp;', 'void main() {', + ' gl_Position = mvp * vec4(pos, 1.0);', '}'] +ESSL = ['#version 320 es', 'precision highp float;', 'layout(location = 0) in vec3 pos;', 'uniform mat4 mvp;', + 'void main() { ... }'] +SPV = ['0x07230203', '0x00010600', '0x0008000b', '0x0000002e', '0x00000000', '0x00020011', '0x00000001', + '0x0006000b', '0x00000001', '0x4c534c47', '0x6474732e', '0x3035342e', '0x00000000', '0x0003000e'] + + +def shaders(E, f, lb): + plate(E, f, 'ink') + E.text('SHADERS', 120, 150, 120, BONE, wght=900, align='left', wdth=105) + E.text('one compiler front, two ways out', 130, 245, 28, a(GLUE, 0.85), font='mono', wght=600, align='left') + # GLSL panel + E.rect(390, 640, 680, 420, INK2, r=20, stroke=a(GLUE, 0.6), sw=3) + E.text('GLSL 4.60', 80, 465, 26, GLUE, font='mono', wght=800, align='left') + total = sum(len(s_) for s_ in GLSL) + n = int(total * clamp01((lb - 32) / 2.0)) + for i, s_ in enumerate(GLSL): + before = sum(len(x) for x in GLSL[:i]) + code_line(E, s_, 80, 525 + i * 52, 25, chars=n - before) + if lb >= 35: + arrow(E, 745, 640, 850, 640, 7, GLUE, u=(lb - 35) / 0.3) + chip(E, 800, 570, 'glslang', 26, INK, GLUE, sc=out_back((lb - 35) / 0.3)) + E.rect(1010, 640, 300, 420, INK2, r=20, stroke=a(GLUE, 0.6), sw=3) + E.text('SPIR-V', 880, 465, 26, GLUE, font='mono', wght=800, align='left') + k = int((lb - 35) * 6) + for i in range(9): + w = SPV[(i + k) % len(SPV)] + E.text(w, 1010, 520 + i * 40 - ((lb * 6) % 1) * 40, 26, a(AMBER, 0.9), font='mono', wght=600) + E.rect(1010, 452, 300, 30, INK) # crop the scrolling words at the panel top + E.text('SPIR-V', 880, 465, 26, GLUE, font='mono', wght=800, align='left') + if lb >= 40: + u1 = (lb - 40) / 0.4 + arrow(E, 1170, 560, 1290, 420, 7, GLUE, u=u1) + arrow(E, 1170, 720, 1290, 830, 7, GLUE, u=u1) + uu = out_back((lb - 40.3) / 0.35) + if lb >= 40.3: + E.rect(1580, 380, 520 * uu, 210 * uu, GLUE, r=22) + E.text('VULKAN', 1580, 345, 70 * uu, INK, wght=900) + E.text('SPIR-V, as-is · Magma', 1580, 420, 28 * uu, INK, font='mono', wght=700) + E.rect(1580, 830, 520 * uu, 330 * uu, INK2, r=22, stroke=a(MINT, 0.8), sw=3) + E.text('OPENGL ES', 1580, 710, 54 * uu, MINT, wght=900) + for i, s_ in enumerate(ESSL): + code_line(E, s_, 1345, 770 + i * 38, 22, alpha=uu, chars=int(len(s_) * clamp01((lb - 41) / 1.5))) + chip(E, 1580, 1030, 'SPIRV-Cross → GLSL ES · Espryt', 24, INK, MINT, sc=uu) + + +def backends(E, f, lb): + """Split screen: Magma/Vulkan vs Espryt/GLES, same cube, the switch flips each beat.""" + side = int(lb) % 2 # 0 = Vulkan active + E.rect(480, 540, 960, 1080, INK) + E.rect(1440, 540, 960, 1080, MINT) + dot_grid(E, f, a(MOSS, 0.5), 60, 2.0) + yaw = f.lt * 0.9 + sq = 0.18 * f.kick + cubes(E, f, [(480, 520, 150, yaw, sq, 0.4 * f.kick, 0.0, 1.0)], deep=hexc('#123a18')) + cubes(E, f, [(1440, 520, 150, yaw, sq, 0.4 * f.kick, 0.0, 1.0)], deep=hexc('#2e6e2f')) + E.text('MAGMA', 480, 160, 120, BONE, wght=900, wdth=110) + E.text('→ VULKAN', 480, 250, 40, GLUE, font='mono', wght=800) + E.text('ESPRYT', 1440, 160, 120, INK, wght=900, wdth=110) + E.text('→ OPENGL ES', 1440, 250, 40, MOSS, font='mono', wght=800) + # dim the inactive half + if lb < 60: + E.rect(480 + 960 * (1 - side), 540, 960, 1080, a(INK, 0.45)) + # the switch + E.rect(960, 870, 980, 110, INK2, r=55, stroke=a(GLUE, 0.7), sw=3) + E.text('MOBILEGL_BACKEND_TYPE=', 560, 870, 30, BONE, font='mono', wght=700, align='left') + E.text('DirectVulkan' if side == 0 else 'DirectGLES', 1000, 870, 30, NEON, font='mono', wght=800, align='left') + E.text('identical frames, either way', 960, 980, 26, a(BONE, 0.7), font='mono') + if lb >= 60: + words = [('SAME APP.', 60), ('SAME FRAME.', 61), ('YOUR PICK.', 62)] + k = min(int(lb - 60), 2) + w, b = words[k] + E.rect(960, 540, 1920, 330, a(INK, 0.85)) + slam(E, f, w, 960, 540, f.t - (lb - b) * S.BEAT, fit_size(E, w, 1500, cap=240), NEON if k == 2 else BONE) + + +def how(E, f): + lb = f.lbeat + P = {'bloom': 0.5, 'grain': 0.032, 'ca': 0.25 * f.kick} + if lb < 4: + plate(E, f, 'ink') + slam(E, f, 'SO…', 960, 300, f.t - lb * S.BEAT, 200, a(BONE, 0.8)) + if lb >= 1: + slam(E, f, 'HOW?', 960, 640, f.t - (lb - 1) * S.BEAT, 440, NEON, punch=0.3) + elif lb < 16: + pipeline(E, f, lb) + elif lb < 32: + frontend(E, f, lb) + elif lb < 48: + shaders(E, f, lb) + else: + backends(E, f, lb) + return P diff --git a/intro-video/hype/sc_modes.py b/intro-video/hype/sc_modes.py new file mode 100644 index 00000000..59790b44 --- /dev/null +++ b/intro-video/hype/sc_modes.py @@ -0,0 +1,327 @@ +"""Bars 56-72: the second drop. Same process / another process / another machine, run-ahead, many clients.""" +import math +from anim import * +from kit import * +from devices import * +from engine import LW, LH, hexc + + +def label_chip(E, x, y, s, col=INK, bgc=BONE, size=26, sc=1.0): + chip(E, x, y, s, size, col, bgc, sc=sc, font='mono', wght=800) + + +def blob_pair(E, f, sep, strand_r, k_goo=90, glow=0.15, y=560, r=200, packets=False, lb=0.0): + """Two blobs separated by sep px, joined by a strand (r=0 -> no strand).""" + cx = 960 + wob = 10 * f.kick + xa, xb = cx - sep / 2, cx + sep / 2 + balls = [(xa, y + wob, r + 8 * f.kick), (xb, y - wob, r + 8 * f.kick)] + for k in range(5): # surface jiggle + ang = f.lt * 2 + k * 1.3 + balls.append((xa + math.cos(ang) * r * 0.5, y + math.sin(ang) * r * 0.5, r * 0.45)) + balls.append((xb + math.cos(-ang) * r * 0.5, y + math.sin(-ang) * r * 0.5, r * 0.45)) + caps = [(xa, y, xb, y, strand_r, strand_r)] if strand_r > 0 else [] + goo(E, balls, caps, k=k_goo, glow=glow) + if packets and strand_r > 0: + for k in range(16): + ph = (f.lbeat * 2 + k / 4) % 4 / 4 + x = lerp(xa + r * 0.7, xb - r * 0.7, ph) + E.circle(x, y, strand_r * 0.45, scale_rgb(PALE, 1.6)) + return xa, xb + + +def modes_v1(E, f): + lb = f.lbeat + P = {'bloom': 0.6, 'grain': 0.032, 'ca': 0.45 * f.kick} + tb_ = lambda b: f.t - (lb - b) * S.BEAT + if lb < 8: # SAME PROCESS + plate(E, f, 'ink') + screen_cam(E, f, zoom=1 + 0.015 * f.kick, shake=18 * math.exp(-lb * 2.5)) + if lb < 0.25: + P['flash'] = (*GLUE[:3], 0.8 * (1 - lb / 0.25)) + slam(E, f, 'SAME PROCESS.', 960, 160, tb_(0), fit_size(E, 'SAME PROCESS.', 1500, cap=170), BONE) + blob_pair(E, f, 0, 0, r=260, glow=0.25) + label_chip(E, 960, 500, 'FRONTEND', sc=out_back(lb / 0.4)) + label_chip(E, 960, 620, 'BACKEND', sc=out_back((lb - 0.5) / 0.4)) + E.text('monolith · inproc', 960, 920, 34, GLUE, font='mono', wght=700) + elif lb < 16: # ANOTHER PROCESS + plate(E, f, 'ink') + screen_cam(E, f, zoom=1 + 0.015 * f.kick, shake=14 * math.exp(-(lb - 8) * 2.5)) + slam(E, f, 'ANOTHER PROCESS.', 960, 160, tb_(8), fit_size(E, 'ANOTHER PROCESS.', 1600, cap=170), BONE) + u = in_out_cubic((lb - 8) / 1.8) + sep = 900 * u + sr = lerp(150, 26, u) + xa, xb = blob_pair(E, f, sep, sr, r=lerp(260, 190, u), packets=lb > 10, lb=lb) + label_chip(E, xa, 560, 'CLIENT', sc=1.0) + label_chip(E, xb, 560, 'SERVER', sc=1.0) + if lb >= 10: + uu = clamp01((lb - 10) / 0.5) + E.text('your app · frontend', xa, 820, 26, a(BONE, 0.8 * uu), font='mono') + E.text('owns the GPU · backend', xb, 820, 26, a(BONE, 0.8 * uu), font='mono') + E.text('spawn + shared memory', 960, 470, 30, a(GLUE, uu), font='mono', wght=700) + elif lb < 24: # ANOTHER MACHINE + plate(E, f, 'ink', drift=(0, 0)) + z = lerp(1.25, 1.0, out_expo((lb - 16) / 1.5)) + screen_cam(E, f, zoom=z + 0.012 * f.kick, shake=14 * math.exp(-(lb - 16) * 2.5)) + slam(E, f, 'ANOTHER MACHINE.', 960, 150, tb_(16), fit_size(E, 'ANOTHER MACHINE.', 1600, cap=170), BONE) + il = Iso(360, 820, 58) + laptop(E, il, f, y=2.0, screen=screen_layer(E, 'm_lap', 600, 380, paint_mac, f)) + it = Iso(1580, 860, 62) + tower(E, it, f) + # long strand with packets, sagging like glue + pts = [] + for k in range(13): + u = k / 12 + pts.append((lerp(560, 1420, u), 560 + 120 * math.sin(math.pi * u) + 14 * math.sin(f.lt * 3 + u * 6))) + caps = [(a_[0], a_[1], b[0], b[1], 16, 16) for a_, b in zip(pts, pts[1:])] + goo(E, [(560, 560, 40), (1420, 560, 40)], caps, k=30, outline=5, bevel=18) + for k in range(8): + ph = ((lb - 16) * 4 / 8 + k / 8) % 1 + idx_ = ph * 12 + i0 = int(idx_) + fr = idx_ - i0 + x = lerp(pts[i0][0], pts[min(i0 + 1, 12)][0], fr) + y = lerp(pts[i0][1], pts[min(i0 + 1, 12)][1], fr) + E.rect(x, y - 2, 30, 22, scale_rgb(PALE, 1.5), r=5) + chip(E, 990, 440, 'TCP', 34, INK, PALE, font='mono', wght=800) + E.text('client', 360, 960, 30, BONE, font='mono', wght=700) + E.text('server', 1580, 960, 30, BONE, font='mono', wght=700) + if lb >= 18: + uu = clamp01((lb - 18) / 0.5) + E.text('the client is the frontend · the server is the backend', 960, 1030, 26, a(GLUE, uu), + font='mono', wght=600) + elif lb < 28: # recap list + plate(E, f, 'mint', grid=False) + rows = ['SAME PROCESS', 'ANOTHER PROCESS', 'ANOTHER MACHINE'] + for i, s in enumerate(rows): + if lb < 24 + i: + continue + y = 290 + i * 230 + slam(E, f, s, 260, y, tb_(24 + i), 150, INK, align='left', wdth=95) + check(E, 170, y, 60, MOSS, 16, (lb - 24 - i) / 0.3) + if lb >= 27: + E.text('monolith · inproc · spawn + shm · tcp', 960, 990, 34, MOSS, font='mono', wght=800) + elif lb < 32: # YOUR CALL. + plate(E, f, 'glue', grid=False) + slam(E, f, 'YOUR CALL.', 960, 540, tb_(28), fit_size(E, 'YOUR CALL.', 1600), INK, punch=0.3) + screen_cam(E, f, shake=16 * math.exp(-(lb - 28) * 3)) + elif lb < 40: # RUN-AHEAD lanes + plate(E, f, 'ink') + slam(E, f, 'RUN-AHEAD', 120, 160, tb_(32), 150, BONE, align='left', wdth=110) + E.text('the client keeps going while the server catches up', 130, 260, 28, a(GLUE, 0.9), font='mono', + wght=600, align='left') + scroll = (lb - 32) * 70 + for lane, (name, y, wait) in enumerate((('APP · monolith', 470, True), ('GPU', 560, True), + ('APP · split', 760, False), ('GPU', 850, False))): + E.text(name, 130, y, 24, a(BONE, 0.8), font='mono', wght=700, align='left') + for k in range(14): + if lane in (0, 1): + x0 = 420 + k * 260 - scroll + if lane == 0: + E.rect(x0 + 60, y, 110, 54, GLUE, r=8) + E.rect(x0 + 180, y, 110, 54, a(BONE, 0.12), r=8) + else: + E.rect(x0 + 180, y, 110, 54, MINT, r=8) + else: + x0 = 420 + k * 150 - scroll * 1.0 + if lane == 2: + E.rect(x0 + 60, y, 130, 54, GLUE, r=8) + else: + E.rect(x0 + 120, y, 130, 54, MINT, r=8) + E.rect(200, 660, 400, 520, INK) # mask the lanes under the labels + for lane, (name, y) in enumerate((('APP · monolith', 470), ('GPU', 560), ('APP · split', 760), ('GPU', 850))): + E.text(name, 130, y, 24, a(BONE, 0.85), font='mono', wght=700, align='left') + E.text('wait', 700 - (scroll % 260), 420, 20, a(BONE, 0.4), font='mono') + elif lb < 48: # ~1/3 faster + plate(E, f, 'ink') + slam(E, f, '≈33% FASTER', 960, 260, tb_(40), 210, NEON, punch=0.3) + u = out_expo((lb - 41) / 1.5) + for i, (name, val, col) in enumerate((('monolith', 1.0, a(BONE, 0.6)), ('inproc / spawn', 1.33, GLUE))): + y = 560 + i * 170 + E.text(name, 520, y, 34, BONE, font='mono', wght=700, align='right') + w = 900 * val / 1.33 * u + E.rect(560 + w / 2, y, w, 110, col, r=14) + E.text(f'{val:.2f}×', 560 + w + 30, y, 44, a(BONE, clamp01(u * 3)), wght=900, align='left') + E.text('under heavy load, with the backend split off', 960, 960, 28, a(BONE, 0.7), font='mono') + else: # many clients orbiting one server + plate(E, f, 'ink') + zoom = 1 + 0.25 * in_expo((lb - 58) / 6) + screen_cam(E, f, zoom=zoom + 0.01 * f.kick, rot=0.05 * in_expo((lb - 58) / 6)) + apps = ['browser', 'terminal', 'game', 'compositor', 'video', 'editor'] + n = min(6, max(0, int(lb - 48) + 1)) + balls = [(960, 560, 150 + 10 * f.kick)] + caps = [] + pos = [] + for k in range(n): + ang = k * 2 * math.pi / 6 + f.lt * 0.35 + u = out_back((lb - 48 - k) / 0.5) + R = 400 * u + x, y = 960 + math.cos(ang) * R * 1.25, 560 + math.sin(ang) * R * 0.8 + balls.append((x, y, 70)) + caps.append((960, 560, x, y, 14, 14)) + pos.append((x, y, apps[k])) + goo(E, balls, caps, k=40, glow=0.2) + label_chip(E, 960, 560, 'SERVER', size=30) + for x, y, s in pos: + label_chip(E, x, y, s, size=22) + if lb >= 56: + uu = clamp01((lb - 56) / 0.5) + E.text('one server · many apps · a whole desktop', 960, 1000, 36, a(GLUE, uu), font='mono', wght=700) + return P + + +def run_ahead(E, f, u): + """6 beats. Time advances one slot per 8th, snapping: the monolith app alternates work and waiting, the split + app never waits while the GPU follows one slot behind.""" + P = {'bloom': 0.55, 'grain': 0.032, 'ca': 0.4 * f.kick} + plate(E, f, 'ink') + tb0 = f.t - u * S.BEAT + slam(E, f, 'RUN-AHEAD', 120, 150, tb0, 150, BONE, align='left', wdth=110) + E.text('the client keeps going while the server catches up', 130, 250, 28, a(GLUE, 0.9), font='mono', wght=600, + align='left') + n = min(12, int(u * 2) + 1) # slots shown so far + frac = (u * 2) % 1 + pop = out_back(clamp01(frac / 0.35), 2.0) + screen_cam(E, f, zoom=1 + 0.012 * f.kick + 0.006 * (1 - clamp01(frac / 0.3))) + X0, SW = 440, 94 + lanes = [('APP · monolith', 430), ('GPU', 515), ('APP · split', 700), ('GPU', 785)] + for name, y in lanes: + E.text(name, 130, y, 26, a(BONE, 0.85), font='mono', wght=700, align='left') + E.line(X0 - 20, y + 36, X0 + 12 * SW, y + 36, 2, a(MOSS, 0.8)) + waits = 0 + for k in range(n): + sc_ = pop if k == n - 1 else 1.0 + x = X0 + k * SW + SW / 2 + # monolith: the app works, then waits while the GPU draws that frame + if k % 2 == 0: + E.rect(x, 430, (SW - 10) * sc_, 58 * sc_, GLUE, r=8) + else: + waits += 1 + E.rect(x, 430, (SW - 10) * sc_, 58 * sc_, a(BONE, 0.14), r=8) + E.text('wait', x, 430, 20 * sc_, a(BONE, 0.6), font='mono', wght=700) + E.rect(x, 515, (SW - 10) * sc_, 58 * sc_, MINT, r=8) + # split: the app records every slot, the server draws one slot behind + E.rect(x, 700, (SW - 10) * sc_, 58 * sc_, GLUE, r=8) + if k >= 1: + E.rect(x, 785, (SW - 10) * sc_, 58 * sc_, MINT, r=8) + # playhead + px = X0 + n * SW - (1 - pop) * 0.0 + E.line(px, 380, px, 830, 4, a(NEON, 0.9)) + E.text(f'app waited: {waits}', X0 + 12 * SW + 40, 472, 30, a(CORAL, 0.95), font='mono', wght=800, align='left') + E.text('app waited: 0', X0 + 12 * SW + 40, 742, 30, GLUE, font='mono', wght=800, align='left') + return P + + +def vf(f, vlb): + """A copy of the frame whose local clock reads vlb beats (beat pulses stay on the real clock).""" + import copy + g = copy.copy(f) + d = (vlb - f.lbeat) * S.BEAT + g.lbeat, g.lt, g.t = vlb, f.lt + d, f.t + d + return g + + +WORDS2 = [('MONOLITH', 'one process'), ('INPROC', 'same process, through the pipe'), ('SPAWN + SHM', 'a child process'), + ('TCP', 'another machine'), ('ONE SERVER', 'owns the GPU'), ('MANY APPS', 'one per client'), + ('A WHOLE DESKTOP', 'compositor included'), ('ANY MACHINE', 'over the network')] + + +def modes(E, f): + """Second drop, accelerating: 4-6 beat ideas, then 1-beat words, then doubling clients, then the vortex.""" + lb = f.lbeat + tb_ = lambda b: f.t - (lb - b) * S.BEAT + if lb < 4: + return modes_v1(E, f) + if lb < 8: + return modes_v1(E, vf(f, 8 + (lb - 4) * 1.5)) + if lb < 14: + return modes_v1(E, vf(f, 16 + (lb - 8))) + if lb < 17: + return modes_v1(E, vf(f, 24 + (lb - 14))) + if lb < 18: + return modes_v1(E, vf(f, 28 + (lb - 17))) + if lb < 24: + return run_ahead(E, f, lb - 18) + if lb < 28: + return modes_v1(E, vf(f, 40 + (lb - 24) * 2)) + P = {'bloom': 0.6, 'grain': 0.032, 'ca': 0.45 * f.kick} + if lb < 36: # clients join on 8ths, the orbit speeds up + plate(E, f, 'ink') + c = climax(f, 28, 36) + screen_cam(E, f, zoom=1.05 - 0.1 * c + 0.012 * f.kick) + apps = ['browser', 'terminal', 'game', 'compositor', 'video', 'editor', 'launcher', 'IDE', 'player', 'chat', + 'office', 'shell'] + n = min(12, int((lb - 28) * 2) + 1) + balls = [(960, 540, 150 + 12 * f.kick)] + caps, pos = [], [] + spin = f.lt * 0.4 + 2.0 * c * c + for k in range(n): + ring = 0 if k < 6 else 1 + ang = (k % 6) * math.pi / 3 + spin * (1 if ring == 0 else -0.7) + ring * 0.5 + u = out_back((lb - 28 - k * 0.5) / 0.35) + R = (330 + 190 * ring) * u + x, y = 960 + math.cos(ang) * R * 1.35, 540 + math.sin(ang) * R * 0.8 + balls.append((x, y, 58 - 8 * ring)) + caps.append((960, 540, x, y, 12, 12)) + pos.append((x, y, apps[k])) + goo(E, balls, caps, k=40, glow=0.2 + 0.2 * c) + chip(E, 960, 540, 'SERVER', 30, INK, PALE) + for x, y, s in pos: + chip(E, x, y, s, 20, INK, BONE) + E.text(f'{n} clients', 960, 1010, 34, GLUE, font='mono', wght=800) + elif lb < 44: # one word a beat + kind, k = beat_plate(f, ['ink', 'glue', 'ink', 'mint'], 36) + b, fg = plate(E, f, kind, grid=False) + w, cap = WORDS2[k % 8] + screen_cam(E, f, zoom=1 + 0.05 * (lb % 1) + 0.02 * f.kick, rot=(0.02 if k % 2 else -0.02)) + speedlines(E, f, 0.3 + 0.2 * climax(f, 36, 44), a(fg, 0.45), seed=k + 40) + slam(E, f, w, 960, 500, tb_(36 + k), fit_size(E, w, 1600, cap=380), fg if kind != 'ink' else NEON, punch=0.22) + E.text(cap, 960, 780, 40, a(fg, 0.8), font='mono', wght=700) + elif lb < 56: # clients doubling every beat + plate(E, f, 'ink') + k = int(lb - 44) + fr = lb - 44 - k + n_prev = 2 ** min(k, 6) + split = out_back(clamp01(fr / 0.45)) + cols = 8 + n_draw = min(64, 2 ** min(k + 1, 6)) + balls = [] + for j in range(n_draw): + parent = j // 2 if k < 6 else j + if k < 6: + # parent positions laid out on a grid sized for n_prev, children slide apart + pc = min(n_prev, 64) + gx = max(1, int(math.ceil(math.sqrt(pc * 16 / 9)))) + px = 960 + ((parent % gx) - (gx - 1) / 2) * 1500 / gx + py = 540 + ((parent // gx) - (math.ceil(pc / gx) - 1) / 2) * 820 / max(1, math.ceil(pc / gx)) + r = min(140, 620 / gx) + off = (j % 2 - 0.5) * r * 1.2 * split + balls.append((px + off, py, r * (1 - 0.25 * split))) + else: + x = 960 + ((j % cols) - 3.5) * 200 + y = 540 + ((j // cols) - 3.5) * 110 + wave = math.sin(lb * math.pi * 2 - j * 0.4) + balls.append((x, y + 10 * wave, 38 + 8 * f.kick)) + screen_cam(E, f, zoom=1.0 + 0.02 * f.kick + 0.08 * climax(f, 50, 56), shake=3 * climax(f, 50, 56)) + goo(E, balls, k=24 if k < 6 else 16, glow=0.25, outline=5, bevel=24) + E.cam, E.shake = (LW / 2, LH / 2, 1.0, 0.0), (0.0, 0.0) + E.rect(960, 1010, 640, 76, INK, r=38, stroke=a(GLUE, 0.7), sw=3) + E.text('more clients · one server', 960, 1010, 36, NEON, font='mono', wght=800) + P['bloom'] = 0.7 + else: # vortex climax + c = climax(f, 56, 63) + plate(E, f, 'ink', grid=False) + speedlines(E, f, 0.4 + 0.6 * c, a(GLUE, 0.8), n=110, seed=11) + screen_cam(E, f, zoom=1 + 0.9 * c + 0.03 * f.kick, rot=-0.08 * c, shake=4 + 16 * c) + balls = [(960, 540, 120 + 140 * c)] + for j in range(48): + ang = j * 2.399 + f.lt * (0.8 + 7 * c) + R = (220 + 700 * rnd('vx', j)) * (1 - 0.85 * c) + balls.append((960 + math.cos(ang) * R * 1.5, 540 + math.sin(ang) * R * 0.85, 26 + 10 * rnd('vr', j))) + goo(E, balls, k=50 + 60 * c, glow=0.25 + 0.5 * c) + E.cam = (LW / 2, LH / 2, 1.0 + 0.04 * f.kick + 0.1 * c, 0.0) + E.text('ONE SERVER.', 960, 200, 150, BONE, wght=900, wdth=100 + 25 * c) + E.text('EVERY APP.', 960, 900, 150, NEON, wght=900, wdth=100 + 25 * c) + P['ca'] = 0.5 + 2.0 * c + P['bloom'] = 0.6 + 0.4 * c + return P diff --git a/intro-video/hype/sc_open.py b/intro-video/hype/sc_open.py new file mode 100644 index 00000000..44fd8daa --- /dev/null +++ b/intro-video/hype/sc_open.py @@ -0,0 +1,197 @@ +"""Bars 0-16: the cold open (a prompt that the screen obeys) and the first build-up.""" +import math +from anim import * +from kit import * +from devices import * +from engine import LW, LH, hexc + +CLEAR_C = hexc('#0b2412') + + +def cold_open(E, f): + """Bars 0-8. GL calls typed at a prompt; each one is executed by the frame itself.""" + lb = f.lbeat + P = {'bloom': 0.7, 'grain': 0.032, 'vignette': 0.45} + bg(E, INK) + # glClear: flood from the prompt + if lb >= 8: + u = out_expo((lb - 8) / 1.2) + E.circle(150, 930, 2400 * u, CLEAR_C) + dot_grid(E, f, a(MOSS, 0.55 * u), 60, 2.2) + cmds = [ # text, type start, type end, execute beat + ('glClearColor(0.04, 0.14, 0.07, 1.0);', 1.0, 3.6, 4.0), + ('glClear(GL_COLOR_BUFFER_BIT);', 4.5, 7.0, 8.0), + ('glDrawArrays(GL_TRIANGLES, 0, 3);', 9.0, 11.6, 12.0), + ('glUseProgram(glue);', 16.0, 18.0, 20.0), + ('// ...and now: run it on everything', 24.0, 27.5, 99.0), + ] + # centre visual: triangle -> glue drop + cx, cy = 960, 470 + if 12 <= lb < 22: + u = out_back((lb - 12) / 0.9, 2.2) + melt = in_out_cubic((lb - 20) / 2.0) + R = 300 * u * (1 - 0.85 * melt) + rot = 0.05 * (lb - 12) + 0.25 * melt + pts = [(cx + R * math.cos(rot - math.pi / 2 + k * 2 * math.pi / 3), + cy + 30 + R * math.sin(rot - math.pi / 2 + k * 2 * math.pi / 3)) for k in range(3)] + hp = 1 + 0.25 * f.hat + if melt < 1: + tri_rgb(E, pts, [scale_rgb(GLUE, 2.2 * hp), scale_rgb(PALE, 1.0), scale_rgb(MOSS, 1.3)]) + if melt > 0: # vertices turn into glue and run to the centre + balls = [(lerp(px, cx, melt), lerp(py, cy + 30, melt), 30 + 120 * melt) for px, py in pts] + goo(E, balls, k=90, outline=6) + elif 22 <= lb: + bob = math.sin(f.lt * 3.0) * 8 + stretch = 0.0 + r = 150 + 10 * f.hat + if lb >= 24: + stretch = 260 * in_cubic((lb - 24) / 7.5) + balls = [(cx, cy + 30 + bob, r)] + caps = [(cx, cy + 30 + bob - stretch - 40, cx, cy + 30 + bob, r * 0.25, r * 0.95)] if stretch > 1 else [] + z = 1 + 9 * in_expo((lb - 27) / 4.6) + E.cam = (cx, cy + 30 + bob, z, 0.0) + goo(E, balls, caps, k=80, outline=7, glow=0.2 * clamp01((lb - 24) / 8)) + E.cam = (LW / 2, LH / 2, 1.0, 0.0) + if lb > 30.6: + bg(E, a(GLUE, clamp01((lb - 30.6) / 0.6))) + # prompt + log + py = 930 + done = [c for c in cmds if lb >= c[2]] + cur = [c for c in cmds if c[1] <= lb < c[2] or (lb >= c[2] and lb < c[3])] + alpha_ui = 1.0 - clamp01((lb - 29) / 1.5) + for i, c in enumerate(reversed([c for c in cmds if lb >= c[3]])): + code_line(E, c[0], 150, py - 56 * (i + 1) - 20, 28, alpha=(0.55 - 0.1 * i) * alpha_ui) + active = None + for c in cmds: + if c[1] <= lb < c[3] and lb < 99: + active = c + E.text('>', 112, py, 30, a(GLUE, alpha_ui), font='mono', wght=700, align='left') + if active: + n = len(typed(f, active[0], active[1], active[2])) + code_line(E, active[0], 150, py, 30, chars=n, cursor=True, f=f, alpha=alpha_ui) + else: + code_line(E, '', 150, py, 30, chars=0, cursor=True, f=f, alpha=alpha_ui) + # execute flashes + for c in cmds: + u = lb - c[3] + if 0 <= u < 0.5: + E.rect(LW / 2, LH / 2, LW, LH, a(GLUE, 0.025 * (1 - u / 0.5))) + hud(E, f, 'MOBILEGL // REEL', 'OPENGL 4.6 CORE', alpha=clamp01(lb / 4) * alpha_ui) + if lb > 30.6: + P['exposure'] = 1.0 + 0.6 * clamp01((lb - 30.6) / 1.4) + return P + + +def your_app(E, f): + """Bars 8-16: your app speaks GL 4.6 -> your device doesn't -> so we made the glue -> countdown.""" + lb = f.lbeat + P = {'bloom': 0.5, 'grain': 0.035} + tb_ = lambda b: f.t - (lb - b) * S.BEAT # absolute time of local beat b + if lb < 8: + plate(E, f, 'mint', drift=(0, -12)) + E.cam = (LW / 2, LH / 2, 1.0 + 0.004 * lb, -0.004 * lb) + x0 = 130 + w = slam(E, f, 'YOUR', x0, 270, tb_(0), 210, INK, align='left') + slam(E, f, 'APP', x0 + w + 50, 270, tb_(1), 210, INK, align='left') + slam(E, f, 'SPEAKS', x0, 500, tb_(2), 210, INK, align='left') + w = slam(E, f, 'OPENGL', x0, 730, tb_(3), 210, INK, align='left') + slam(E, f, '4.6', x0 + w + 50, 730, tb_(3) + 0.06, 210, MOSS, align='left', wdth=80) + chips_ = ['#version 460 core', 'glBindVertexArray', 'glDrawElementsInstanced', 'GL_ARB_direct_state_access', + 'glMultiDrawElementsIndirect', 'compute shaders', 'SSBOs', 'tessellation'] + pos = [(1460, 190), (1500, 330), (1420, 470), (900, 900), (1430, 610), (420, 905), (1650, 905), (1300, 905)] + for i, s in enumerate(chips_): + bi = 4 + i * 0.5 + if lb >= bi: + u = out_back((lb - bi) / 0.35) + chip(E, pos[i][0], pos[i][1], s, 30, BONE, INK, sc=u, rot=0.03 * math.sin(i * 2.3)) + E.cam = (LW / 2, LH / 2, 1.0, 0.0) + elif lb < 16: + plate(E, f, 'ink') + w = slam(E, f, 'YOUR', 130, 190, tb_(8), 150, BONE, align='left') + w2 = slam(E, f, 'DEVICE', 130 + w + 40, 190, tb_(8.5), 150, BONE, align='left') + if lb >= 15: + stamp(E, f, "DOESN'T.", 960, 560, tb_(15), 210, CORAL, rot=-0.09, w_pad=50) + if lb >= 15: + P['shake'] = 1 + screen_cam(E, f, shake=10 * math.exp(-(lb - 15) * 3)) + devs = [('phone', 400, 'Android', 'OpenGL ES + Vulkan only'), ('laptop', 960, 'macOS', 'OpenGL 4.1, deprecated'), + ('board', 1520, 'ARM Linux', 'GLES-only vendor drivers')] + for i, (kind, x, name, sub) in enumerate(devs): + bi = 9 + i + if lb < bi: + continue + u = out_back((lb - bi) / 0.4) + iso = Iso(x, 760 + 40 * (1 - u), 34 * u) + if kind == 'phone': + phone(E, iso, f) + elif kind == 'laptop': + laptop(E, iso, f, y=2.0) + else: + board(E, iso, f) + E.text(name, x, 880, 44, a(BONE, u), wght=800) + E.text(sub, x, 935, 24, a(BONE, 0.65 * u), font='mono', wght=500) + ci = 12 + i + if lb >= ci: + cross(E, x, 600, 120, CORAL, 26, (lb - ci) / 0.25) + E.cam = (LW / 2, LH / 2, 1.0, 0.0) + E.shake = (0.0, 0.0) + elif lb < 24: + plate(E, f, 'mint', drift=(10, 0)) + slam(E, f, 'SO WE MADE', 960, 230, tb_(16), 120, INK, wdth=110) + size = fit_size(E, 'THE GLUE.', 1640) + if lb >= 18: + slam(E, f, 'THE GLUE.', 960, 470, tb_(18), size, INK, punch=0.25) + top = 470 - size * 0.36 # cap top + # a drop falls onto the words at beat 19 and drips over them + if lb >= 18.2: + fall = clamp01((lb - 18.2) / 0.8) + X = 1290 + y = lerp(-150, top - 30, in_cubic(fall)) + if fall < 1: + b, c = drop_shape(X, y, 56, 90 * fall) + goo(E, b, c, k=50) + else: + spread = out_expo((lb - 19) / 0.6) + balls = [(X, top - 18, 46 * (1 - 0.4 * spread))] + caps = [(X - 250 * spread, top - 6, X + 230 * spread, top - 6, 14, 14)] + for k, (dx, ln) in enumerate(((-230, 70), (-120, 150), (-20, 60), (80, 190), (190, 90))): + L = ln * out_cubic((lb - 19 - 0.12 * k) / 3.0) + x = X + dx * spread + if L > 2: + caps.append((x, top - 6, x, top - 6 + L, 10, 13)) + balls.append((x, top - 6 + L, 17)) + goo(E, balls, caps, k=34, outline=5, bevel=22) + # the crossed devices come back with checks + for i, (name, x) in enumerate((('Android', 560), ('macOS', 960), ('ARM Linux', 1360))): + bi = 20 + i + if lb < bi: + continue + u = out_back((lb - bi) / 0.3) + E.circle(x, 780, 80 * u, INK) + check(E, x, 780, 70 * u, GLUE, 14, (lb - bi - 0.1) / 0.3) + E.text(name, x, 910, 40, a(INK, u), wght=800) + E.line(x, 690, x, 600, 8, a(LEAF, u)) + else: + # bars 14-15: loading bar, then 3-2-1, then one beat of silence + if lb < 28: + plate(E, f, 'ink') + u = clamp01((lb - 24) / 3.9) + k = int(u * 32) + E.text('LOADING DESKTOP OPENGL', 960, 420, 64, BONE, wght=800, wdth=110) + for i in range(32): + c = NEON if i < k else a(MOSS, 0.8) + E.rect(960 - 31 * 26 / 2 + i * 26, 540, 18, 54, c, r=3) + E.text(f'{int(u * 100):3d}%', 960, 640, 40, GLUE, font='mono', wght=700) + E.text('frontend · pipe · backend · window system', 960, 720, 24, a(BONE, 0.5), font='mono') + elif lb < 31: + i = int(lb - 28) + kind = ['glue', 'ink', 'mint'][i] + b, fg = plate(E, f, kind, grid=False) + slam(E, f, str(3 - i), 960, 560, tb_(28 + i), 760, fg if kind != 'ink' else NEON, punch=0.35, wght=900, + wdth=110) + P['shake'] = 1 + screen_cam(E, f, shake=14 * math.exp(-(lb - 28 - i) * 5), seed=i) + else: # the silent beat: the block falls into the glue on the downbeat + pre_drop(E, f, 960, 330, 165, tb_(32)) + P['grain'] = 0.05 + return P diff --git a/intro-video/hype/sc_pipe.py b/intro-video/hype/sc_pipe.py new file mode 100644 index 00000000..5dba8e30 --- /dev/null +++ b/intro-video/hype/sc_pipe.py @@ -0,0 +1,126 @@ +"""Bars 48-56: MGPipe over the snare roll. Only what changed, no round trips, deduplicated.""" +import math +from anim import * +from kit import * +from devices import * +from engine import LW, LH, hexc + +ROLLS = [t for t, _ in S.events('roll') if S.tb(48) <= t < S.tb(56)] +LABELS = ['BIND', 'STATE Δ', 'DRAW', 'UNIFORM', 'CLEAR', 'DRAW', 'STATE Δ', 'BLIT', 'DRAW', 'FENCE'] + + +def tube(E, f, y, x0=330, x1=1590, rate_scale=1.0): + E.rect((x0 + x1) / 2, y, x1 - x0, 96, INK2, r=48, stroke=a(GLUE, 0.6), sw=4) + speed = 900.0 + for k, ts in enumerate(ROLLS): + if ts > f.t: + break + x = x0 + 40 + speed * (f.t - ts) + if x > x1 - 40: + continue + lab = LABELS[k % len(LABELS)] + w = E.measure(lab, 'mono', 20, 800) + 26 + E.rect(x, y, w, 46, GLUE if 'Δ' not in lab else NEON, r=23) + E.text(lab, x, y, 20, INK, font='mono', wght=800) + + +def card(E, x, y, title, sub, col=GLUE, fg=INK, w=300, h=200): + E.rect(x, y, w, h, col, r=26) + E.text(title, x, y - 18, 44, fg, wght=900) + E.text(sub, x, y + 40, 20, a(fg, 0.7), font='mono', wght=600) + + +ROWS = [('BLEND', 'off', 'on'), ('DEPTH_TEST', 'on', 'on'), ('VIEWPORT', '1920×1080', '1920×1080'), + ('PROGRAM', '12', '12'), ('TEX0', '2D:7', '2D:9'), ('CULL_FACE', 'back', 'back'), ('VAO', '3', '3'), + ('SCISSOR', 'off', 'off'), ('UBO0', '5', '5'), ('STENCIL', 'off', 'off')] + + +def pipe(E, f): + lb = f.lbeat + P = {'bloom': 0.55, 'grain': 0.035, 'ca': 0.2 + 0.6 * f.roll * clamp01(lb / 24)} + tb_ = lambda b: f.t - (lb - b) * S.BEAT + zoom = 1 + 0.06 * clamp01(lb / 31) + 0.01 * f.roll + if lb < 8: + plate(E, f, 'ink', drift=(-60, 0)) + screen_cam(E, f, zoom=zoom) + slam(E, f, 'MGPIPE', 960, 300, tb_(0), 260, BONE, wdth=120) + if lb >= 1: + E.text('the stream between frontend and backend', 960, 450, 32, a(GLUE, clamp01(lb - 1)), font='mono', + wght=600) + card(E, 200, 720, 'FRONT', 'your process', GLUE) + card(E, 1720, 720, 'BACK', 'owns the GPU', MINT) + tube(E, f, 720) + elif lb < 16: + plate(E, f, 'mint') + screen_cam(E, f, zoom=zoom) + slam(E, f, 'ONLY WHAT CHANGED.', 960, 150, tb_(8), 120, INK, wdth=105) + beat = int(lb - 8) + changed = {0: [0], 1: [4], 2: [0, 6], 3: [9], 4: [2], 5: [4, 7], 6: [1], 7: [3, 0]} + live = changed.get(beat, []) + for i, (name, v0, v1) in enumerate(ROWS): + y = 290 + i * 70 + hot = i in live + E.rect(470, y, 640, 58, INK if hot else a(PALE, 0.9), r=10) + E.text(name, 170, y, 26, GLUE if hot else INK, font='mono', wght=700, align='left') + val = (v1 if (beat + i) % 2 else v0) if hot else v0 + E.text(val, 770, y, 26, NEON if hot else a(INK, 0.6), font='mono', wght=700, align='right') + if hot: + u = (lb % 1) / 0.8 + x = lerp(800, 1500, in_out_cubic(u)) + yy = lerp(y, 620, in_out_cubic(u)) + if u < 1: + chip(E, x, yy, f'{name}={val}', 22, INK, GLUE) + E.rect(1600, 620, 420, 300, INK, r=26) + E.text('SENT', 1600, 520, 30, GLUE, font='mono', wght=800) + E.text(f'{len(live)}', 1600, 630, 150, NEON, wght=900) + E.text(f'of {len(ROWS)} fields', 1600, 730, 26, a(BONE, 0.7), font='mono') + elif lb < 24: + plate(E, f, 'ink') + screen_cam(E, f, zoom=zoom) + slam(E, f, 'NO ROUND TRIPS.', 960, 160, tb_(16), 130, BONE, wdth=105) + card(E, 380, 560, 'CLIENT', 'frontend', GLUE, w=420, h=260) + card(E, 1540, 560, 'SERVER', 'backend', MINT, w=420, h=260) + code_line(E, 'glGenBuffers(1, &buf);', 170, 780, 32, chars=int(clamp01((lb - 16.3) / 1.2) * 22)) + if lb >= 18: + u = out_back((lb - 18) / 0.35) + chip(E, 380, 880, 'buf = { slot: 17, gen: 3 }', 30, INK, PALE, sc=u) + E.text('minted on the client, instantly', 380, 960, 24, a(BONE, 0.7 * u), font='mono') + if lb >= 19: + arrow(E, 610, 520, 1310, 520, 10, GLUE, u=(lb - 19) / 0.5) + E.text('records →', 960, 470, 26, GLUE, font='mono', wght=700) + if lb >= 20: + u = clamp01((lb - 20) / 0.5) + for k in range(12): + x0 = 1310 - k * 60 + if (1310 - x0) / 700 < u: + E.line(x0, 620, x0 - 30, 620, 8, a(BONE, 0.4)) + E.poly([(600, 620), (640, 600), (640, 640)], a(BONE, 0.4)) + if lb >= 21: + cross(E, 960, 620, 80, CORAL, 18, (lb - 21) / 0.3) + E.text('waiting for a reply', 960, 700, 26, a(CORAL, 0.9), font='mono', wght=700) + elif lb < 28: + plate(E, f, 'glue', grid=False) + screen_cam(E, f, zoom=zoom) + slam(E, f, 'DEDUPLICATED.', 960, 180, tb_(24), 150, INK, wdth=105) + u = in_out_cubic((lb - 25) / 1.6) + for k in range(6): + ang = k * math.pi / 3 + 0.4 + x = 960 + math.cos(ang) * 520 * (1 - u) + y = 640 + math.sin(ang) * 260 * (1 - u) + E.rect(x, y, 360, 220, INK, r=24, rot=0.1 * math.sin(k) * (1 - u)) + for L in range(3): + E.rect(x - 60 + 20 * (L % 2), y - 50 + L * 50, 220 - 40 * (L % 2), 22, a(GLUE, 0.8), r=8, + rot=0.1 * math.sin(k) * (1 - u)) + if lb >= 26.6: + chip(E, 1140, 520, '×6 → 1', 40, INK, PALE, sc=out_back((lb - 26.6) / 0.3), font='archivo', wght=900) + elif lb < 31: + words = ['RECORD', 'DIFF', 'DEDUPE', 'SEND', 'RECORD', 'DIFF'] + k = min(int((lb - 28) * 2), 5) + kind = ['ink', 'ink', 'glue', 'glue', 'mint', 'mint'][k] + b, fg = plate(E, f, kind, grid=False) + slam(E, f, words[k], 960, 560, tb_(28 + k * 0.5), fit_size(E, words[k], 1600, cap=520), fg if kind != 'ink' else NEON, + punch=0.2) + P['ca'] = 1.0 + 0.3 * k + else: + bg(E, hexc('#000000')) + return P diff --git a/intro-video/hype/scenes.py b/intro-video/hype/scenes.py new file mode 100644 index 00000000..4dfb9360 --- /dev/null +++ b/intro-video/hype/scenes.py @@ -0,0 +1,74 @@ +"""Timeline: which scene draws which bars, plus the global beat-driven post and the stutter glitch.""" +import math +import importlib +from anim import * +from engine import LW, LH + +# (first bar, end bar, module, function) +TIMELINE = [ + (0, 8, 'sc_open', 'cold_open'), + (8, 16, 'sc_open', 'your_app'), + (16, 32, 'sc_drop1', 'drop1'), + (32, 48, 'sc_how', 'how'), + (48, 56, 'sc_pipe', 'pipe'), + (56, 72, 'sc_modes', 'modes'), + (72, 80, 'sc_break', 'breakdown'), + (80, 88, 'sc_build3', 'checklist'), + (88, 104, 'sc_final', 'final_drop'), + (104, 200, 'sc_final', 'outro'), +] +_fns = {} + + +def _fn(mod, name): + key = (mod, name) + if key not in _fns: + try: + _fns[key] = getattr(importlib.import_module(mod), name) + except (ImportError, AttributeError): + _fns[key] = None + return _fns[key] + + +STUTTER = [t for t, _ in S.events('stutter')] +PATTERN = [2, 2, 1, 1, 0.5, 0.5, 0.5, 0.5, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25, 0.25] + + +def stutter_remap(t): + """Same slicing as the music's stutter: inside the beat, show the first 8th again and again.""" + for ts in STUTTER: + if ts <= t < ts + S.BEAT: + u = t - ts + p = 0.0 + for k, s in enumerate(PATTERN): + d = s * S.S16 + if u < p + d: + return ts + (u - p), k + p += d + return t, -1 + + +def draw(E, t): + t_src, sk = stutter_remap(t) + bar = t_src / S.BAR + entry = None + for e in TIMELINE: + if e[0] <= bar < e[1]: + entry = e + break + E.begin_sub(INK) + P = {} + if entry: + fn = _fn(entry[2], entry[3]) + f = Frame(t_src, entry[0]) + if fn: + P = fn(E, f) or {} + else: + E.rect(LW / 2, LH / 2, LW, LH, INK) + E.text(f'{entry[2]}.{entry[3]} bar {bar:.2f}', LW / 2, LH / 2, 60, BONE, font='mono') + E.flush() + if sk >= 0: # glitch dressing on the repeats + P = dict(P) + P['ca'] = P.get('ca', 0) + 3.0 + sk * 0.3 + P['scan'] = 0.25 + return P diff --git a/intro-video/hype/score.py b/intro-video/hype/score.py new file mode 100644 index 00000000..ec84c08f --- /dev/null +++ b/intro-video/hype/score.py @@ -0,0 +1,217 @@ +"""The arrangement both the music and the picture are cut to: tempo grid, sections, harmony, melodies +and every drum / fx event. The video reads the same events for its beat pulses, so they never drift.""" +import math + +BPM = 128.0 +BEAT = 60.0 / BPM # 0.46875 s +BAR = 4 * BEAT # 1.875 s +S16 = BEAT / 4 +NBARS = 112 +END = NBARS * BAR + 3.5 # music + reverb tail (213.5 s) + +SECTIONS = [ # name, first bar, end bar + ('intro', 0, 8), ('build1', 8, 16), ('drop1', 16, 32), ('verse', 32, 48), ('build2', 48, 56), + ('drop2', 56, 72), ('break', 72, 80), ('build3', 80, 88), ('drop3', 88, 104), ('outro', 104, 112), +] + + +def tb(bar, beat=0.0, s16=0.0): + """Time of bar + beat + sixteenth, in seconds.""" + return bar * BAR + beat * BEAT + s16 * S16 + + +def section(bar): + for name, a, b in SECTIONS: + if a <= bar < b: + return name, a, b + return 'end', NBARS, NBARS + 4 + + +def key_shift(bar): + return 2 if bar >= 88 else 0 # truck-driver lift into the last drop + + +# --- harmony --------------------------------------------------------------------------------------- +A, F, C, G, Em = 'Am', 'F', 'C', 'G', 'Em' +CHORDS = { # close voicings around middle C (midi), root for the bass + 'Am': ((57, 60, 64), 45), 'F': ((57, 60, 65), 41), 'C': ((55, 60, 64), 48), + 'G': ((55, 59, 62), 43), 'Em': ((55, 59, 64), 40), 'Dm': ((57, 62, 65), 38), +} +MAIN = [A, F, C, G] +VERSE = [F, G, Em, A] + + +def chord_name(bar): + name, a, _ = section(bar) + i = bar - a + if name == 'verse': + return VERSE[i % 4] + if name == 'break': + return [F, F, G, G, A, A, F, G][i] + if name == 'outro': + return [A, F, C, G, C, C, C, C][i] + if name == 'end': + return C + return MAIN[i % 4] + + +def chord(bar): + """(voicing, bass root) for the bar, transposed by the key lift.""" + v, r = CHORDS[chord_name(bar)] + k = key_shift(bar) + return tuple(n + k for n in v), r + k + + +# --- melodies (16th-step grid: (step, length, midi)) ------------------------------------------------ +HOOK = [ + [(0, 2, 76), (3, 1, 76), (4, 2, 74), (6, 2, 76), (8, 3, 81), (11, 2, 79), (14, 2, 76)], # Am + [(0, 2, 77), (3, 1, 76), (4, 2, 74), (6, 4, 72), (10, 2, 74), (12, 4, 76)], # F + [(0, 2, 79), (3, 1, 79), (4, 2, 77), (6, 2, 76), (8, 3, 76), (11, 2, 72), (14, 2, 74)], # C + [(0, 6, 74), (6, 2, 71), (8, 4, 74), (12, 2, 76), (14, 2, 79)], # G +] +HOOK_END = [(0, 3, 74), (3, 3, 71), (6, 2, 74), (8, 8, 69)] # G, resolving + + +def hook_bar(i): + """Bar i (0..7) of the 8-bar lead phrase.""" + return HOOK_END if i % 8 == 7 else HOOK[i % 4] + + +def notes_in(bar, pattern, shift=0): + k = key_shift(bar) + return [(tb(bar, 0, s), l * S16, m + k + shift) for s, l, m in pattern] + + +# --- events ---------------------------------------------------------------------------------------- +def _events(): + ev = [] # (time, kind, vel) + + def add(t, kind, vel=1.0): + ev.append((t, kind, vel)) + + for bar in range(NBARS): + name, a, b = section(bar) + i = bar - a + last = bar == b - 1 + if name == 'intro': + if i >= 4: + for s in range(2, 16, 4): + add(tb(bar, 0, s), 'hat', 0.5 + 0.1 * (i - 4)) + if i == 0: + add(tb(bar), 'impact', 0.5) + elif name == 'build1': + for q in range(4): + if not (last and q == 3): + add(tb(bar, q), 'kick', 0.7 if i < 6 else 0.78) + add(tb(bar, q, 2), 'hat', 0.7) + for q in (1, 3): + if not (last and q == 3): + add(tb(bar, q), 'clap', 0.65) + elif name in ('drop1', 'drop2', 'drop3'): + hot = {'drop1': 1.0, 'drop2': 1.0, 'drop3': 1.0}[name] + fill = last or i == 7 + for q in range(4): + add(tb(bar, q), 'kick', hot) + add(tb(bar, q, 2), 'ohat', 0.8) + for q in (1, 3): + add(tb(bar, q), 'clap', 1.0) + for s in range(16): + if s % 4 != 2: + add(tb(bar, 0, s), 'hat', 0.45 + 0.25 * (s % 2 == 0)) + if i % 8 == 0: + add(tb(bar), 'crash', 1.0) + if i == 14: # climb: 8th-note snares, then 16ths in the last bar + for s in range(0, 16, 2): + add(tb(bar, 0, s), 'snare', 0.35 + 0.02 * s) + elif last: + for s in range(12): + add(tb(bar, 0, s), 'snare', 0.55 + 0.035 * s) + elif fill: + for s in (12, 13, 14, 15): + add(tb(bar, 0, s), 'snare', 0.6 + 0.1 * (s - 12)) + if last: + add(tb(bar, 3), 'stutter', 1.0) + elif name == 'verse': + for q in range(4): + add(tb(bar, q), 'kick', 0.8) + for q in (1, 3): + add(tb(bar, q), 'clap', 0.75) + for s in range(16): + add(tb(bar, 0, s), 'shaker', 0.5 + 0.3 * (s % 4 == 2)) + if i % 4 == 3: + add(tb(bar, 3, 2), 'snare', 0.5) + add(tb(bar, 3, 3), 'snare', 0.6) + if i == 8: + add(tb(bar), 'crash', 0.7) + elif name in ('build2', 'build3'): + n = 8 + for q in range(4): + if i < 6: + add(tb(bar, q), 'kick', 0.7) + # snare roll: quarters -> 8ths -> 16ths -> 32nds, rising + div = 1 if i < 2 else 2 if i < 4 else 4 if i < 6 else 8 + steps = 4 * div + for s in range(steps): + if last and s >= steps * 3 // 4: + break # one beat of silence before the drop + prog = (i + s / steps) / n + add(tb(bar, s * 4 / steps), 'roll', 0.35 + 0.65 * prog) + if i == 0: + add(tb(bar), 'crash', 0.8) + elif name == 'break': + if i == 0: + add(tb(bar), 'impact', 0.8) + if i >= 4: + for q in (0, 2): + add(tb(bar, q), 'heart', 0.5 + 0.1 * (i - 4)) + elif name == 'outro': + if i < 4: + for q in range(4): + add(tb(bar, q), 'kick', 1.0 - 0.15 * i) + add(tb(bar, q, 2), 'ohat', 0.7 - 0.1 * i) + for q in (1, 3): + add(tb(bar, q), 'clap', 0.9 - 0.15 * i) + if i == 0: + add(tb(bar), 'crash', 1.0) + if i == 4: + add(tb(bar), 'impact', 1.0) + add(tb(bar), 'crash', 1.0) + add(tb(bar), 'kick', 1.0) + for b in (16, 56, 88): # drops: impact + reverse swell into them + add(tb(b), 'impact', 1.0) + add(tb(b), 'drop', 1.0) + for a, b in ((4, 8), (12, 16), (48, 56), (80, 88), (30, 32), (70, 72), (102, 104)): + add(tb(a), 'riser', tb(b) - tb(a)) + return sorted(ev) + + +EVENTS = _events() + + +def events(kind): + return [(t, v) for t, k, v in EVENTS if k == kind] + + +def pulse(t, kind, decay=0.12, kinds_cache={}): + """Exponentially decaying pulse from the most recent event of kind (0..1 * vel).""" + ts = kinds_cache.get(kind) + if ts is None: + ts = kinds_cache[kind] = events(kind) + best = 0.0 + lo, hi = 0, len(ts) + while lo < hi: # last event <= t + mid = (lo + hi) // 2 + if ts[mid][0] <= t: + lo = mid + 1 + else: + hi = mid + if lo: + te, v = ts[lo - 1] + best = v * math.exp(-(t - te) / decay) + return best + + +if __name__ == '__main__': + print(f'{NBARS} bars, {NBARS * BAR:.2f}s + tail -> {END:.2f}s, {len(EVENTS)} events') + for name, a, b in SECTIONS: + print(f'{name:8s} bars {a:3d}-{b:3d} {tb(a):7.2f}s - {tb(b):7.2f}s') diff --git a/intro-video/hype/shaders/block.glsl b/intro-video/hype/shaders/block.glsl new file mode 100644 index 00000000..60c0525d --- /dev/null +++ b/intro-video/hype/shaders/block.glsl @@ -0,0 +1,245 @@ +#version 430 +#include "common.glsl" +// The MobileGlues mark, alive: a crafting-table / command-block block floating in a blob of glue +// (a glue pool around its lower half, tongues of glue rising behind it, a few loose drops), raymarched +// under an orthographic isometric camera. +in vec2 v_uv; +out vec4 o; +uniform vec2 u_res; +uniform vec4 u_cubes[16]; // screen x, y, size (half-edge px), yaw +uniform vec4 u_cubes2[16]; // squash, wobble, tint mix (0 green .. 1 alt), alpha +uniform vec4 u_cubes3[16]; // seconds since the block hit the glue (big = settled) +uniform int u_n; +uniform float u_time; +uniform vec3 u_body; // glue colour (linear) +uniform vec3 u_alt; // alternative block tint +uniform vec3 u_deep; // deep glue colour +uniform float u_speckle; // visibility of the items on the block top +uniform float u_pitch; + +vec4 C1, C2, C3; +float seed; +float sd_rbox(vec3 p, vec3 b, float r){ vec3 q = abs(p) - b + r; return length(max(q, 0.0)) + min(max(q.x, max(q.y, q.z)), 0.0) - r; } +float sd_cap(vec3 p, vec3 a, vec3 b, float r){ vec3 pa = p - a, ba = b - a; float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0); return length(pa - ba * h) - r; } +float smin(float a, float b, float k){ float h = max(k - abs(a - b), 0.0) / k; return min(a, b) - h * h * k * 0.25; } +float smax(float a, float b, float k){ float h = max(k - abs(a - b), 0.0) / k; return max(a, b) + h * h * k * 0.25; } + +// C3.x = seconds since the block hit the glue (negative: still falling, large: settled) +float since(){ return C3.x; } +float splash(){ return since() < 0.0 ? 0.0 : exp(-since() * 2.6); } +float bob(){ + float u = since(); + float rest = 0.06 * sin(u_time * 2.2 + seed * 3.0) - C2.x * 0.5; + if (u < 0.0) return 0.25 + 22.0 * u * u; // falling in + return rest * (1.0 - exp(-u * 2.0)) - 0.42 * exp(-u * 4.5) * cos(u * 11.0); // plunge, bounce, settle +} + +vec3 deform(vec3 p){ + float wob = C2.y; + return p + wob * 0.04 * vec3(sin(3.1 * p.y + u_time * 9.0), 0.0, sin(2.9 * p.x + u_time * 8.0)); +} +float d_block(vec3 p){ return sd_rbox(p - vec3(0.0, bob(), 0.0), vec3(0.78), 0.07); } + +// glue level around the block: a wavy line about half-way up its sides +float level(vec3 p){ + float a = atan(p.z, p.x); + return -0.12 + 0.13 * sin(a * 5.0 + u_time * 1.4 + seed) + 0.07 * sin(a * 9.0 - u_time * 1.1) + 0.25 * splash(); +} +float d_glue(vec3 p){ + float sq = C2.x, u = since(), sp = splash(); + // the pool: spreads and ripples outward from the impact + float r = length(p.xz); + float ripple = sp * 0.09 * sin(9.0 * r - 16.0 * u) * smoothstep(1.9, 0.6, r); + float wide = 1.5 * (1.0 + 0.12 * sp * sin(u * 12.0)); + float th = atan(p.z, p.x); + float R = wide * (1.0 + 0.08 * sin(3.0 * th + seed + u_time * 0.7) + 0.05 * sin(5.0 * th - u_time * 0.9 + seed * 2.0)); + float hh = 0.2 * (1.0 - 0.5 * sq) * (1.0 - 0.35 * sp); + vec2 dq = vec2(r - (R - 0.3), abs(p.y + 0.78 - ripple) - hh + 0.0); // a pillow: round in plan, soft rim + float pool = length(max(dq, 0.0)) + min(max(dq.x, dq.y), 0.0) - 0.3; + float g = pool; + // before the hit: a fat, wobbling blob of glue waiting in the middle; the block squashes it flat + float mound = u < 0.0 ? 1.0 : exp(-u * 10.0); + if (mound > 0.01) { + vec3 m = p - vec3(0.0, -0.55, 0.0); + float w = 1.0 + 0.06 * sin(u_time * 5.0); + vec3 rad = vec3(1.2 * w, 0.85 * mound / w + 0.01, 1.2 * w); + float e = (length(m / rad) - 1.0) * min(min(rad.x, rad.y), rad.z); + g = smin(g, e, 0.3); + } + // a coat that hugs the block up to the wavy level (only once the block is in) + if (u >= 0.0) { + vec3 pb = p - vec3(0.0, bob(), 0.0); + float coat = smax(sd_rbox(pb, vec3(0.86), 0.32), pb.y - level(pb), 0.18); + coat += (1.0 - smoothstep(0.0, 0.12, u)) * 0.6; + g = smin(g, coat, 0.35); + } + // tongues erupt behind the block on impact, overshoot, then sway; their drops are flung up and hover + float erupt = u < 0.0 ? 0.0 : max(0.0, 1.0 - exp(-u * 5.0) + 0.55 * exp(-u * 3.0) * sin(u * 10.0)); + if (erupt > 0.0) { + for (int i = 0; i < 5; i++){ + float fi = float(i); + float ang = 3.93 - C1.w + (fi - 2.0) * 0.48; // spread around the back (relative to the view) + vec2 xz = vec2(cos(ang), sin(ang)) * (1.18 + 0.12 * sin(fi * 2.1)); + float lag = clamp(u * 6.0 - fi * 0.35, 0.0, 1.0); + float top = ((0.55 + 0.45 * fract(sin(fi * 17.3 + seed) * 4375.5)) * (1.0 - 0.6 * sq) + + 0.08 * sin(u_time * 2.0 + fi * 1.7) + 0.7) * erupt * lag - 0.7; + float rr = (0.24 + 0.08 * fract(sin(fi * 9.1 + seed) * 1735.3)) * min(1.0, 0.3 + lag); + g = smin(g, sd_cap(p, vec3(xz.x, -0.7, xz.y), vec3(xz.x, top, xz.y), rr), 0.25); + float fly = 1.6 * exp(-u * 1.7) * max(sin(u * 4.0), -0.2); + float dy = top + 0.45 + fly + 0.12 * sin(u_time * 2.6 + fi * 2.3); + g = min(g, length(p - vec3(xz.x * 1.05, dy, xz.y * 1.05)) - rr * 0.45 * min(1.0, u * 4.0)); + } + } + // the splash itself: droplets thrown out radially, falling back + if (u >= 0.0 && u < 0.9) { + for (int i = 0; i < 10; i++){ + float fi = float(i); + float a = fi * 0.628 + 0.3; + float sp2 = 2.6 + 1.2 * fract(sin(fi * 31.7) * 943.1); + vec3 c = vec3(cos(a) * (1.0 + sp2 * u), -0.35 + 4.6 * u - 8.5 * u * u, sin(a) * (1.0 + sp2 * u)); + g = min(g, length(p - c) - 0.13 * (1.0 - u / 0.9)); + } + } + return g; +} +float map(vec3 p){ p = deform(p); return min(d_block(p), d_glue(p)) * 0.7; } +float map_block(vec3 p){ return d_block(deform(p)) * 0.8; } +float map_glue(vec3 p){ return d_glue(deform(p)) * 0.7; } +vec3 nrm(vec3 p){ vec2 e = vec2(1e-3, 0); return normalize(vec3(map(p + e.xyy) - map(p - e.xyy), map(p + e.yxy) - map(p - e.yxy), map(p + e.yyx) - map(p - e.yyx))); } + +float h2(vec2 p){ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); } + +vec3 block_albedo(vec3 q, vec3 n){ + vec3 base = mix(vec3(0.16, 0.5, 0.1), u_alt, C2.z); + vec3 an = abs(n); + vec2 uv; float top = 0.0; + if (an.y > max(an.x, an.z)) { uv = q.xz; top = 1.0; } + else if (an.x > an.z) uv = vec2(q.z, q.y); else uv = vec2(q.x, q.y); + uv /= 0.78; + vec2 tx = floor((uv * 0.5 + 0.5) * 16.0); // 16x16 texels + float grain = 0.92 + 0.16 * h2(tx + top * 31.0); + vec2 c = (tx + 0.5) / 16.0 * 2.0 - 1.0; + vec3 col = base * grain; + if (top > 0.5) { + // crafting grid: pale panel, dark frame, 3x3 slots, some with items + if (max(abs(c.x), abs(c.y)) < 0.6) { + col = vec3(0.47, 0.75, 0.42) * (0.95 + 0.1 * h2(tx)); + vec2 slot = floor((c + 0.6) / 0.4); + vec2 sc = (slot + 0.5) * 0.4 - 0.6; + if (max(abs(c.x - sc.x), abs(c.y - sc.y)) > 0.15) col *= 0.82; + float r = h2(slot + 7.0); + vec3 item = r < 0.2 ? vec3(0.62, 0.11, 0.08) : r < 0.4 ? vec3(0.21, 0.09, 0.01) : r < 0.55 ? vec3(0.55, 0.3, 0.08) + : r < 0.7 ? vec3(0.13, 0.09, 0.06) : r < 0.85 ? vec3(0.66, 0.42, 0.25) : vec3(0.1, 0.3, 0.08); + if (h2(slot + 3.0) < 0.75 && max(abs(c.x - sc.x), abs(c.y - sc.y)) < 0.1) col = mix(col, item, u_speckle); + } else if (max(abs(c.x), abs(c.y)) < 0.72) col = base * 0.55; + } else { + // command-block side: framed square with an up chevron + float m = max(abs(c.x), abs(c.y + 0.05)); + if (m > 0.5 && m < 0.66) col = base * 0.55; + else if (m <= 0.5) { + col = base * 1.1 * grain; + float chev = abs(abs(c.x) - (0.25 - (c.y + 0.05))); + if (chev < 0.1 && abs(c.x) < 0.4 && c.y > -0.35 && c.y < 0.3) col = base * 1.8; + } + } + return col; +} + +vec4 shade_cube(vec2 s, int i){ + C1 = u_cubes[i]; C2 = u_cubes2[i]; C3 = u_cubes3[i]; + seed = float(i) * 1.37; + float sz = C1.z; + const float FIT = 1.4; // the glue blob is wider than the old cube + vec2 q = (s - C1.xy) / sz * FIT; + if (dot(q, q) > (C3.x < 0.0 || C3.x < 1.0 ? 30.0 : 9.0)) return vec4(0); + float yaw = C1.w + 0.7853982, pit = u_pitch; + vec3 fw = normalize(vec3(-cos(pit) * sin(yaw), -sin(pit), -cos(pit) * cos(yaw))); + vec3 rt = normalize(cross(fw, vec3(0, 1, 0))); + vec3 up = cross(rt, fw); + vec3 ro = -fw * 5.0 + rt * q.x - up * q.y; + float t = 0.0, d, dmin = 1e5; + bool hit = false; + for (int k = 0; k < 180; k++){ + d = map(ro + fw * t); + dmin = min(dmin, d); + if (d < 6e-4){ hit = true; break; } + t += d; + if (t > 10.0) break; + } + float pxw = FIT / sz * u_logical.x / u_res.x; + float cov = hit ? 1.0 : clamp(1.0 - dmin / pxw, 0.0, 1.0) * step(dmin, pxw); + if (cov <= 0.0) return vec4(0); + vec3 p = ro + fw * t; + vec3 n = nrm(p); + vec3 qd = deform(p); + bool glue = hit && d_glue(qd) < d_block(qd); + vec3 L = normalize(vec3(-0.45, 0.85, 0.35)); + vec3 H = normalize(L - fw); + vec3 c; + float alpha = 1.0; + if (glue) { + // slime: a glossy skin over translucent green; march through it, see the block inside + float fres = 0.04 + 0.96 * pow(1.0 - clamp(dot(n, -fw), 0.0, 1.0), 4.0); + vec3 rd = refract(fw, n, 0.75); + if (dot(rd, rd) < 0.5) rd = fw; + vec3 q2 = p - n * 0.01; + float path = 0.0; + bool inner = false; + vec3 pin; + for (int k = 0; k < 64; k++){ + float db = map_block(q2); + if (db < 1e-3) { inner = true; pin = q2; break; } + float dg = map_glue(q2); + if (dg > 0.004) { // left the glue: is the block behind this bit of glue? + float t2 = 0.0; + for (int j = 0; j < 40; j++){ float d2 = map_block(q2 + rd * t2); if (d2 < 1e-3) { inner = true; pin = q2 + rd * t2; break; } t2 += d2; if (t2 > 4.0) break; } + break; + } + float st = clamp(min(-dg, db), 0.015, 0.2); + q2 += rd * st; + path += st; + } + vec3 sigma = vec3(2.3, 0.55, 2.7); // green passes, red/blue are absorbed + vec3 T = exp(-sigma * path * 1.2); + vec3 scatter = u_body * (0.55 + 0.45 * clamp(dot(n, L), 0.0, 1.0)); + vec3 behind = vec3(0.0); + if (inner) { + vec3 nb = normalize(vec3(map_block(pin + vec3(1e-3, 0, 0)) - map_block(pin - vec3(1e-3, 0, 0)), + map_block(pin + vec3(0, 1e-3, 0)) - map_block(pin - vec3(0, 1e-3, 0)), + map_block(pin + vec3(0, 0, 1e-3)) - map_block(pin - vec3(0, 0, 1e-3)))); + vec3 qb = deform(pin) - vec3(0.0, bob(), 0.0); + behind = block_albedo(qb, nb) * (0.6 + 0.35 * clamp(dot(nb, L), 0.0, 1.0)); + } + float Tavg = dot(T, vec3(0.3, 0.5, 0.2)); + float body = 1.0 - Tavg; // how much the slime itself is seen + c = behind * T + scatter * body; + c = mix(c, vec3(0.75, 1.0, 0.65), fres * 0.35); + c += vec3(1.0) * pow(clamp(dot(n, H), 0.0, 1.0), 120.0) * 1.4; + c += vec3(0.85, 1.0, 0.75) * pow(clamp(dot(n, H), 0.0, 1.0), 16.0) * 0.12; + // nothing solid behind: the background shows through (premultiplied, alpha < 1) + alpha = inner ? 1.0 : clamp(0.35 + 0.65 * body + 0.5 * fres, 0.0, 1.0); + if (!inner) c = c * 1.0; + } else { + float dif = clamp(dot(n, L), 0.0, 1.0); + vec3 qb = qd - vec3(0.0, bob(), 0.0); + vec3 an = abs(n); + vec3 alb = block_albedo(qb, n); + float face = an.y > max(an.x, an.z) ? 1.25 : (n.x * rt.x + n.z * rt.z < 0.0 ? 1.0 : 0.78); + c = alb * face * (0.7 + 0.4 * dif); + vec3 e = abs(qb) - 0.78; + float edge = smoothstep(-0.06, -0.02, max(min(e.x, e.y), max(min(e.y, e.z), min(e.x, e.z)))); + c = mix(c, vec3(0.3, 0.9, 0.18), edge * 0.7); // the icon's bright lime edges + } + float a = cov * C2.w * alpha; + return vec4(c * cov * C2.w, a); +} + +void main(){ + vec2 s = uncam(vec2(v_uv.x, 1.0 - v_uv.y) * u_logical); + vec4 acc = vec4(0); + for (int i = 0; i < u_n; i++){ + vec4 c = shade_cube(s, i); + acc = c + acc * (1.0 - c.a); + } + o = acc * u_alpha; +} diff --git a/intro-video/hype/shaders/goo.glsl b/intro-video/hype/shaders/goo.glsl new file mode 100644 index 00000000..252d4390 --- /dev/null +++ b/intro-video/hype/shaders/goo.glsl @@ -0,0 +1,63 @@ +#version 430 +#include "common.glsl" +// 2D "glue" metaballs: smooth union of circles and tapered capsules, shaded as a glossy bead. +in vec2 v_uv; +out vec4 o; +uniform vec2 u_res; +uniform vec4 u_balls[64]; // x, y, r, unused +uniform int u_nb; +uniform vec4 u_caps[24]; // ax, ay, bx, by +uniform vec4 u_capr[24]; // r0, r1 +uniform int u_nc; +uniform float u_k; // smooth-union radius (px) +uniform vec4 u_col; // body colour (linear) +uniform vec4 u_rim; // outline colour +uniform vec4 u_hi; // specular colour +uniform float u_outline; // outline width (px) +uniform float u_bevel; // bevel width (px) +uniform float u_glow; // emissive inner glow amount + +float smin(float a, float b, float k){ + float h = max(k - abs(a - b), 0.0) / k; + return min(a, b) - h * h * k * 0.25; +} +float field(vec2 p){ + float d = 1e5; + for (int i = 0; i < u_nb; i++) d = smin(d, length(p - u_balls[i].xy) - u_balls[i].z, u_k); + for (int i = 0; i < u_nc; i++){ + vec2 a = u_caps[i].xy, b = u_caps[i].zw; + vec2 pa = p - a, ba = b - a; + float h = clamp(dot(pa, ba) / max(dot(ba, ba), 1e-4), 0.0, 1.0); + d = smin(d, length(pa - ba * h) - mix(u_capr[i].x, u_capr[i].y, h), u_k); + } + return d; +} +float height(float d){ float u = 1.0 - clamp(-d / u_bevel, 0.0, 1.0); return sqrt(max(1.0 - u * u, 0.0)); } // dome +void main(){ + vec2 s = vec2(v_uv.x, 1.0 - v_uv.y) * u_logical; + vec2 p = uncam(s); + float d = field(p); + float px = u_logical.x / u_res.x / u_cam.z; + if (d > 2.0 * px) { o = vec4(0); return; } + // wide central differences: smooths the creases a distance field has along its medial axes + float e = max(1.5, 0.14 * u_bevel); + float hx = 0.0, hy = 0.0; + for (int k = 0; k < 2; k++){ + float ee = e * (k == 0 ? 1.0 : 0.5); + hx += (height(field(p + vec2(ee, 0))) - height(field(p - vec2(ee, 0)))) / ee; + hy += (height(field(p + vec2(0, ee))) - height(field(p - vec2(0, ee)))) / ee; + } + vec3 n = normalize(vec3(-hx * u_bevel * 0.175, -hy * u_bevel * 0.175, 1.0)); + vec3 L = normalize(vec3(-0.55, -0.75, 0.75)); + float dif = clamp(dot(n, L), 0.0, 1.0); + vec3 H = normalize(L + vec3(0, 0, 1)); + float spec = pow(clamp(dot(n, H), 0.0, 1.0), 70.0); + vec3 c = u_col.rgb * (0.45 + 0.65 * dif); + c *= 0.8 + 0.3 * clamp(0.5 - 0.5 * (n.x + n.y) * 1.6, 0.0, 1.0) + 0.0; // lit from the top-left, thick body + c += u_col.rgb * u_glow * height(d); + float rim = 1.0 - smoothstep(u_outline - px, u_outline + px, -d); + c = mix(c, u_rim.rgb, rim * u_rim.a); + c += u_hi.rgb * spec * 1.4; + float a = clamp(0.5 - d / px, 0.0, 1.0) * u_col.a; + o = vec4(c * a, a) * u_alpha; +} diff --git a/intro-video/hype/sting.py b/intro-video/hype/sting.py new file mode 100644 index 00000000..6ef6c926 --- /dev/null +++ b/intro-video/hype/sting.py @@ -0,0 +1,21 @@ +"""MobileGlues mark sting: the block drops into a blob of glue, which splashes, erupts behind it and settles. +render: python render.py video 0 4.5 --module sting --audio out/sting.wav --samples 8 --out out/glue_drop.mp4""" +import math +from anim import * +from kit import cubes, bg +from engine import LW, LH, hexc + +END = 4.5 +T_HIT = 1.1 + + +def draw(E, t): + E.begin_sub(MINT) + f = Frame(t, 0) + bg(E, MINT) + u = t - T_HIT + zoom = 1.0 + (0.03 * math.exp(-u * 3) * math.sin(u * 20) if u > 0 else 0.0) + E.cam = (LW / 2, LH / 2, zoom, 0.0) + cubes(E, f, [(960, 560, 330, 0.0, 0.0, 0.0, 0.0, 1.0, u)]) + fade = clamp01((t - (END - 0.4)) / 0.4) + return {'bloom': 0.2, 'grain': 0.02, 'vignette': 0.22, 'flash': (*MINT[:3], fade)} diff --git a/intro-video/hype/sting_audio.py b/intro-video/hype/sting_audio.py new file mode 100644 index 00000000..ab96675f --- /dev/null +++ b/intro-video/hype/sting_audio.py @@ -0,0 +1,67 @@ +"""Sound for the glue-drop sting: a falling whoosh, a wet thump, splash plinks, tongues popping, a soft chime.""" +import sys +import numpy as np +import soundfile as sf +from dsp import SR, sine, svf, saw, compress, limit +from music import reverb_ir, conv +import music as M +import sting + +N = int(sting.END * SR) +rng = np.random.default_rng(7) +L = np.zeros(N) +R = np.zeros(N) + + +def put(t, x, pan=0.0, g=1.0): + i0 = int(t * SR) + n = min(len(x), N - i0) + a = (pan + 1) * np.pi / 4 + L[i0:i0 + n] += x[:n] * np.cos(a) * 1.414 * g + R[i0:i0 + n] += x[:n] * np.sin(a) * 1.414 * g + + +def tt(n): + return np.arange(n) / SR + + +def bloop(f0, f1, dur, v): + n = int(dur * SR) + t = tt(n) + f = f1 + (f0 - f1) * np.exp(-t / (dur * 0.25)) + return sine(f, 0.0) * np.minimum(t / 0.004, 1) * np.exp(-t / (dur * 0.35)) * v + + +hit = sting.T_HIT +# whoosh while it falls +n = int(0.5 * SR) +t = tt(n) +nz = svf(rng.standard_normal(n), 400 + 3000 * (t / 0.5) ** 2, 0.5, 1) +put(hit - 0.5, nz * (t / 0.5) ** 2 * 0.25) +# impact: low thump + wet splat +n = int(0.9 * SR) +t = tt(n) +thump = sine(45 + 120 * np.exp(-t / 0.05), 0.0) * np.exp(-t / 0.25) +splat = svf(rng.standard_normal(n), 2500 * np.exp(-t / 0.08) + 200, 0.3, 0) * np.exp(-t / 0.12) +put(hit, np.tanh(1.5 * thump) * 0.9 + splat * 0.7) +put(hit, bloop(520, 140, 0.35, 0.45)) +# droplets landing around +for k in range(9): + tk = hit + 0.25 + 0.06 * k + 0.05 * rng.random() + put(tk, bloop(1400 + 600 * rng.random(), 700, 0.09, 0.14), pan=rng.uniform(-0.8, 0.8)) +# tongues popping up, rising +notes = [392, 440, 523.25, 587.33, 659.25] +for k, fz in enumerate(notes): + put(hit + 0.12 + 0.07 * k, bloop(fz * 1.5, fz, 0.2, 0.28), pan=-0.5 + 0.25 * k) +# settle chime: soft FM bells, C add9 +for k, m in enumerate([60, 64, 67, 74, 79]): + put(hit + 0.75 + 0.02 * k, M.bell(m, 1.4, 0.16), pan=-0.3 + 0.15 * k) +ir = reverb_ir(1.8, 6000, 3) +M.N = N +rl, rr = conv(L, R, ir) +L2, R2 = L + rl * 0.35, R + rr * 0.35 +L2, R2 = limit(L2, R2, 0.89, 0.004, 0.05) +peak = max(abs(L2).max(), abs(R2).max()) +g = 0.85 / peak +sf.write(sys.argv[1], np.stack([L2 * g, R2 * g], 1).astype(np.float32), SR, subtype='PCM_24') +print('ok', peak) diff --git a/intro-video/paper/README.md b/intro-video/paper/README.md new file mode 100644 index 00000000..77f48b7b --- /dev/null +++ b/intro-video/paper/README.md @@ -0,0 +1,51 @@ +# MobileGL intro — paper-cutout video + +A ~4:50 explainer of what MobileGL is and how it works, in a handmade paper-cutout / origami style: +hand-cut shapes with paper grain and layered shadows, stop-motion "boil" at 12 fps, torn-paper wipes, a +procedural ukulele-and-marimba soundtrack, burned-in captions plus `.srt` files. English and Chinese versions are +rendered from the same code. + +Everything is plain Python on the CPU (Pillow + numpy + scipy), deterministic, and rendered in parallel worker +processes. + +## Files + +| file | what | +|---|---| +| `paper.py` | the paper engine: palette, fonts and mixed Latin/CJK text runs, hand-cut polygon roughening, periodic paper grain, sprites with inner/drop shadows, stop-motion jitter, torn-paper wipes, translation lookup (`T()`) | +| `video.py` | the twelve scenes (title, the problem, meet MobileGL, why it works, frontend, shaders, records, backends, anywhere, perks, platforms, outro), their captions, and the `stills` / `meta` / `render` entry points | +| `music.py` | the soundtrack: Karplus-Strong ukulele, marimba, bass and light percussion (120 BPM, I-V-vi-IV in C), plus paper swooshes on wipes; reads the scene timings written by `video.py meta` | +| `zh.py` | the Chinese strings (`MGL_LANG=zh`) | +| `cover.py` | a 16:9 thumbnail whose centred 4:3 crop keeps everything important | +| `fetch_fonts.py` | downloads the fonts into `fonts/` | + +## Setup + +```sh +pip install -r requirements.txt +python fetch_fonts.py # Fredoka, Patrick Hand, ZCOOL KuaiLe, LXGW WenKai, Cascadia Mono (all SIL OFL) +``` + +`ffmpeg` must be on `PATH`. + +## Render + +```sh +python video.py stills 3.0 45.5 # spot-check frames -> out/still_*.png +python video.py render ffmpeg # out/video_noaudio.mp4 + out/mobilegl_intro.json/.srt (scene timings) +python music.py out/mobilegl_intro.json out/music.wav +ffmpeg -i out/video_noaudio.mp4 -i out/music.wav -c:v copy -c:a aac -b:a 192k -shortest out/mobilegl_intro.mp4 +``` + +Chinese version: set `MGL_LANG=zh` for the `video.py` and `cover.py` steps; output goes to `out_zh/` +(`video_noaudio_zh.mp4`, `mobilegl_intro_zh.srt`). The scene timings are identical, so the same `music.wav` is used: + +```sh +MGL_LANG=zh python video.py render ffmpeg +ffmpeg -i out_zh/video_noaudio_zh.mp4 -i out/music.wav -c:v copy -c:a aac -b:a 192k -shortest out_zh/mobilegl_intro_zh.mp4 +``` + +Cover: `python cover.py` writes `out/cover.png` and a 1280×720 JPEG; `--guides` also writes a preview with the +4:3 crop lines and the cropped image. + +The full render uses every core but two; expect a few minutes per language. diff --git a/intro-video/paper/cover.py b/intro-video/paper/cover.py new file mode 100644 index 00000000..0ce881df --- /dev/null +++ b/intro-video/paper/cover.py @@ -0,0 +1,106 @@ +"""Cover / thumbnail for the MobileGL intro: one 16:9 image whose centre 4:3 crop keeps everything +important; the side bands only carry decoration. MGL_LANG=zh for the Chinese cover.""" +from video import * +import paper + +paper.TR.update({ + 'Desktop OpenGL, anywhere': '桌面 OpenGL,处处可用', + 'How it works, in 5 minutes': '5 分钟看懂它如何工作', +} if LANG == 'zh' else {}) + +SAFE_W = H * 4 // 3 # 1440: width kept by a centred 4:3 crop +OX = (W - SAFE_W) // 2 # 240: left edge of the safe area + + +@cache +def spr_big_tile(ch, color, k=1.22): + w, h = int(160 * k), int(180 * k) + a = Art(w, h, f'bigtile|{ch}|{color}|{k}') + c = 46 * k + a.poly([(0, 14 * k), (14 * k, 0), (w - c, 0), (w, c), (w, h - 14 * k), (w - 14 * k, h), (14 * k, h), + (0, h - 14 * k)], color, rough=0.9) + a.poly([(w - c, 0), (w - c, c), (w, c)], shade(color, 1.3), rough=0.4) + a.text(w / 2 - 2, h / 2 + 4, ch, ('fredoka', int(122 * k), 'Bold'), WHITE, shade=True) + return a.build() + + +def put(c, spr, x, y, rot=0.0, s=1.0, elev=1.2): + c.put(spr, x, y, rot, s, None, 1, elev, boil=0) + + +def side_decor(c): + """Decoration confined to the two bands outside the 4:3 crop (x < 240, x > 1680).""" + put(c, spr_plane(MUSTARD), 125, 330, 14, 0.8, 3.0) + put(c, spr_plane(CORAL), 110, 790, -10, 0.7, 3.0) + put(c, spr_crane(CORAL), 1800, 300, -6, 0.5, 3.0) + put(c, spr_plane(TEAL), 1805, 770, -16, 0.75, 3.0) + rng = random.Random(7) + for band in ((25, OX - 25), (OX + SAFE_W + 25, W - 25)): + n = 0 + while n < 7: + x, y = rng.uniform(*band), rng.uniform(60, H - 60) + size = rng.randrange(24, 46) + if not (band[0] + size / 2 < x < band[1] - size / 2): + continue + if any(abs(y - yy) < 150 for yy in (330, 790, 300, 770)) and abs(x - (band[0] + band[1]) / 2) < 90: + continue # keep clear of the planes / crane + put(c, spr_confetti(rng.randrange(4), rng.choice((CORAL, MUSTARD, TEAL, BLUE, PLUM, ORANGE)), size), + x, y, rng.uniform(-40, 40), 1, 0.6) + n += 1 + + +def safe_content(c): + """Everything that matters, laid out inside the centre 1440x1080.""" + cx = W / 2 + rng = random.Random(43) + n = 0 + while n < 12: # a few confetti pieces inside the safe area's own edges + x, y = rng.uniform(OX + 40, OX + SAFE_W - 40), rng.uniform(40, H - 40) + if OX + 90 < x < OX + SAFE_W - 90 and 90 < y < H - 60: + continue + put(c, spr_confetti(rng.randrange(4), rng.choice((CORAL, MUSTARD, TEAL, BLUE, PLUM, ORANGE)), + rng.randrange(24, 46)), x, y, rng.uniform(-40, 40), 1, 0.6) + n += 1 + step = 166 + for i, ch in enumerate('MobileGL'): + put(c, spr_big_tile(ch, TITLE_COLS[i], 0.98), cx - step * 3.5 + step * i, 240 + (5 if i % 2 else -5), + (3.5 if i % 2 else -3.5), 1, 1.3) + put(c, spr_label('Desktop OpenGL, anywhere', ('fredoka', 64, 'SemiBold'), WHITE, INK, padx=44, pady=20), + cx, 465, -1.5, 1, 1.4) + put(c, spr_label('How it works, in 5 minutes', ('hand', 42), CORAL, WHITE, padx=28, pady=10), + cx, 565, 2, 1, 1.4) + y = 835 + put(c, spr_appwin(), OX + 225, y, -4, 0.5) + put(c, spr_arrow(90, INK, 18, 40), OX + 445, y, 0, 1, 0.6) + put(c, spr_crane(BRAND), OX + 655, y - 45, -4, 0.85, 3.5) + put(c, spr_slab('MobileGL', 230, BRAND, h=64, size=36), OX + 655, y + 75, 2) + put(c, spr_arrow(90, INK, 18, 40), OX + 870, y, 0, 1, 0.6) + put(c, spr_phone(BG_PEACH), OX + 1035, y + 10, 4, 0.5) + put(c, spr_tablet(BG_MINT), OX + 1250, y + 25, -3, 0.52) + put(c, spr_bubble('OpenGL ES', ('fredoka', 36, 'SemiBold'), TEAL, WHITE), OX + 1020, y - 140, -3, 1, 1.5) + put(c, spr_bubble('Vulkan', ('fredoka', 36, 'SemiBold'), PLUM, WHITE, 'br'), OX + 1285, y - 115, 3, 1, 1.5) + put(c, spr_sticker('OpenGL\n4.6 Core', MUSTARD, r=80, size=29, fg=INK), OX + 1310, 462, 12, 1, 1.6) + + +def cover(): + frame = background(BG_CREAM).copy() + c = Ctx(frame, 100.0, 0, 1) + side_decor(c) + safe_content(c) + return frame.convert('RGB') + + +if __name__ == '__main__': + suffix = '' if LANG == 'en' else '_' + LANG + img = cover() + img.save(os.path.join(OUT, f'cover{suffix}.png')) + img.resize((1280, 720), Image.LANCZOS).save(os.path.join(OUT, f'cover{suffix}_1280x720.jpg'), quality=92) + if '--guides' in sys.argv: # preview with the 4:3 crop lines drawn in + g = img.copy() + d = ImageDraw.Draw(g) + for x in (OX, OX + SAFE_W): + d.line([(x, 0), (x, H)], fill=(220, 0, 0), width=4) + g.resize((1280, 720), Image.LANCZOS).save(os.path.join(OUT, f'cover{suffix}_guides.jpg'), quality=88) + img.crop((OX, 0, OX + SAFE_W, H)).resize((960, 720), Image.LANCZOS).save( + os.path.join(OUT, f'cover{suffix}_crop43.jpg'), quality=88) + print('ok') diff --git a/intro-video/paper/fetch_fonts.py b/intro-video/paper/fetch_fonts.py new file mode 100644 index 00000000..d51b8c60 --- /dev/null +++ b/intro-video/paper/fetch_fonts.py @@ -0,0 +1,37 @@ +"""Download the fonts the paper-cutout video uses into ./fonts (all SIL OFL 1.1). Run once before rendering.""" +import io +import os +import urllib.request +import zipfile + +HERE = os.path.dirname(os.path.abspath(__file__)) +GF = 'https://github.com/google/fonts/raw/main/ofl/' +FONTS = { + 'Fredoka.ttf': GF + 'fredoka/Fredoka%5Bwdth,wght%5D.ttf', + 'PatrickHand.ttf': GF + 'patrickhand/PatrickHand-Regular.ttf', + 'ZCOOLKuaiLe.ttf': GF + 'zcoolkuaile/ZCOOLKuaiLe-Regular.ttf', + 'LXGWWenKai.ttf': 'https://github.com/lxgw/LxgwWenKai/releases/download/v1.522/LXGWWenKai-Regular.ttf', +} +CASCADIA = 'https://github.com/microsoft/cascadia-code/releases/download/v2407.24/CascadiaCode-2407.24.zip' + + +def fetch(url): + with urllib.request.urlopen(url) as r: + return r.read() + + +if __name__ == '__main__': + out = os.path.join(HERE, 'fonts') + os.makedirs(out, exist_ok=True) + for name, url in FONTS.items(): + path = os.path.join(out, name) + if not os.path.exists(path): + print('fetching', name) + with open(path, 'wb') as f: + f.write(fetch(url)) + path = os.path.join(out, 'CascadiaMono.ttf') + if not os.path.exists(path): + print('fetching CascadiaMono.ttf') + with zipfile.ZipFile(io.BytesIO(fetch(CASCADIA))) as z, open(path, 'wb') as f: + f.write(z.read('ttf/CascadiaMono.ttf')) + print('fonts ready in', out) diff --git a/intro-video/paper/music.py b/intro-video/paper/music.py new file mode 100644 index 00000000..10001f88 --- /dev/null +++ b/intro-video/paper/music.py @@ -0,0 +1,292 @@ +"""Procedural upbeat ukulele/marimba track (120 BPM, C major, I-V-vi-IV) + paper SFX. +Usage: python music.py out/mobilegl_intro.json out/music.wav""" +import sys, json, functools +import numpy as np +from scipy import signal +from scipy.io import wavfile + +SR = 44100 +BPM = 120 +BEAT = 60 / BPM +BAR = 4 * BEAT +rng_global = np.random.default_rng(42) + + +def hz(m): + return 440.0 * 2 ** ((m - 69) / 12) + + +def N(s): + names = {'C': 0, 'D': 2, 'E': 4, 'F': 5, 'G': 7, 'A': 9, 'B': 11} + return 12 * (int(s[-1]) + 1) + names[s[0]] + (1 if '#' in s else 0) + + +def env_ad(n, a=0.002, release=0.02): + e = np.ones(n) + na, nr = max(1, int(a * SR)), max(1, int(release * SR)) + e[:na] = np.linspace(0, 1, na) + e[-nr:] *= np.linspace(1, 0, nr) + return e + + +# ---------------------------------------------------------------- instruments +@functools.lru_cache(None) +def pluck(m, dur=1.4): + f = hz(m) + P = int(SR / f) + total = int(dur * SR) + r = np.random.default_rng(m) + y = np.zeros(total + 1) + burst = r.uniform(-1, 1, P) + burst = np.convolve(burst, [0.3, 0.4, 0.3], 'same') + burst -= burst.mean() + y[1:P + 1] = burst + k = P + 1 + while k < total + 1: + e = min(k + P, total + 1) + y[k:e] = 0.4985 * (y[k - P:e - P] + y[k - P - 1:e - P - 1]) + k = e + y = y[1:] + y *= env_ad(total, 0.001, 0.08) + b, a = signal.butter(2, min(0.99, 5200 / (SR / 2))) + return signal.lfilter(b, a, y) / (np.abs(y).max() + 1e-9) + + +@functools.lru_cache(None) +def mallet(m, dur=0.7): + f = hz(m) + t = np.arange(int(dur * SR)) / SR + y = (np.sin(2 * np.pi * f * t) * np.exp(-t * 5.5) + + 0.32 * np.sin(2 * np.pi * 3.93 * f * t) * np.exp(-t * 22) + + 0.10 * np.sin(2 * np.pi * 9.2 * f * t) * np.exp(-t * 55)) + return y * env_ad(len(t), 0.0015, 0.03) + + +@functools.lru_cache(None) +def bass(m, dur): + f = hz(m) + t = np.arange(int(dur * SR)) / SR + y = np.sin(2 * np.pi * f * t) + 0.35 * np.sin(2 * np.pi * 2 * f * t) + 0.15 * np.sin(2 * np.pi * 3 * f * t) + e = (1 - np.exp(-t * 300)) * np.exp(-t * 3.0) + return y * e * env_ad(len(t), 0.001, 0.03) + + +@functools.lru_cache(None) +def kick(): + t = np.arange(int(0.35 * SR)) / SR + f = 48 + 110 * np.exp(-t * 32) + ph = 2 * np.pi * np.cumsum(f) / SR + y = np.sin(ph) * np.exp(-t * 11) + y[:int(0.004 * SR)] += np.random.default_rng(1).uniform(-0.4, 0.4, int(0.004 * SR)) + return y + + +def _band(x, lo, hi, order=2): + b, a = signal.butter(order, [lo / (SR / 2), min(0.99, hi / (SR / 2))], 'band') + return signal.lfilter(b, a, x) + + +def _hp(x, fc): + b, a = signal.butter(2, fc / (SR / 2), 'high') + return signal.lfilter(b, a, x) + + +@functools.lru_cache(None) +def clap(): + n = int(0.25 * SR) + t = np.arange(n) / SR + r = np.random.default_rng(2) + y = np.zeros(n) + for k, d in enumerate([0, 0.009, 0.018, 0.027]): + s = int(d * SR) + dec = 120 if k < 3 else 22 + y[s:] += r.uniform(-1, 1, n - s) * np.exp(-(t[:n - s]) * dec) + y = _band(y, 900, 3200) + return y / np.abs(y).max() + + +@functools.lru_cache(None) +def hat(open_=False): + n = int((0.18 if open_ else 0.06) * SR) + t = np.arange(n) / SR + y = _hp(np.random.default_rng(3).uniform(-1, 1, n), 7000) * np.exp(-t * (18 if open_ else 70)) + return y / np.abs(y).max() + + +@functools.lru_cache(None) +def shaker(): + n = int(0.09 * SR) + t = np.arange(n) / SR + e = np.minimum(1, t / 0.02) * np.exp(-t * 40) + y = _hp(np.random.default_rng(4).uniform(-1, 1, n), 5000) * e + return y / np.abs(y).max() + + +def swoosh(seed): + n = int(0.75 * SR) + t = np.arange(n) / SR + r = np.random.default_rng(seed) + x = r.uniform(-1, 1, n) + e = np.sin(np.pi * np.clip(t / 0.75, 0, 1)) ** 2.2 + y = _band(x, 500, 5000) * e + crinkle = np.zeros(n) + for _ in range(26): + s = int(r.uniform(0.05, 0.6) * SR) + L = int(0.004 * SR) + crinkle[s:s + L] += r.uniform(-1, 1, L) * r.uniform(0.3, 1) + y += 0.5 * _hp(crinkle, 2500) + return y / np.abs(y).max() + + +@functools.lru_cache(None) +def tok(): + t = np.arange(int(0.08 * SR)) / SR + f = 700 * np.exp(-t * 12) + 260 + return np.sin(2 * np.pi * np.cumsum(f) / SR) * np.exp(-t * 45) + + +# ---------------------------------------------------------------- score +CHORDS = [ # name, uke voicing (G C E A strings), bass root, chord tones for arps + ('C', [N('G4'), N('C4'), N('E4'), N('C5')], N('C2'), [N('C5'), N('E5'), N('G5'), N('C6')]), + ('G', [N('G4'), N('D4'), N('G4'), N('B4')], N('G1'), [N('B4'), N('D5'), N('G5'), N('B5')]), + ('Am', [N('A4'), N('C4'), N('E4'), N('A4')], N('A1'), [N('C5'), N('E5'), N('A5'), N('C6')]), + ('F', [N('A4'), N('C4'), N('F4'), N('A4')], N('F1'), [N('C5'), N('F5'), N('A5'), N('C6')]), +] +MEL_A = [ + [(0, 'E5', .5), (1, 'G5', .5), (2, 'A5', .5), (2.5, 'G5', .5), (3, 'E5', 1)], + [(0, 'D5', .5), (1, 'G5', .5), (1.5, 'B5', .5), (2, 'D6', 1), (3, 'B5', .5), (3.5, 'G5', .5)], + [(0, 'C6', .5), (0.5, 'B5', .5), (1, 'A5', 1), (2, 'E5', .5), (3, 'A5', .5), (3.5, 'C6', .5)], + [(0, 'A5', 1.5), (1.5, 'G5', .5), (2, 'F5', .5), (2.5, 'E5', .5), (3, 'D5', 1)], +] +MEL_B = [ + [(0, 'G5', .5), (0.5, 'G5', .5), (1, 'C6', 1), (2, 'E6', .5), (2.5, 'D6', .5), (3, 'C6', 1)], + [(0, 'B5', .5), (1, 'D6', .5), (1.5, 'B5', .5), (2, 'G5', 1), (3, 'A5', .5), (3.5, 'B5', .5)], + [(0, 'C6', 1), (1, 'E6', .5), (1.5, 'D6', .5), (2, 'C6', .5), (2.5, 'A5', .5), (3, 'E5', 1)], + [(0, 'F5', .5), (0.5, 'A5', .5), (1, 'C6', 1), (2, 'A5', .5), (2.5, 'G5', .5), (3, 'G5', 1)], +] +STRUM = [(0, 'D'), (1, 'D'), (1.5, 'U'), (2.5, 'U'), (3, 'D'), (3.5, 'U')] +BASS = [(0, 0, .45), (1, 0, .2), (1.5, 12, .2), (2, 0, .45), (3, 7, .3), (3.5, 12, .2)] + + +class Mix: + def __init__(self, seconds): + self.n = int(seconds * SR) + SR * 3 + self.buses = {k: np.zeros((self.n, 2)) for k in ('dry', 'wet')} + + def add(self, x, t, gain=1.0, pan=0.0, bus='dry'): + s = int(t * SR) + if s >= self.n: + return + x = x[:self.n - s] + l, r = np.cos((pan + 1) * np.pi / 4), np.sin((pan + 1) * np.pi / 4) + self.buses[bus][s:s + len(x), 0] += x * gain * l * 1.414 + self.buses[bus][s:s + len(x), 1] += x * gain * r * 1.414 + + +def section(bar, last): + if bar >= last: + return 'end' + if bar < 2: + return 'intro0' + if bar < 8: + return 'intro' + if 64 <= bar < 68 or 112 <= bar < 116: + return 'break' + return 'mel' if ((bar - 8) // 16) % 2 == 0 else 'arp' + + +def build(meta, out): + total = meta['total'] + mix = Mix(total) + last = int((total - 3.6) // BAR) # final hit bar + r = np.random.default_rng(9) + for bar in range(last + 1): + t0 = bar * BAR + sec = section(bar, last) + name, uke, root, tones = CHORDS[bar % 4] + if sec == 'end': + for k, m in enumerate(uke): + mix.add(pluck(m, 3.0), t0 + 0.012 * k, 0.42, -0.25, 'wet') + mix.add(mallet(N('C6'), 2.0), t0, 0.45, 0.2, 'wet') + mix.add(mallet(N('G5'), 2.0), t0 + 0.06, 0.3, 0.2, 'wet') + mix.add(bass(N('C2'), 2.0), t0, 0.55) + mix.add(kick(), t0, 0.7) + mix.add(hat(True), t0, 0.25, 0.3) + break + drums = sec in ('intro', 'mel', 'arp') + # ukulele strums + for b, d in STRUM: + order = uke if d == 'D' else uke[::-1] + vel = (0.36 if d == 'D' else 0.24) * (0.8 if sec == 'break' else 1) + for k, m in enumerate(order): + mix.add(pluck(m), t0 + b * BEAT + 0.011 * k + r.uniform(0, 0.004), vel * r.uniform(0.85, 1.05), + -0.25, 'wet') + # bass + if sec != 'intro0': + for b, off, d in BASS: + g = 0.42 if sec != 'break' else 0.3 + mix.add(bass(root + off, round(d * BEAT * 2, 3)), t0 + b * BEAT, g) + # drums + if drums: + for b in (0, 2): + mix.add(kick(), t0 + b * BEAT, 0.62) + if sec != 'intro' or bar >= 4: + for b in (1, 3): + mix.add(clap(), t0 + b * BEAT, 0.26, 0.05, 'wet') + for e in range(8): + mix.add(hat(open_=(e == 7 and bar % 4 == 3)), t0 + e * BEAT / 2, + 0.045 if e % 2 == 0 else 0.075, 0.3) + if sec in ('mel', 'arp'): + for s in range(16): + mix.add(shaker(), t0 + s * BEAT / 4, 0.045 if s % 2 else 0.025, -0.35) + if bar % 8 == 7: # little fill + mix.add(kick(), t0 + 3.5 * BEAT, 0.45) + mix.add(clap(), t0 + 3.75 * BEAT, 0.16, 0.05, 'wet') + if sec == 'break': + for s in range(8): + mix.add(shaker(), t0 + s * BEAT / 2, 0.05, -0.35) + # melody / arps + if sec == 'mel' or sec == 'break' and bar % 4 >= 2: + phrase = MEL_A if ((bar - 8) // 4) % 2 == 0 else MEL_B + for b, note, d in phrase[bar % 4]: + g = 0.3 if sec == 'mel' else 0.2 + mix.add(mallet(N(note), max(0.4, d * BEAT * 1.6)), t0 + b * BEAT, g, 0.2, 'wet') + if sec == 'mel' and ((bar - 8) // 16) >= 2: # later: octave sparkle doubling + mix.add(mallet(N(note) + 12, 0.4), t0 + b * BEAT, 0.08, 0.35, 'wet') + if sec == 'arp': + pat = [0, 1, 2, 3, 2, 1, 2, 3, 0, 1, 2, 3, 2, 3, 1, 2] + for s, i in enumerate(pat): + mix.add(mallet(tones[i], 0.35), t0 + s * BEAT / 4, 0.13 if s % 4 else 0.18, 0.35, 'wet') + if bar % 2 == 0: # simple call-back motif + for b, note in [(0, tones[3] + 0), (0.5, tones[2]), (1, tones[3])]: + mix.add(mallet(note + 12 if note < N('C6') else note, 0.5), t0 + b * BEAT, 0.12, -0.1, 'wet') + # SFX + for k, w in enumerate(meta['wipes']): + mix.add(swoosh(100 + k), w - 0.05, 0.2, 0.4 - 0.8 * (k % 2)) + for k, p in enumerate(meta['pops']): + mix.add(tok(), p + 0.35, 0.14, -0.6 + 0.15 * (k % 8)) + # reverb on the wet bus + ir_n = int(1.3 * SR) + t = np.arange(ir_n) / SR + ir = np.random.default_rng(7).standard_normal((ir_n, 2)) * np.exp(-t * 4.2)[:, None] + ir[:, 0] = _band(ir[:, 0], 200, 6000) + ir[:, 1] = _band(ir[:, 1], 200, 6000) + ir /= np.sqrt((ir ** 2).sum(axis=0)) + wet = mix.buses['wet'] + rev = np.stack([signal.fftconvolve(wet[:, c], ir[:, c])[:mix.n] for c in range(2)], axis=1) + y = mix.buses['dry'] + wet + 0.22 * rev + y = y[:int(total * SR)] + bh, ah = signal.butter(2, 30 / (SR / 2), 'high') + y = signal.filtfilt(bh, ah, y, axis=0) + y /= np.abs(y).max() / 0.95 + y = np.tanh(1.4 * y) / np.tanh(1.4) + n_in, n_out = int(0.25 * SR), int(1.6 * SR) + y[:n_in] *= np.linspace(0, 1, n_in)[:, None] + y[-n_out:] *= np.linspace(1, 0, n_out)[:, None] ** 1.5 + y *= 0.89 / np.abs(y).max() + wavfile.write(out, SR, (y * 32767).astype(np.int16)) + print('wrote', out, len(y) / SR, 's; final hit bar', last, 'at', last * BAR) + + +if __name__ == '__main__': + build(json.load(open(sys.argv[1])), sys.argv[2]) diff --git a/intro-video/paper/paper.py b/intro-video/paper/paper.py new file mode 100644 index 00000000..4623419f --- /dev/null +++ b/intro-video/paper/paper.py @@ -0,0 +1,618 @@ +"""Tiny paper-cutout rendering engine: hand-cut shapes, paper grain, layered shadows, +stop-motion jitter, torn-paper wipes. Everything is deterministic so worker processes +can rebuild identical sprite caches independently.""" +import os, re, math, zlib, random, functools +import numpy as np +from PIL import Image, ImageDraw, ImageFilter, ImageFont, ImageChops +from scipy import ndimage + +HERE = os.path.dirname(os.path.abspath(__file__)) +W, H = 1920, 1080 +FPS = 12 # stop-motion animation rate (encoded at 24 with doubled frames) +SS = 3 # supersampling for shape masks + +# ---------------------------------------------------------------- palette +INK = (45, 42, 40); WHITE = (253, 250, 243); PAPER = (250, 244, 230) +CORAL = (231, 111, 81); TEAL = (42, 157, 143); MUSTARD = (236, 190, 90) +NAVY = (38, 70, 83); ORANGE = (244, 162, 97); SKY = (142, 202, 230) +BLUE = (52, 140, 200); BRAND = (0, 89, 156); PLUM = (146, 102, 150) +GREEN = (118, 178, 92); DIRT = (150, 100, 66); DARK = (58, 62, 72) +GREY = (170, 168, 160); PINK = (240, 150, 160); RED = (200, 50, 50) +BG_CREAM = (243, 234, 214); BG_SKY = (206, 228, 236); BG_MINT = (214, 234, 218) +BG_PEACH = (248, 222, 203); BG_LAV = (226, 219, 238); BG_BUTTER = (246, 233, 192) +BG_NAVY = (40, 58, 82) + + +def shade(c, f): + if f >= 1: + return tuple(int(v + (255 - v) * (f - 1)) for v in c) + return tuple(int(v * f) for v in c) + + +def crc(*parts): + return zlib.crc32('|'.join(map(str, parts)).encode()) + + +# ---------------------------------------------------------------- easing +def clamp01(x): return 0.0 if x < 0 else 1.0 if x > 1 else x +def lerp(a, b, t): return a + (b - a) * t +def prog(t, t0, d): return clamp01((t - t0) / d) if d > 0 else float(t >= t0) +def ease_out(p): return 1 - (1 - p) ** 3 +def ease_in(p): return p ** 3 +def ease_io(p): return p * p * (3 - 2 * p) + + +def ease_back(p, s=1.70158): + p -= 1 + return p * p * ((s + 1) * p + s) + 1 + + +def bump(t, t0, d): + return math.sin(math.pi * prog(t, t0, d)) + + +# ---------------------------------------------------------------- fonts +FONT_FILES = { + 'fredoka': os.path.join(HERE, 'fonts', 'Fredoka.ttf'), + 'hand': os.path.join(HERE, 'fonts', 'PatrickHand.ttf'), + 'mono': next((p for p in (os.path.join(HERE, 'fonts', 'CascadiaMono.ttf'), r'C:\Windows\Fonts\CascadiaMono.ttf') + if os.path.exists(p)), os.path.join(HERE, 'fonts', 'CascadiaMono.ttf')), + 'kuaile': os.path.join(HERE, 'fonts', 'ZCOOLKuaiLe.ttf'), + 'wenkai': os.path.join(HERE, 'fonts', 'LXGWWenKai.ttf'), +} + +# ---------------------------------------------------------------- localisation +LANG = os.environ.get('MGL_LANG', 'en') +TR = {} +if LANG == 'zh': + from zh import TR_ZH as TR +CJK_SUB = {'hand': ('wenkai', 0.9), 'mono': ('wenkai', 0.95)} +CJK_RICH = {'fredoka': ('kuaile', 0.96)} # Latin runs keep the original face, CJK runs use this + + +def T(s): + return TR.get(s, s) if TR else s + + +def has_cjk(s): + return any('\u3000' <= ch <= '\u9fff' or '\uff00' <= ch <= '\uffef' for ch in s) + + +def resolve(spec, s): + """Text containing CJK is drawn with a CJK font standing in for the Latin family.""" + if not has_cjk(s): + return spec + if spec[0] in CJK_RICH: + name, k = CJK_RICH[spec[0]] + return ('rich', spec, (name, max(8, int(round(spec[1] * k))))) + if spec[0] in CJK_SUB: + name, k = CJK_SUB[spec[0]] + return (name, max(8, int(round(spec[1] * k)))) + return spec + + +class RichFont: + """Two faces sharing a baseline: one for Latin runs, one for CJK runs.""" + + def __init__(self, lat, cjk, scale): + self.fl, self.fc = get_font(lat, scale), get_font(cjk, scale) + (al, dl), (ac, dc) = self.fl.getmetrics(), self.fc.getmetrics() + self.asc, self.desc = max(al, ac), max(dl, dc) + + @staticmethod + def _is_c(ch): + return has_cjk(ch) or '\u2010' <= ch <= '\u206f' + + def runs(self, s): + out, cur, curc = [], '', None + for ch in s: + c = curc if (ch == ' ' and curc is not None) else self._is_c(ch) + if curc is None or c == curc: + cur, curc = cur + ch, c + else: + out.append((cur, curc)) + cur, curc = ch, c + if cur: + out.append((cur, curc)) + return [(r, self.fc if c else self.fl) for r, c in out] + + def getlength(self, s): + return sum(f.getlength(r) for r, f in self.runs(s)) + + def bbox(self, s, spacing, h): + lines = s.split('\n') + w = max(self.getlength(l) for l in lines) + hh = len(lines) * (self.asc + self.desc) + (len(lines) - 1) * spacing + return (-w / 2, 0, w / 2, hh) if h == 'm' else (0, 0, w, hh) + + def draw(self, d, x, y, s, spacing, h, v, fill): + lines = s.split('\n') + lh = self.asc + self.desc + top = y - ((len(lines) * lh + (len(lines) - 1) * spacing) / 2 if v == 'm' else 0) + for i, ln in enumerate(lines): + xs = x - (self.getlength(ln) / 2 if h == 'm' else 0) + base = top + self.asc + i * (lh + spacing) + for r, f in self.runs(ln): + d.text((xs, base), r, font=f, fill=fill, anchor='ls') + xs += f.getlength(r) + + +@functools.lru_cache(None) +def get_font(spec, scale=1): + if spec[0] == 'rich': + return RichFont(spec[1], spec[2], scale) + f = ImageFont.truetype(FONT_FILES[spec[0]], int(round(spec[1] * scale))) + if spec[0] == 'fredoka': + f.set_variation_by_name(spec[2] if len(spec) > 2 else 'SemiBold') + return f + + +_D = ImageDraw.Draw(Image.new('L', (8, 8))) + + +def text_bbox(s, font, spacing=6, align='center', h='m'): + s = T(s) + font = resolve(font, s) + if font[0] == 'rich': + return get_font(font).bbox(s, spacing, h) + return _D.multiline_textbbox((0, 0), s, font=get_font(font), anchor=h + 'a', + spacing=spacing, align=align) + + +def measure(s, font, spacing=6): + l, t, r, b = text_bbox(s, font, spacing) + return r - l, b - t + + +_TOK = re.compile(r'[A-Za-z0-9_.+#/×{}\-\u00a0]+|\s+|.', re.S) +_NO_START = set(',。、:;!?)》」』”’…—,.;:!?)') +_NO_END = set('(《「『“‘(') + + +_BREAK_AFTER = set(',。、:;!?') + + +def _cjk_greedy(toks, f, w): + out, cur = [], [] + for tk in toks: + line = ''.join(cur) + if not line.strip() or f.getlength((line + tk).rstrip()) <= w: + cur.append(tk) + continue + j = None # prefer breaking right after punctuation if the line stays reasonably full + for k in range(len(cur) - 1, 0, -1): + if cur[k - 1][-1] in _BREAK_AFTER or cur[k - 1].isspace(): + if f.getlength(''.join(cur[:k]).rstrip()) >= 0.7 * w: + j = k + break + if j: + out.append(''.join(cur[:j]).rstrip()) + cur = cur[j:] + else: + out.append(line.rstrip()) + cur = [] + while cur and cur[0].isspace(): + cur.pop(0) + if cur or not tk.isspace(): + cur.append(tk) + out.append(''.join(cur).rstrip()) + return out + + +def wrap_cjk(s, f, maxw): + out = [] + for para in s.split('\n'): + toks = [] + for tk in _TOK.findall(para): + if toks and (tk[0] in _NO_START or toks[-1][-1] in _NO_END): + toks[-1] += tk + else: + toks.append(tk) + n = len(_cjk_greedy(toks, f, maxw)) + w = maxw + if n > 1: # balance: narrowest width that keeps the same line count + lo = maxw * 0.3 + while w - lo > 4: + mid = (lo + w) / 2 + if len(_cjk_greedy(toks, f, mid)) <= n: + w = mid + else: + lo = mid + out += _cjk_greedy(toks, f, w) + return '\n'.join(out) + + +def wrap(s, font, maxw): + s = T(s) + font = resolve(font, s) + f = get_font(font) + if has_cjk(s): + return wrap_cjk(s, f, maxw) + out = [] + for para in s.split('\n'): + line = '' + for w in para.split(' '): + cand = (line + ' ' + w).strip() + if not line or f.getlength(cand) <= maxw: + line = cand + else: + out.append(line) + line = w + out.append(line) + return '\n'.join(out) + + +# ---------------------------------------------------------------- paper grain +_GRAIN = None + + +def grain(): + global _GRAIN + if _GRAIN is None: + size = 1024 + rng = np.random.default_rng(7) + ky = np.fft.fftfreq(size)[:, None] + kx = np.fft.fftfreq(size)[None, :] + k2 = kx ** 2 + ky ** 2 + + def periodic(sigma): # gaussian-filtered white noise, seamlessly tileable + f = np.fft.fft2(rng.standard_normal((size, size))) + o = np.real(np.fft.ifft2(f * np.exp(-k2 * (2 * np.pi * sigma) ** 2 / 2))) + return (o / o.std()).astype(np.float32) + g = 0.45 * periodic(90) + 0.35 * periodic(24) + 0.3 * periodic(5) + g += 0.4 * rng.standard_normal((size, size)).astype(np.float32) + fib = Image.new('L', (size, size), 0) + d = ImageDraw.Draw(fib) + for _ in range(1400): + x, y = rng.uniform(0, size, 2) + ang = rng.uniform(0, math.pi) + L = rng.uniform(5, 26) + col = int(rng.uniform(70, 170)) + for ox in (-size, 0, size): + for oy in (-size, 0, size): + d.line([(x + ox, y + oy), (x + ox + L * math.cos(ang), y + oy + L * math.sin(ang))], + fill=col, width=1) + fib = np.asarray(fib.filter(ImageFilter.GaussianBlur(0.5)), np.float32) / 255 + g = (g - g.mean()) / g.std() + _GRAIN = g + fib * 1.4 + return _GRAIN + + +def textured(color, w, h, seed, k): + c = np.array(color, np.float32) + if k <= 0: + return np.ascontiguousarray(np.broadcast_to(c.astype(np.uint8), (h, w, 3))) + g = grain() + r = random.Random(seed) + ys = (np.arange(h) + r.randrange(1024)) % 1024 + xs = (np.arange(w) + r.randrange(1024)) % 1024 + out = c[None, None, :] * (1 + k * g[np.ix_(ys, xs)][..., None]) + return np.clip(out, 0, 255).astype(np.uint8) + + +# ---------------------------------------------------------------- shapes +def rrect_pts(x, y, w, h, r): + r = max(0.0, min(r, w / 2, h / 2)) + pts = [] + for cx, cy, a0 in [(x + w - r, y + r, -90), (x + w - r, y + h - r, 0), + (x + r, y + h - r, 90), (x + r, y + r, 180)]: + for k in range(9): + a = math.radians(a0 + 90 * k / 8) + pts.append((cx + r * math.cos(a), cy + r * math.sin(a))) + return pts + + +def ellipse_pts(cx, cy, rx, ry, n=None): + n = n or max(28, int((rx + ry) / 2.5)) + return [(cx + rx * math.cos(2 * math.pi * k / n), cy + ry * math.sin(2 * math.pi * k / n)) + for k in range(n)] + + +def star_pts(cx, cy, r1, r2, n=5, rot=-90): + pts = [] + for k in range(2 * n): + r = r1 if k % 2 == 0 else r2 + a = math.radians(rot + 180 * k / n) + pts.append((cx + r * math.cos(a), cy + r * math.sin(a))) + return pts + + +def gear_pts(cx, cy, r1, r2, teeth): + pts = [] + for k in range(teeth * 4): + a = 2 * math.pi * k / (teeth * 4) + r = r2 if (k % 4) in (1, 2) else r1 + pts.append((cx + r * math.cos(a), cy + r * math.sin(a))) + return pts + + +def rot_rect_pts(cx, cy, w, h, ang): + a = math.radians(ang) + ca, sa = math.cos(a), math.sin(a) + return [(cx + x * ca - y * sa, cy + x * sa + y * ca) + for x, y in [(-w / 2, -h / 2), (w / 2, -h / 2), (w / 2, h / 2), (-w / 2, h / 2)]] + + +def roughen(pts, amp, seed, step=7): + if amp <= 0 or len(pts) < 3: + return pts + p = [] + n0 = len(pts) + for i in range(n0): + x0, y0 = pts[i] + x1, y1 = pts[(i + 1) % n0] + n = max(1, int(math.hypot(x1 - x0, y1 - y0) / step)) + for k in range(n): + p.append((x0 + (x1 - x0) * k / n, y0 + (y1 - y0) * k / n)) + p = np.array(p, np.float32) + rng = np.random.default_rng(seed) + a = rng.standard_normal(p.shape).astype(np.float32) + a = sum(np.roll(a, s, axis=0) for s in range(-1, 2)) + b = rng.standard_normal(p.shape).astype(np.float32) + b = sum(np.roll(b, s, axis=0) for s in range(-6, 7)) + a *= 0.6 * amp / (a.std() + 1e-6) + b *= 0.9 * amp / (b.std() + 1e-6) + return [tuple(v) for v in (p + a + b)] + + +# ---------------------------------------------------------------- art builder +class Art: + """Stack of paper layers. Each layer gets grain and casts a soft shadow on layers below.""" + + def __init__(self, w, h, key): + self.w, self.h, self.key = int(math.ceil(w)), int(math.ceil(h)), key + self.ops = [] + + def _seed(self): + return crc(self.key, len(self.ops)) + + def poly(self, pts, color, rough=1.0, shade=True, tex=0.035, opacity=1.0): + self.ops.append(dict(kind='poly', pts=list(pts), color=color, rough=rough, shade=shade, + tex=tex, opacity=opacity, seed=self._seed())) + return self + + def rrect(self, x, y, w, h, r, color, **kw): + return self.poly(rrect_pts(x, y, w, h, r), color, **kw) + + def ellipse(self, cx, cy, rx, ry, color, **kw): + return self.poly(ellipse_pts(cx, cy, rx, ry), color, **kw) + + def circle(self, cx, cy, r, color, **kw): + return self.ellipse(cx, cy, r, r, color, **kw) + + def text(self, cx, cy, s, font, color, h='m', v='m', spacing=6, align='center', + shade=False, tex=0.0, opacity=1.0): + s = T(s) + font = resolve(font, s) + self.ops.append(dict(kind='text', x=cx, y=cy, s=s, font=font, color=color, h=h, v=v, + spacing=spacing, align=align, shade=shade, tex=tex, + opacity=opacity, seed=self._seed())) + return self + + def lines(self, pls, width, color, shade=False, tex=0.0, caps=True, opacity=1.0): + self.ops.append(dict(kind='lines', pls=pls, width=width, color=color, shade=shade, + tex=tex, caps=caps, opacity=opacity, seed=self._seed())) + return self + + def build(self, pad=30): + P = pad + w2, h2 = self.w + 2 * P, self.h + 2 * P + acc = Image.new('RGBA', (w2, h2), (0, 0, 0, 0)) + + def T(x, y): + return ((x + P) * SS, (y + P) * SS) + + for op in self.ops: + m = Image.new('L', (w2 * SS, h2 * SS), 0) + d = ImageDraw.Draw(m) + if op['kind'] == 'poly': + pts = roughen(op['pts'], op['rough'], op['seed']) + d.polygon([T(x, y) for x, y in pts], fill=255) + elif op['kind'] == 'text' and op['font'][0] == 'rich': + get_font(op['font'], SS).draw(d, *T(op['x'], op['y']), op['s'], op['spacing'] * SS, + op['h'], op['v'], 255) + elif op['kind'] == 'text': + l, t, r, b = text_bbox(op['s'], op['font'], op['spacing'], op['align'], op['h']) + x = op['x'] - ((l + r) / 2 if op['h'] == 'm' else l) + y = op['y'] - ((t + b) / 2 if op['v'] == 'm' else t) + d.multiline_text(T(x, y), op['s'], font=get_font(op['font'], SS), fill=255, + anchor=op['h'] + 'a', spacing=op['spacing'] * SS, + align=op['align']) + else: + wd = max(1, int(op['width'] * SS)) + for pl in op['pls']: + q = [T(x, y) for x, y in pl] + d.line(q, fill=255, width=wd, joint='curve') + if op['caps']: + for qx, qy in (q[0], q[-1]): + d.ellipse([qx - wd / 2, qy - wd / 2, qx + wd / 2, qy + wd / 2], fill=255) + m = m.resize((w2, h2), Image.BOX) + if op['opacity'] < 1: + o = op['opacity'] + m = m.point(lambda v: int(v * o)) + if op['shade']: + sh = ImageChops.offset(m.filter(ImageFilter.GaussianBlur(2.4)), 2, 3) + sh = ImageChops.multiply(sh, acc.getchannel('A')).point(lambda v: int(v * 0.40)) + sl = Image.new('RGBA', (w2, h2), (30, 20, 10, 0)) + sl.putalpha(sh) + acc = Image.alpha_composite(acc, sl) + layer = Image.fromarray(textured(op['color'], w2, h2, op['seed'], op['tex']), 'RGB') + layer = layer.convert('RGBA') + layer.putalpha(m) + acc = Image.alpha_composite(acc, layer) + return Sprite(acc, self.key) + + +class Sprite: + def __init__(self, img, key, shadow_alpha=0.40, blur=6): + arr = np.array(img) + a = arr[..., 3] + hole = a == 0 + if hole.any() and not hole.all(): + iy, ix = ndimage.distance_transform_edt(hole, return_distances=False, return_indices=True) + for ch in range(3): + c = arr[..., ch] + c[hole] = c[iy[hole], ix[hole]] + self.img = Image.fromarray(arr, 'RGBA') + self.key = key + sa = img.getchannel('A').filter(ImageFilter.GaussianBlur(blur)).point( + lambda v: int(v * shadow_alpha)) + self.shadow = Image.new('RGBA', img.size, (35, 25, 15, 0)) + self.shadow.putalpha(sa) + self.w, self.h = img.size + + +def comp(dst, src, x, y): + sx0, sy0 = max(0, -x), max(0, -y) + dx0, dy0 = max(0, x), max(0, y) + w = min(src.width - sx0, dst.width - dx0) + h = min(src.height - sy0, dst.height - dy0) + if w <= 0 or h <= 0: + return + if sx0 or sy0 or w < src.width or h < src.height: + src = src.crop((sx0, sy0, sx0 + w, sy0 + h)) + dst.alpha_composite(src, (dx0, dy0)) + + +def _fade(img, alpha): + img = img.copy() + img.putalpha(img.getchannel('A').point(lambda v: int(v * alpha))) + return img + + +def place(frame, spr, x, y, rot=0.0, sx=1.0, sy=None, alpha=1.0, elev=1.0, fi=0, boil=1.0, + shadow=True): + sy = sx if sy is None else sy + if alpha <= 0.01 or sx <= 0.02 or sy <= 0.02: + return + if boil: + r = random.Random(crc(spr.key, fi // 2)) + x += r.uniform(-1.1, 1.1) * boil + y += r.uniform(-1.1, 1.1) * boil + rot += r.uniform(-0.4, 0.4) * boil + img, sh = spr.img, spr.shadow + if abs(sx - 1) > 1e-3 or abs(sy - 1) > 1e-3: + size = (max(1, int(img.width * sx)), max(1, int(img.height * sy))) + img = img.resize(size, Image.BILINEAR) + sh = sh.resize(size, Image.BILINEAR) if shadow else sh + if abs(rot) > 0.05: + img = img.rotate(rot, Image.BICUBIC, expand=True) + if shadow: + sh = sh.rotate(rot, Image.BILINEAR, expand=True) + if alpha < 0.999: + img = _fade(img, alpha) + if shadow: + sh = _fade(sh, alpha) + if shadow and elev > 0: + comp(frame, sh, int(round(x - sh.width / 2 + 6 * elev)), int(round(y - sh.height / 2 + 9 * elev))) + comp(frame, img, int(round(x - img.width / 2)), int(round(y - img.height / 2))) + + +class Ctx: + def __init__(self, frame, t, fi, dur): + self.frame, self.t, self.fi, self.dur = frame, t, fi, dur + + def put(self, spr, x, y, rot=0.0, s=1.0, sy=None, alpha=1.0, elev=1.0, boil=1.0, shadow=True): + place(self.frame, spr, x, y, rot, s, sy, alpha, elev, self.fi, boil, shadow) + + def _out(self, out, od): + if out is None or self.t <= out: + return 1.0, 1.0 + q = prog(self.t, out, od) + return 1 - 0.85 * ease_in(q), 1 - q + + def pop(self, spr, x, y, t0, d=0.55, rot=0.0, s=1.0, elev=1.0, out=None, od=0.35, alpha=1.0, + shadow=True): + if self.t < t0: + return False + p = prog(self.t, t0, d) + e = ease_out(p) + sc = s * (0.2 + 0.8 * ease_back(p)) + sg = 1 if crc(spr.key) % 2 else -1 + so, ao = self._out(out, od) + if ao <= 0.01: + return False + self.put(spr, x, y, rot + (1 - e) * 14 * sg, sc * so, None, alpha * min(1, p * 4) * ao, + elev + 3 * (1 - e), shadow=shadow) + return True + + def slide(self, spr, p0, p1, t0, d=0.8, rot=0.0, s=1.0, elev=1.0, arc=0.0, out=None, od=0.35, + rot0=None, s0=None, before=False, lift=2.5): + if self.t < t0 and not before: + return False + p = prog(self.t, t0, d) + e = ease_io(p) + x = lerp(p0[0], p1[0], e) + y = lerp(p0[1], p1[1], e) - arc * math.sin(math.pi * e) + r = rot if rot0 is None else lerp(rot0, rot, e) + sc = s if s0 is None else lerp(s0, s, e) + so, ao = self._out(out, od) + if ao <= 0.01: + return False + self.put(spr, x, y, r, sc * so, None, ao, elev + lift * math.sin(math.pi * p)) + return True + + def unfold(self, spr, x, y, t0, d=0.6, rot=0.0, s=1.0, elev=1.0, out=None, od=0.35): + if self.t < t0: + return False + p = prog(self.t, t0, d) + so, ao = self._out(out, od) + if ao <= 0.01: + return False + self.put(spr, x, y, rot, s * max(0.03, ease_back(p, 1.3)) * so, s * so, ao * min(1, p * 5), + elev + 2 * (1 - p)) + return True + + +# ---------------------------------------------------------------- backgrounds / wipe +@functools.lru_cache(None) +def background(color): + rgb = textured(color, W, H, crc('bg', color), 0.04).astype(np.float32) + yy, xx = np.mgrid[0:H, 0:W] + v = ((xx - W / 2) / (W * 0.72)) ** 2 + ((yy - H / 2) / (H * 0.72)) ** 2 + rgb *= (1 - 0.10 * np.clip(v, 0, 1.6))[..., None] + img = Image.fromarray(np.clip(rgb, 0, 255).astype(np.uint8)).convert('RGBA') + ov = Image.new('RGBA', (W, H), (0, 0, 0, 0)) + d = ImageDraw.Draw(ov) + for (a, b) in [((0, H * 0.18), (W, H * 0.92)), ((W * 0.64, 0), (W * 0.30, H))]: + d.line([a, b], fill=(40, 30, 20, 16), width=3) + d.line([(a[0] + 3, a[1] - 3), (b[0] + 3, b[1] - 3)], fill=(255, 255, 255, 26), width=3) + return Image.alpha_composite(img, ov.filter(ImageFilter.GaussianBlur(1.2))) + + +SHEET_M = 110 + + +@functools.lru_cache(None) +def sheet(color): + bg = background(color) + M = SHEET_M + base = Image.new('RGBA', (W + M, H)) + base.paste(bg, (M, 0)) + base.paste(bg.crop((0, 0, M, H)).transpose(Image.FLIP_LEFT_RIGHT), (0, 0)) + rng = random.Random(crc('tear', color)) + edge, core = [], [] + for y in range(0, H + 15, 12): + x = M * 0.62 + rng.uniform(-14, 14) + 12 * math.sin(y / 85.0) + edge.append((x, y)) + core.append((x - rng.uniform(4, 11), y)) + mask = Image.new('L', (W + M, H), 0) + ImageDraw.Draw(mask).polygon(edge + [(W + M, H), (W + M, 0)], fill=255) + cmask = Image.new('L', (W + M, H), 0) + ImageDraw.Draw(cmask).polygon(core + [(W + M, H), (W + M, 0)], fill=255) + out = Image.new('RGBA', (W + M, H), (0, 0, 0, 0)) + sh = ImageChops.offset(cmask.filter(ImageFilter.GaussianBlur(9)), -12, 0).point(lambda v: int(v * 0.35)) + sl = Image.new('RGBA', out.size, (30, 20, 10, 0)) + sl.putalpha(sh) + out = Image.alpha_composite(out, sl) + cl = Image.new('RGBA', out.size, shade(color, 1.6) + (255,)) + cl.putalpha(cmask) + out = Image.alpha_composite(out, cl) + b2 = base.copy() + b2.putalpha(mask) + return Image.alpha_composite(out, b2) + + +def draw_wipe(frame, color, p): + x = int(round(lerp(W, -SHEET_M, ease_io(p)))) + comp(frame, sheet(color), x, 0) diff --git a/intro-video/paper/requirements.txt b/intro-video/paper/requirements.txt new file mode 100644 index 00000000..3ecb079b --- /dev/null +++ b/intro-video/paper/requirements.txt @@ -0,0 +1,3 @@ +numpy +scipy +pillow diff --git a/intro-video/paper/video.py b/intro-video/paper/video.py new file mode 100644 index 00000000..1f69acad --- /dev/null +++ b/intro-video/paper/video.py @@ -0,0 +1,1201 @@ +"""MobileGL intro video — paper-cutout style. `python video.py stills 3.0 25.5 ...` or `python video.py render`.""" +import sys, json, time, subprocess +from multiprocessing import Pool +from paper import * + +cache = functools.lru_cache(None) + + +# ================================================================ generic sprites +@cache +def spr_label(text, font=('fredoka', 40, 'SemiBold'), bg=WHITE, fg=INK, padx=30, pady=16, r=10, + maxw=None, rough=1.0, spacing=6, align='center'): + if maxw: + text = wrap(text, font, maxw) + tw, th = measure(text, font, spacing) + w, h = tw + 2 * padx, th + 2 * pady + a = Art(w, h, f'label|{text}|{font}|{bg}|{fg}') + a.rrect(0, 0, w, h, r, bg, rough=rough) + a.text(w / 2, h / 2, text, font, fg, spacing=spacing, align=align) + return a.build() + + +@cache +def spr_tile(ch, color): + a = Art(160, 180, f'tile|{ch}|{color}') + a.poly([(0, 14), (14, 0), (114, 0), (160, 46), (160, 166), (146, 180), (14, 180), (0, 166)], + color, rough=0.8) + a.poly([(114, 0), (114, 46), (160, 46)], shade(color, 1.3), rough=0.4) + a.text(78, 94, ch, ('fredoka', 122, 'Bold'), WHITE, shade=True) + return a.build() + + +@cache +def spr_confetti(kind, color, size): + a = Art(size, size, f'conf|{kind}|{color}|{size}') + if kind == 0: + a.poly([(0, size), (size / 2, 0), (size, size)], color, rough=0.5) + elif kind == 1: + a.circle(size / 2, size / 2, size / 2, color, rough=0.5) + elif kind == 2: + a.rrect(0, 0, size, size, 3, color, rough=0.5) + else: + a.poly(star_pts(size / 2, size / 2, size / 2, size / 4.5), color, rough=0.3) + return a.build(pad=16) + + +@cache +def spr_crane(color): + lt, dk, dd = shade(color, 1.25), shade(color, 0.8), shade(color, 0.62) + a = Art(330, 230, f'crane|{color}') + a.poly([(178, 128), (252, 26), (206, 140)], dd, rough=0.5) # far wing + a.poly([(28, 66), (142, 150), (118, 164)], dk, rough=0.5) # tail + a.poly([(110, 150), (170, 116), (234, 150), (170, 182)], color, rough=0.5) # body + a.poly([(110, 150), (170, 182), (156, 150)], dk, rough=0.4, shade=False) + a.poly([(214, 148), (292, 50), (230, 158)], dk, rough=0.4) # neck + a.poly([(292, 50), (324, 66), (286, 64)], dd, rough=0.3) # head + a.poly([(138, 142), (92, 10), (194, 134)], lt, rough=0.5) # near wing + a.poly([(138, 142), (92, 10), (150, 120)], color, rough=0.4, shade=False) + return a.build() + + +@cache +def spr_plane(color): + a = Art(170, 96, f'plane|{color}') + a.poly([(0, 18), (170, 48), (52, 48)], shade(color, 1.25), rough=0.4) + a.poly([(52, 48), (170, 48), (72, 64)], shade(color, 0.65), rough=0.3) + a.poly([(52, 48), (170, 48), (22, 88)], color, rough=0.4) + return a.build() + + +@cache +def spr_bubble(text, font=('mono', 32), bg=WHITE, fg=INK, tail='bl'): + tw, th = measure(text, font) + w, h = tw + 60, th + 40 + a = Art(w, h + 34, f'bubble|{text}|{bg}|{tail}') + tp = [(40, h - 8), (92, h - 8), (28, h + 32)] if tail == 'bl' else \ + [(w - 92, h - 8), (w - 40, h - 8), (w - 28, h + 32)] + a.poly(tp, bg, rough=0.6, shade=False) + a.rrect(0, 0, w, h, 22, bg, shade=False) + a.text(w / 2, h / 2, text, font, fg) + return a.build() + + +@cache +def spr_appwin(): + a = Art(560, 400, 'appwin') + a.rrect(0, 0, 560, 400, 22, DARK) + a.circle(34, 28, 9, CORAL, shade=False, rough=0.3) + a.circle(60, 28, 9, MUSTARD, shade=False, rough=0.3) + a.circle(86, 28, 9, GREEN, shade=False, rough=0.3) + a.text(300, 28, 'my_gl_game', ('hand', 30), (225, 222, 215)) + a.rrect(14, 52, 532, 334, 10, SKY, rough=0.6) + a.circle(462, 118, 34, MUSTARD) + a.ellipse(170, 112, 52, 22, WHITE, rough=0.7) + a.ellipse(206, 98, 34, 22, WHITE, rough=0.7) + heights = [3, 3, 4, 4, 3, 2, 2, 3, 4, 5, 5, 4] + sky = [(14, 386)] + for i, hh in enumerate(heights): + top = 386 - hh * 34 + sky += [(14 + 44 * i, top), (14 + 44 * (i + 1), top)] + sky += [(546, 386)] + a.poly(sky, DIRT, rough=0.5) + for i, hh in enumerate(heights): + top = 386 - hh * 34 + a.rrect(14 + 44 * i, top, 44, 14, 2, GREEN, rough=0.4, shade=i % 2 == 0) + a.rrect(152, 160, 18, 76, 2, DIRT, rough=0.4) # voxel tree + a.rrect(120, 104, 82, 66, 4, shade(GREEN, 0.8), rough=0.5) + return a.build() + + +@cache +def spr_phone(screen=BG_SKY, w=230, h=440): + a = Art(w, h, f'phone|{screen}|{w}') + a.rrect(0, 0, w, h, 34, DARK) + a.rrect(14, 46, w - 28, h - 100, 12, screen, rough=0.6) + a.rrect(w / 2 - 30, 20, 60, 10, 5, (30, 32, 38), shade=False) + a.circle(w / 2, h - 28, 14, (84, 88, 98), shade=False) + return a.build() + + +@cache +def spr_tablet(screen=BG_MINT): + a = Art(400, 300, f'tablet|{screen}') + a.rrect(0, 0, 400, 300, 28, DARK) + a.rrect(40, 18, 320, 264, 10, screen, rough=0.6) + a.circle(20, 150, 8, (84, 88, 98), shade=False) + return a.build() + + +@cache +def spr_laptop(screen=SKY, s=1.0): + a = Art(360, 240, f'laptop|{screen}') + a.rrect(30, 0, 300, 196, 14, DARK) + a.rrect(44, 14, 272, 166, 6, screen, rough=0.5) + a.poly([(0, 202), (360, 202), (338, 236), (22, 236)], GREY, rough=0.6) + return a.build() + + +@cache +def spr_gpu(size=220, label='GPU'): + a = Art(size, size, f'gpu|{size}|{label}') + pins = [] + n = 5 + e0, e1 = size * 0.03, size * 0.2 + for k in range(n): + o = size * (0.26 + 0.48 * k / (n - 1)) + pins += [[(o, e0), (o, e1)], [(o, size - e0), (o, size - e1)], + [(e0, o), (e1, o)], [(size - e0, o), (size - e1, o)]] + a.lines(pins, size * 0.05, GREY, caps=False, tex=0.03, shade=False) + m1, m2 = size * 0.14, size * 0.25 + a.rrect(m1, m1, size - 2 * m1, size - 2 * m1, size * 0.07, DARK) + a.rrect(m2, m2, size - 2 * m2, size - 2 * m2, size * 0.04, (84, 92, 108)) + a.text(size / 2, size / 2, label, ('fredoka', int(size * 0.2), 'SemiBold'), WHITE) + return a.build() + + +@cache +def spr_slab(text, w, color, h=110, size=52, fg=WHITE): + a = Art(w, h, f'slab|{text}|{w}|{color}') + a.rrect(0, 0, w, h, 18, color) + a.rrect(10, 10, w - 20, h - 20, 12, shade(color, 1.08), shade=False, rough=0.6) + a.text(w / 2, h / 2, text, ('fredoka', size, 'SemiBold'), fg) + return a.build() + + +@cache +def spr_arrow(L=120, color=INK, wd=22, head=46): + a = Art(L, head, f'arrow|{L}|{color}|{wd}|{head}') + cy, hx = head / 2, L - head * 0.9 + a.poly([(0, cy - wd / 2), (hx, cy - wd / 2), (hx, 0), (L, cy), (hx, head), (hx, cy + wd / 2), + (0, cy + wd / 2)], color, rough=0.6) + return a.build() + + +@cache +def spr_arrow_half(side): + rng = random.Random(5 if side == 'L' else 6) + head, wd, cy = 70, 34, 35 + if side == 'L': + L = 150 + jag = [(L + rng.uniform(-10, 10), cy - wd / 2 + wd * k / 6) for k in range(7)] + pts = [(0, cy - wd / 2)] + jag + [(0, cy + wd / 2)] + else: + L = 150 + hx = L - head * 0.9 + jag = [(rng.uniform(-10, 10), cy + wd / 2 - wd * k / 6) for k in range(7)] + pts = jag + [(hx, cy - wd / 2), (hx, 0), (L, cy), (hx, head), (hx, cy + wd / 2)] + a = Art(L, head, f'arrowhalf|{side}') + a.poly(pts, CORAL, rough=0.6) + return a.build() + + +@cache +def spr_sticker(text, color, r=80, size=34, fg=WHITE): + a = Art(2 * r, 2 * r, f'sticker|{text}|{color}|{r}') + a.circle(r, r, r, color) + a.circle(r, r, r - 10, shade(color, 1.1), shade=False, rough=0.5) + a.text(r, r, text, ('fredoka', size, 'SemiBold'), fg, spacing=2) + return a.build() + + +@cache +def spr_qmark(): + return spr_sticker('?', MUSTARD, r=62, size=96) + + +@cache +def spr_star(color=MUSTARD, r=40): + a = Art(2 * r, 2 * r, f'star|{color}|{r}') + a.poly(star_pts(r, r, r, r * 0.42), color, rough=0.4) + return a.build(pad=18) + + +@cache +def spr_gear(r, color): + a = Art(2 * r, 2 * r, f'gear|{r}|{color}') + a.poly(gear_pts(r, r, r * 0.78, r, 10), color, rough=0.4) + a.circle(r, r, r * 0.28, shade(color, 0.7), rough=0.3) + return a.build(pad=16) + + +@cache +def spr_cloud(r=34): + a = Art(3 * r, 2 * r, f'cloud|{r}') + a.circle(r * 0.9, r * 1.15, r * 0.8, WHITE, shade=False, rough=0.6) + a.circle(r * 1.6, r * 0.95, r * 0.95, WHITE, shade=False, rough=0.6) + a.circle(r * 2.2, r * 1.25, r * 0.7, WHITE, shade=False, rough=0.6) + return a.build(pad=16) + + +@cache +def spr_house(roof, w=360, h=330): + a = Art(w, h, f'house|{roof}|{w}') + a.rrect(w * 0.68, 22, 44, 96, 4, shade(roof, 0.7)) + a.rrect(32, 128, w - 64, h - 128, 8, PAPER) + a.poly([(0, 142), (w / 2, 8), (w, 142)], roof) + a.rrect(w / 2 - 34, h - 112, 68, 112, 10, shade(roof, 0.75)) + a.circle(w / 2 + 20, h - 56, 5, MUSTARD, shade=False, rough=0.2) + a.rrect(64, 168, 64, 58, 6, SKY, rough=0.5) + a.rrect(w - 128, 168, 64, 58, 6, SKY, rough=0.5) + return a.build() + + +HOUSE_CHIMNEY = (360 * 0.68 + 22 - 180, 22 - 165) # offset from house centre + + +@cache +def spr_ink(text, font, color): + tw, th = measure(text, font) + a = Art(tw + 12, th + 12, f'ink|{text}|{font}|{color}') + a.text((tw + 12) / 2, (th + 12) / 2, text, font, color, shade=True) + return a.build() + + +def wrap_balanced(s, font, maxw): + n = wrap(s, font, maxw).count(chr(10)) + if n == 0: + return s + lo, hi = 200.0, float(maxw) + while hi - lo > 6: + mid = (lo + hi) / 2 + if wrap(s, font, mid).count(chr(10)) <= n: + hi = mid + else: + lo = mid + return wrap(s, font, hi) + + +# ================================================================ captions / tags +CAP_FONT = ('hand', 52) + + +@cache +def spr_caption(text): + s = wrap_balanced(text, CAP_FONT, 1560) + tw, th = measure(s, CAP_FONT, 4) + w, h = max(tw + 90, 520), th + 38 + a = Art(w, h, 'cap|' + text) + a.rrect(0, 0, w, h, 6, PAPER, rough=1.6) + a.text(w / 2, h / 2 + 2, s, CAP_FONT, INK, spacing=4) + a.poly(rot_rect_pts(26, 2, 96, 32, -10), (248, 232, 168), rough=0.5, opacity=0.78, tex=0.02, shade=False) + a.poly(rot_rect_pts(w - 26, 2, 96, 32, 9), (248, 232, 168), rough=0.5, opacity=0.78, tex=0.02, shade=False) + return a.build() + + +def draw_caption(c, t0, t1, text, idx): + if c.t < t0 or c.t > t1 + 0.35: + return + spr = spr_caption(text) + p = prog(c.t, t0, 0.45) + q = prog(c.t, t1, 0.32) + y = 1046 - (spr.h - 60) / 2 + y += (1 - ease_back(p, 1.2)) * 200 + ease_in(q) * 220 + rot = (0.8 if idx % 2 else -0.8) + c.put(spr, 960, y, rot, 1, None, 1, 1.2) + + +@cache +def spr_tag(text, color): + tw, th = measure(text, ('fredoka', 36, 'SemiBold')) + w, h = tw + 100, 70 + a = Art(w, h, f'tag|{text}|{color}') + a.poly([(0, 0), (w - 34, 0), (w, h / 2), (w - 34, h), (0, h)], color, rough=1.0) + a.circle(w - 40, h / 2, 9, shade(color, 0.6), shade=False, rough=0.3) + a.text(28, h / 2, text, ('fredoka', 36, 'SemiBold'), WHITE, h='l') + return a.build() + + +def draw_tag(c, text, color): + spr = spr_tag(text, color) + p = prog(c.t, 0.15, 0.7) + x = lerp(-spr.w / 2 - 20, spr.w / 2 - 20, ease_back(p, 1.1)) + c.put(spr, x, 82, -1.5, 1, None, 1, 1.4) + + +# ================================================================ shared bits +def confetti(c, seed, t0, n=16, box=(230, 220, 1690, 760), cols=(CORAL, MUSTARD, TEAL, BLUE, PLUM, ORANGE)): + rng = random.Random(seed) + k = 0 + while k < n: + x, y = rng.uniform(70, 1850), rng.uniform(150, 880) + kind, col, size, rot = rng.randrange(4), rng.choice(cols), rng.randrange(26, 56), rng.uniform(-40, 40) + if box[0] < x < box[2] and box[1] < y < box[3]: + continue + bob = 5 * math.sin(c.t * 1.7 + k) + c.pop(spr_confetti(kind, col, size), x, y + bob, t0 + k * 0.09, rot=rot, elev=0.6) + k += 1 + + +def crane_fly(c, spr, t0, t1, p0, p1, ctrl, s=1.0, flip=False): + if c.t < t0: + return + p = ease_io(prog(c.t, t0, t1 - t0)) + x = (1 - p) ** 2 * p0[0] + 2 * (1 - p) * p * ctrl[0] + p * p * p1[0] + y = (1 - p) ** 2 * p0[1] + 2 * (1 - p) * p * ctrl[1] + p * p * p1[1] + dx = 2 * (1 - p) * (ctrl[0] - p0[0]) + 2 * p * (p1[0] - ctrl[0]) + dy = 2 * (1 - p) * (ctrl[1] - p0[1]) + 2 * p * (p1[1] - ctrl[1]) + rot = -math.degrees(math.atan2(dy, abs(dx) + 1e-6)) * 0.35 if p < 1 else 0 + flap = 1 + 0.08 * math.sin(c.t * 10) if p < 1 else 1 + 0.03 * math.sin(c.t * 3) + y += 10 * math.sin(c.t * 2.1) + c.put(spr, x, y, rot, s, s * flap, 1, 4.5) + + +TITLE_COLS = [CORAL, MUSTARD, TEAL, ORANGE, PLUM, CORAL, BRAND, BRAND] + + +def title_tiles(c, y, t0, pops=None): + for i, ch in enumerate('MobileGL'): + tt = t0 + 0.16 * i + if c.t < tt: + continue + p = prog(c.t, tt, 0.65) + yy = y - 560 * (1 - ease_back(p, 1.6)) + 3 * math.sin(c.t * 2 + i) + rot = (3.5 if i % 2 else -3.5) + (1 - ease_out(p)) * (25 if i % 2 else -25) + c.put(spr_tile(ch, TITLE_COLS[i]), 960 - 172 * 3.5 + 172 * i, yy, rot, 1, None, 1, + 1 + 3 * (1 - ease_out(p))) + + +# ================================================================ scene 1: title +def s_title(c): + confetti(c, 11, 0.4) + title_tiles(c, 400, 0.3) + c.unfold(spr_label('a desktop OpenGL implementation', ('fredoka', 50, 'Medium'), WHITE, INK, + padx=40), 960, 612, 2.4, rot=-1) + crane_fly(c, spr_crane(CORAL), 1.2, 4.4, (-260, 560), (1650, 190), (760, -40)) + + +# ================================================================ scene 2: the problem +def s_problem(c): + c.pop(spr_appwin(), 560, 460, 0.4, rot=-2) + c.pop(spr_bubble('glDrawElements(…)'), 700, 178, 2.0, rot=2) + c.pop(spr_bubble('glUniform4f(…)'), 360, 205, 3.0, rot=-3) + c.pop(spr_phone(BG_PEACH), 1320, 480, 6.3, rot=3) + c.pop(spr_tablet(BG_MINT), 1665, 510, 6.8, rot=-2) + c.pop(spr_bubble('OpenGL ES', ('fredoka', 42, 'SemiBold'), TEAL, WHITE), 1360, 175, 8.0, rot=-2) + c.pop(spr_bubble('Vulkan', ('fredoka', 42, 'SemiBold'), PLUM, WHITE, 'br'), 1680, 270, 8.6, rot=2) + gap = 6 * math.sin(max(0, c.t - 12.6) * 2.4) if c.t > 12.6 else 0 + c.pop(spr_arrow_half('L'), 938 - gap, 470, 12.0, rot=2) + c.pop(spr_arrow_half('R'), 1122 + gap, 470, 12.2, rot=-3) + c.pop(spr_qmark(), 1030, 345, 13.0, rot=8) + + +# ================================================================ scene 3: meet MobileGL +def s_meet(c): + c.pop(spr_slab('Your app', 760, CORAL), 960, 190, 0.5) + c.pop(spr_slab('GPU', 760, DARK), 960, 790, 1.0) + c.pop(spr_slab('OpenGL ES driver', 420, TEAL, size=40), 742, 625, 1.4) + c.pop(spr_slab('Vulkan driver', 420, PLUM, size=40), 1178, 625, 1.6) + c.slide(spr_slab('MobileGL', 900, BRAND, h=130, size=66), (2500, 405), (960, 405), 3.6, d=1.0, + rot0=-8, rot=0, elev=1.2) + if c.t > 4.6: + cr = spr_crane(MUSTARD) + c.pop(cr, 640, 395, 4.6, s=0.45, rot=-6, elev=1.5) + for i, (x, y, t0) in enumerate([(960, 300, 5.2), (742, 522, 5.5), (1178, 522, 5.7), + (742, 710, 5.9), (1178, 710, 6.1)]): + c.pop(spr_arrow(70, INK, 16, 36), x, y, t0, rot=-90, elev=0.5) + c.pop(spr_sticker('OpenGL\n4.6 Core', MUSTARD, r=92, size=32, fg=INK), 1480, 200, 6.4, rot=12) + c.pop(spr_sticker('no app\nchanges', TEAL, r=82, size=30), 450, 405, 17.3, rot=-10) + + +# ================================================================ scene 4: why it works +KNOBS = [(110, 150, 'blend'), (250, 150, 'depth'), (390, 150, 'cull'), (530, 150, 'stencil')] +TOGGLES = [(110, 330, 'texture 0'), (250, 330, 'program'), (390, 330, 'VAO'), (530, 330, 'viewport')] +BOARD_STATES = [ # (time, knob angles, toggles on) + (0.0, (30, -40, 90, 0), (1, 0, 1, 1)), + (13.4, (-60, -40, 90, 0), (1, 0, 1, 1)), + (14.4, (-60, 50, 90, 0), (1, 1, 1, 1)), + (15.4, (-60, 50, 90, 0), (0, 1, 1, 1)), + (19.6, (100, 50, 90, 0), (0, 1, 1, 1)), + (21.6, (100, 50, -20, 120), (1, 1, 0, 1)), +] + + +@cache +def spr_board(): + a = Art(640, 440, 'board') + a.rrect(0, 0, 640, 440, 24, NAVY) + a.rrect(16, 16, 608, 408, 16, shade(NAVY, 1.18), shade=False, rough=0.6) + for x, y, lab in KNOBS: + a.circle(x, y, 48, shade(NAVY, 0.8), shade=False, rough=0.5) + a.text(x, y + 72, lab, ('hand', 30), WHITE) + for x, y, lab in TOGGLES: + a.rrect(x - 20, y - 46, 40, 92, 20, shade(NAVY, 0.7), shade=False, rough=0.4) + a.text(x, y + 72, lab, ('hand', 28), WHITE) + return a.build() + + +@cache +def spr_knob(): + a = Art(80, 80, 'knob') + a.circle(40, 40, 38, MUSTARD) + a.rrect(35, 6, 10, 34, 5, DARK, shade=False, rough=0.3) + return a.build(pad=18) + + +@cache +def spr_lever(on): + a = Art(40, 40, f'lever{on}') + a.circle(20, 20, 19, CORAL if on else WHITE) + return a.build(pad=16) + + +@cache +def spr_recipe(i): + items = [['shaders: A + B', 'blend: off', 'depth: less', 'vertex format #2'], + ['shaders: A + B', 'blend: on', 'depth: always', 'vertex format #2'], + ['shaders: C + B', 'blend: on', 'cull: off', 'vertex format #5']][i] + a = Art(360, 250, f'recipe{i}') + a.rrect(0, 0, 360, 250, 10, WHITE) + a.rrect(0, 0, 360, 58, 10, [TEAL, PLUM, ORANGE][i], shade=False) + a.text(180, 29, f'Draw recipe #{i + 1}', ('fredoka', 30, 'SemiBold'), WHITE) + a.text(30, 76, '\n'.join('• ' + T(s) for s in items), ('hand', 34), INK, h='l', v='t', + align='left', spacing=2) + return a.build() + + +@cache +def spr_ingredient(kind): + a = Art(250, 280, f'ingr|{kind}') + a.rrect(0, 0, 250, 280, 16, WHITE) + cx, cy = 125, 115 + if kind == 'buffers': + for k, col in enumerate([shade(BLUE, 0.8), BLUE, shade(BLUE, 1.25)]): + a.rrect(45 + k * 8, 60 + k * 34, 150, 52, 8, col) + a.text(cx + 12, 160, '01101', ('mono', 26), WHITE) + elif kind == 'textures': + for i in range(4): + for j in range(4): + if (i + j) % 2 == 0: + a.rrect(53 + i * 36, 43 + j * 36, 36, 36, 2, CORAL, rough=0.4, shade=False) + else: + a.rrect(53 + i * 36, 43 + j * 36, 36, 36, 2, MUSTARD, rough=0.4, shade=False) + elif kind == 'shaders': + a.rrect(50, 44, 150, 150, 8, PAPER) + a.rrect(42, 36, 166, 22, 10, shade(PAPER, 0.85)) + a.rrect(42, 182, 166, 22, 10, shade(PAPER, 0.85)) + a.text(66, 72, 'void main()\n{\n color =\n …;\n}', ('mono', 18), INK, h='l', v='t', + align='left', spacing=3) + else: + a.circle(85, cy, 46, NAVY) + a.circle(85, cy, 32, MUSTARD) + a.rrect(80, cy - 30, 10, 28, 4, DARK, shade=False) + a.rrect(155, cy - 52, 40, 104, 20, NAVY) + a.circle(175, cy - 28, 17, CORAL) + a.text(cx, 238, kind, ('fredoka', 38, 'SemiBold'), INK) + return a.build() + + +def board_state(t): + st = BOARD_STATES[0] + prev = st + for s in BOARD_STATES: + if t >= s[0]: + prev, st = st, s + p = ease_back(prog(t, st[0], 0.45), 2.0) if st is not BOARD_STATES[0] else 1 + knobs = [lerp(a, b, p) for a, b in zip(prev[1], st[1])] + return knobs, st[2] + + +def s_why(c): + t = c.t + out1 = 11.0 + c.pop(spr_label('OpenGL', ('fredoka', 46, 'SemiBold'), BRAND, WHITE), 560, 250, 1.0, rot=-3, out=out1) + c.pop(spr_label('OpenGL ES', ('fredoka', 46, 'SemiBold'), TEAL, WHITE), 960, 235, 1.6, rot=2, out=out1 + .1) + c.pop(spr_label('Vulkan', ('fredoka', 46, 'SemiBold'), PLUM, WHITE), 1360, 250, 2.2, rot=-2, out=out1 + .2) + for i, kind in enumerate(['buffers', 'textures', 'shaders', 'state']): + c.pop(spr_ingredient(kind), 480 + 320 * i, 560, 6.3 + 0.45 * i, rot=(-3, 2, -2, 3)[i], + out=out1 + 0.1 * i) + if t < 11.3: + return + BX, BY = 470, 520 + RX, RY = 1450, 540 + c.pop(spr_label('OpenGL: one big switchboard', ('fredoka', 38, 'SemiBold'), WHITE, INK), + BX, 215, 11.6, rot=-1.5) + c.pop(spr_label('Explicit recipes', ('fredoka', 38, 'SemiBold'), WHITE, INK), RX, 215, 17.6, rot=1.5) + if c.pop(spr_board(), BX, BY, 11.4, rot=-1): + knobs, togg = board_state(t) + bp = prog(t, 11.4, 0.55) + sc = 0.2 + 0.8 * ease_back(bp) + for (x, y, _), ang in zip(KNOBS, knobs): + c.put(spr_knob(), BX + (x - 320) * sc, BY + (y - 220) * sc, -ang - 1, sc, None, 1, 0.5) + for (x, y, _), on in zip(TOGGLES, togg): + c.put(spr_lever(on), BX + (x - 320) * sc, BY + (y - 220 + (-24 if on else 24)) * sc, 0, sc, + None, 1, 0.5) + snaps = [18.3, 20.3, 22.3] + c.pop(spr_slab('MobileGL', 300, BRAND, h=80, size=44), 960, 330, 17.6, rot=-3) + for i, ts in enumerate(snaps): + if t >= ts: + fp = prog(t, ts, 0.35) + if fp < 1: + c.put(spr_star(WHITE, 90), BX, BY, fp * 40, 0.4 + 1.6 * fp, None, 1 - fp, 0, shadow=False) + dst = (RX + (-30, 20, -10)[i], RY + 70 - 70 * i) + c.slide(spr_recipe(i), (BX, BY), dst, ts + 0.15, d=1.1, rot=(-4, 3, -1)[i], rot0=20, + s=1, s0=0.3, arc=220, elev=1 + i * 0.3) + + +# ================================================================ scene 5: frontend +NB = (1010, 470) +TABS = [('Textures', CORAL), ('Uniforms', MUSTARD), ('Blend', TEAL), ('Vertex arrays', PLUM)] +SLIPS = [(2.0, 'glBindTexture(GL_TEXTURE_2D, 7)', 0), (3.6, 'glUniform4f(tint, 1, .5, 0, 1)', 1), + (5.2, 'glEnable(GL_BLEND)', 2), (6.8, 'glBindVertexArray(3)', 3), + (8.4, 'glDrawElements(GL_TRIANGLES, …)', None)] + + +@cache +def spr_notebook(): + a = Art(640, 450, 'notebook') + a.rrect(0, 0, 640, 450, 18, BRAND) + a.rrect(22, 18, 292, 414, 8, PAPER, rough=0.8) + a.rrect(326, 18, 292, 414, 8, PAPER, rough=0.8) + ls = [] + for y in range(110, 420, 44): + ls += [[(42, y), (296, y)], [(346, y), (598, y)]] + a.lines(ls, 2, (170, 200, 228), caps=False) + a.lines([[(320, 26), (320, 424)]], 5, shade(BRAND, 0.7), caps=False) + a.text(168, 60, 'GL state', ('hand', 38), shade(BRAND, 1.0)) + a.text(472, 60, 'draws', ('hand', 38), shade(BRAND, 1.0)) + return a.build() + + +@cache +def spr_nbtab(text, color): + a = Art(230, 62, f'nbtab|{text}') + a.rrect(0, 0, 230, 62, 14, color) + a.text(142, 31, text, ('fredoka', 26, 'SemiBold'), WHITE) + return a.build() + + +@cache +def spr_slip(text): + return spr_label(text, ('mono', 30), WHITE, INK, padx=22, pady=14, r=4) + + +@cache +def spr_stamp(text): + font = ('fredoka', 44, 'Bold') + tw, th = measure(text, font) + w, h = tw + 56, th + 44 + a = Art(w, h, 'stamp|' + text) + rp = rrect_pts(0, 0, w, h, 10) + a.lines([rp + [rp[0]]], 7, RED, tex=0.25, opacity=0.9) + a.text(w / 2, h / 2, text, font, RED, tex=0.25, opacity=0.9) + return a.build() + + +def s_frontend(c): + t = c.t + APP = (190, 470) + c.pop(spr_appwin(), APP[0], APP[1], 0.3, s=0.42, rot=-3) + nb_on = t >= 0.6 + lifts = [0] * 4 + for ts, txt, tab in SLIPS: + if tab is not None: + lifts[tab] = 26 * bump(t, ts + 1.1, 1.3) + if nb_on: + for i, (name, col) in enumerate(TABS): + c.pop(spr_nbtab(name, col), NB[0] + 388 + lifts[i], NB[1] - 150 + 92 * i, 0.9 + 0.12 * i, + elev=0.6 + lifts[i] / 20) + c.pop(spr_notebook(), NB[0], NB[1], 0.6, rot=0) + rows = [NB[1] - 225 + y for y in (132, 220, 308, 396)] + for k, (ts, txt, tab) in enumerate(SLIPS): + if tab is not None: + dst = (NB[0] - 152, rows[tab] - 10) + else: + dst = (NB[0] + 152, NB[1] - 225 + 132) + c.slide(spr_slip(txt), APP, dst, ts, d=1.1, s=0.45, s0=0.8, rot=(-2 if k % 2 else 2), rot0=-10, + arc=160, elev=0.3) + # the bad call + bad = spr_slip('glBindTexture(GL_TEXTURE_2D, 999)') + if t >= 12.8: + if t < 15.6: + c.slide(bad, APP, (520, 770), 12.8, d=1.0, s=0.62, s0=0.6, rot=4, rot0=-8, arc=160) + sx, sy = 520, 770 + else: + p = prog(t, 15.6, 1.2) + sx, sy = lerp(520, 60, ease_in(p)), 770 + 420 * ease_in(p) + c.put(bad, sx, sy, 4 + 40 * p, 0.62, None, 1 - p * 0.3, 2) + if t >= 14.1: + sp = prog(t, 14.1, 0.25) + c.put(spr_stamp('GL_INVALID_OPERATION'), sx + 20, sy + 4, -8, 0.55 * (1.8 - 0.8 * ease_out(sp)), + None, min(1, sp * 3) * (1 - prog(t, 16.4, 0.4)), 0, shadow=False) + if t >= 18.5: + bob = 6 * math.sin(t * 2) + c.pop(spr_gpu(170), 1700, 790 + bob, 18.7, rot=4) + for k in range(3): + ph = (t * 0.7 + k / 3) % 1 + c.put(spr_ink('z', ('fredoka', 40 + 12 * k, 'Bold'), BRAND), + 1780 + 50 * ph + 10 * k, 700 - 110 * ph - 10 * k, 10, 0.6 + 0.6 * ph, None, + min(1, (1 - ph) * 3), 0.5) + + +# ================================================================ scene 6: shaders +GLSL = ['#version 460 core', 'in vec3 pos;', 'uniform mat4 mvp;', '', 'void main() {', + ' gl_Position =', ' mvp * vec4(pos, 1);', '}'] +GLSLES = ['#version 320 es', 'precision highp', ' float;', 'in vec3 pos;', 'uniform …;', + 'void main() {', ' gl_Position = …;', '}'] + + +@cache +def spr_scroll(title, lines, color=PAPER, tcol=BRAND): + n = len(lines) + w, h = 340, 130 + n * 31 + a = Art(w, h, f'scroll|{title}') + a.rrect(12, 18, w - 24, h - 36, 4, color) + a.rrect(0, 0, w, 36, 18, shade(color, 0.86)) + a.rrect(0, h - 36, w, 36, 18, shade(color, 0.86)) + a.text(w / 2, 66, title, ('fredoka', 34, 'SemiBold'), tcol) + a.text(34, 96, '\n'.join(lines), ('mono', 21), INK, h='l', v='t', align='left', spacing=10) + return a.build() + + +@cache +def spr_machine(name, color, w=360, h=260): + a = Art(w, h + 70, f'machine|{name}|{color}') + a.poly([(28, 0), (168, 0), (128, 76), (68, 76)], shade(color, 0.8)) + a.rrect(0, 64, w, h, 24, color) + a.rrect(w - 10, 170, 26, 100, 6, DARK) + a.rrect(30, h - 10, w - 60, 66, 12, WHITE) + a.text(w / 2, h + 23, name, ('fredoka', 40, 'SemiBold'), INK) + return a.build() + + +def s_shaders(c): + t = c.t + MX, MY = 900, 450 + if t < 3.0: + c.pop(spr_scroll('GLSL', tuple(GLSL)), 300, 440, 0.4, rot=-3) + else: + p = prog(t, 3.0, 1.3) + if p < 1: + c.put(spr_scroll('GLSL', tuple(GLSL)), lerp(300, MX - 82, ease_io(p)), + lerp(440, MY - 205, ease_io(p)) - 120 * math.sin(math.pi * p), -3 + 20 * p, + 1 - 0.85 * ease_in(p), None, 1, 1 + 2 * math.sin(math.pi * p)) + busy = 3.6 < t < 6.4 + spin = (t * (220 if busy else 30)) + if c.pop(spr_machine('glslang', MUSTARD), MX, MY, 1.0): + shake = 2.5 * math.sin(t * 40) if busy else 0 + c.put(spr_gear(46, CORAL), MX - 60 + shake, MY - 30, spin, 1, None, 1, 0.5) + c.put(spr_gear(34, TEAL), MX + 28 + shake, MY - 50, -spin * 1.35 + 18, 1, None, 1, 0.5) + crane = spr_crane(BLUE) + if t >= 5.8: + if t < 12.6: + c.slide(crane, (MX + 190, MY + 40), (1250, 420), 5.8, d=0.9, s=0.8, s0=0.2, arc=120, elev=1.5) + c.pop(spr_label('SPIR-V', ('fredoka', 40, 'SemiBold'), BLUE, WHITE), 1250, 560, 6.4, rot=-2, + out=12.4) + else: + c.slide(crane, (1250, 420), (1530, 240), 12.6, d=1.0, s=0.7, s0=0.8, arc=60, elev=1.5) + c.slide(crane, (1250, 420), (1380, 680), 12.6, d=1.0, s=0.3, s0=0.8, arc=40, elev=1.0, + out=13.6, od=0.3) + if t >= 12.6: + c.pop(spr_label('Magma (Vulkan): uses it as-is', ('fredoka', 36, 'SemiBold'), PLUM, WHITE), 1560, 380, + 13.4, rot=2) + if c.pop(spr_machine('SPIRV-Cross', TEAL, w=330, h=200), 1380, 700, 12.4, s=0.62): + c.put(spr_gear(34, MUSTARD), 1380 - 40, 700 - 34, t * 160, 0.62, None, 1, 0.5) + c.pop(spr_scroll('GLSL ES', tuple(GLSLES), tcol=TEAL), 1730, 620, 14.6, s=0.78, rot=3) + c.pop(spr_label('Espryt (OpenGL ES): GLSL ES', ('fredoka', 34, 'SemiBold'), TEAL, WHITE), 1560, 862, + 15.0, rot=-2) + + +# ================================================================ scene 7: records +BELT_Y = 560 +BX0, BX1 = 330, 1590 + + +@cache +def spr_belt(): + a = Art(1320, 64, 'belt') + a.rrect(0, 0, 1320, 64, 32, DARK) + a.rrect(14, 12, 1292, 16, 8, shade(DARK, 1.35), shade=False, rough=0.5) + return a.build() + + +@cache +def spr_roller(): + a = Art(40, 40, 'roller') + a.circle(20, 20, 19, GREY) + a.lines([[(20, 4), (20, 36)]], 4, DARK, shade=False) + return a.build(pad=14) + + +@cache +def spr_record(text, color): + tw, th = measure(text, ('hand', 34)) + w, h = tw + 60, 66 + a = Art(w, h, f'record|{text}|{color}') + a.rrect(0, 0, w, h, 8, WHITE) + a.rrect(0, 0, 20, h, 6, color, shade=False) + a.text(20 + (w - 20) / 2, h / 2, text, ('hand', 34), INK) + return a.build() + + +@cache +def spr_box(name, color, w=230, h=170, size=40): + a = Art(w, h, f'box|{name}|{color}|{w}') + a.rrect(0, 0, w, h, 20, color) + a.rrect(16, 16, w - 32, h - 32, 12, shade(color, 1.12), shade=False, rough=0.5) + a.text(w / 2, h / 2, name, ('fredoka', size, 'SemiBold'), WHITE) + return a.build() + + +def ride(c, spr, ts, y=BELT_Y - 62, v=270, extra=None): + """Card spawned at ts from the frontend, rides the belt into the backend.""" + if c.t < ts: + return None + x = BX0 + v * (c.t - ts) + if x > BX1: + p = prog(x, BX1, 110) + if p >= 1: + return None + c.put(spr, x, y - 30 * p, 0, 1 - 0.8 * p, None, 1 - p, 1) + return None + pp = prog(c.t, ts, 0.35) + c.put(spr, x, y - 60 * (1 - ease_back(pp)), 0, 0.6 + 0.4 * ease_out(pp), None, 1, 1) + return x + + +def s_records(c): + t = c.t + c.pop(spr_belt(), (BX0 + BX1) / 2, BELT_Y, 0.4, elev=0.6) + if t >= 0.6: + for k in range(11): + c.put(spr_roller(), BX0 - 10 + k * 128, BELT_Y, -t * 260 + k * 30, 0.9, None, 1, 0.3) + c.pop(spr_box('Frontend', BRAND), 190, 520, 0.6, rot=-2) + c.pop(spr_house(TEAL, 300, 280), 1745, 470, 0.8) + c.pop(spr_label('Backend', ('fredoka', 36, 'SemiBold'), TEAL, WHITE), 1745, 660, 1.0, rot=2) + seq = [(1.6, spr_record('texture 7 on unit 0', CORAL)), (2.6, spr_record('blend: on', TEAL)), + (3.6, spr_record('viewport 1920×1080', MUSTARD)), + (4.7, spr_label('DRAW', ('fredoka', 44, 'Bold'), ORANGE, WHITE, padx=36)), + (8.6, spr_record('uniform tint', MUSTARD)), + (9.7, spr_label('DRAW', ('fredoka', 44, 'Bold'), ORANGE, WHITE, padx=36)), + (24.0, spr_record('texture 12 on unit 1', CORAL)), + (25.0, spr_label('DRAW', ('fredoka', 44, 'Bold'), ORANGE, WHITE, padx=36))] + for ts, spr in seq: + ride(c, spr, ts) + c.pop(spr_sticker('only\nchanges', CORAL, r=78, size=30), 760, 300, 9.0, rot=-8, out=17.8) + # handle tag riding with a new object + x = ride(c, spr_record('new texture', BLUE), 12.4) + if x is not None: + c.put(spr_label('handle {slot 12, gen 3}', ('mono', 24), MUSTARD, INK, padx=14, pady=8, r=6), + x + 10, BELT_Y + 70, -3, 1, None, 1, 0.8) + c.pop(spr_sticker('no\nwaiting', BLUE, r=74, size=30), 1210, 300, 13.4, rot=8, out=17.8) + # dedupe: two identical blocks merge + blk = spr_record('blend on · depth less', PLUM) + MP = (700, 360) + if 18.4 <= t < 21.2: + p = ease_io(prog(t, 18.5, 1.2)) + if p < 1: + c.put(blk, lerp(450, MP[0], p), lerp(250, MP[1], p), lerp(-8, 0, p), 1, None, 1, 2) + c.put(blk, lerp(980, MP[0], p), lerp(440, MP[1], p), lerp(7, 0, p), 1, None, 1, 2) + else: + q = ease_io(prog(t, 20.3, 0.9)) + c.put(blk, lerp(MP[0], BX0, q), lerp(MP[1], BELT_Y - 62, q) - 80 * math.sin(math.pi * q), 0, 1, + None, 1, 2) + if 19.6 <= t < 20.4: + sp = prog(t, 19.6, 0.8) + c.put(spr_star(MUSTARD, 46), MP[0] + 150, MP[1] - 40, sp * 90, 0.4 + 0.9 * math.sin(math.pi * sp), + None, 1, 1) + if t >= 18.4: + c.pop(spr_sticker('sent\nonce', MUSTARD, r=74, size=32, fg=INK), 1060, 280, 19.9, rot=10) + ride(c, blk, 21.2) + + +# ================================================================ scene 8: backends +EH, MH = (560, 470), (1360, 470) + + +def s_backends(c): + t = c.t + c.pop(spr_house(CORAL), EH[0], EH[1], 0.4, rot=-1) + c.pop(spr_house(TEAL), MH[0], MH[1], 0.7, rot=1) + c.pop(spr_label('Espryt', ('fredoka', 52, 'SemiBold'), CORAL, WHITE), EH[0], 700, 6.2, rot=-2) + c.pop(spr_label('speaks OpenGL ES', ('hand', 40), WHITE, INK, pady=8), EH[0], 790, 6.6, rot=1) + c.pop(spr_label('Magma', ('fredoka', 52, 'SemiBold'), TEAL, WHITE), MH[0], 700, 7.6, rot=2) + c.pop(spr_label('speaks Vulkan', ('hand', 40), WHITE, INK, pady=8), MH[0], 790, 8.0, rot=-1) + c.pop(spr_gpu(150), 960, 540, 1.2, rot=-3) + which = 0 if t < 9.5 or (13 < t < 16.5) else 1 + if t >= 9.0: + val = ['DirectGLES', 'DirectVulkan'][which] + k1 = spr_label('MOBILEGL_BACKEND_TYPE=', ('mono', 34), DARK, WHITE, padx=24, pady=14, r=6) + k2 = spr_label(val, ('mono', 34), [CORAL, TEAL][which], WHITE, padx=20, pady=14, r=6) + i1, i2 = k1.w - 60, k2.w - 60 + x0 = 960 - (i1 + i2) / 2 + c.pop(k1, x0 + i1 / 2, 175, 9.0) + c.put(k2, x0 + i1 + i2 / 2 + 8, 175, -2 if which else 2, 1, None, min(1, (t - 9.0) * 4), 1.6) + # records rain into the active house + active = (EH, MH)[which] if t >= 9.0 else (EH if t < 4.8 else MH) + for k in range(40): + ts = 1.6 + k * 0.55 + if not (ts <= t < ts + 1.0): + continue + tgt = (EH, MH)[0 if t < 9.0 and ts < 4.8 else which] if t >= 9.0 or ts < 4.8 else MH + p = prog(t, ts, 1.0) + col = (CORAL, TEAL, MUSTARD, PLUM)[k % 4] + c.put(spr_confetti(2, col, 34), lerp(960, tgt[0], ease_io(p)), lerp(260, tgt[1] + 40, ease_io(p)) + - 120 * math.sin(math.pi * p), p * 200, 1 - 0.4 * p, None, 1 - ease_in(p), 1.5) + # chimney puffs of the active house + for k in range(4): + ph = (t * 0.6 + k / 4) % 1 + hx, hy = active[0] + HOUSE_CHIMNEY[0], active[1] + HOUSE_CHIMNEY[1] + if t > 2: + c.put(spr_cloud(26), hx + 40 * ph, hy - 20 - 140 * ph, 0, 0.5 + 0.8 * ph, None, + min(1, ph * 4) * (1 - ph), 0.4) + if t >= 12.0: + c.pop(spr_sticker('fully\nseparate', MUSTARD, r=80, size=30, fg=INK), 960, 330, 12.2, rot=-6) + + +# ================================================================ scene 9: anywhere +PANEL_X = [285, 735, 1185, 1635] +PANEL_Y = 470 + + +@cache +def spr_fb(name, color, w=140): + a = Art(w, 58, f'fb|{name}|{color}|{w}') + a.rrect(0, 0, w, 58, 14, color) + a.text(w / 2, 29, name, ('fredoka', 26, 'SemiBold'), WHITE) + return a.build() + + +def _wavy(x, y0, y1, amp=7, ph=0.0): + return [(x + amp * math.sin(y / 22 + ph), y) for y in range(int(y0), int(y1) + 1, 6)] + + +def _dashed(x, y, w, h, r=16, dash=14): + pts = rrect_pts(x, y, w, h, r) + pts.append(pts[0]) + out, acc, cur = [], 0.0, [] + for (x0, y0), (x1, y1) in zip(pts, pts[1:]): + L = math.hypot(x1 - x0, y1 - y0) + n = max(1, int(L / 3)) + for k in range(n): + px, py = x0 + (x1 - x0) * k / n, y0 + (y1 - y0) * k / n + on = int(acc / dash) % 2 == 0 + if on: + cur.append((px, py)) + elif cur: + out.append(cur) + cur = [] + acc += L / n + if cur: + out.append(cur) + return [s for s in out if len(s) > 1] + + +@cache +def spr_panel(i): + titles = ['One thread', 'Two threads', 'Two processes', 'Two machines'] + notes = ['monolith', 'inproc', 'spawn + shared memory', 'tcp://host:port'] + a = Art(400, 440, f'panel{i}') + a.rrect(0, 0, 400, 440, 18, WHITE) + a.text(200, 44, titles[i], ('fredoka', 38, 'SemiBold'), INK) + a.text(200, 410, notes[i], ('mono', 22) if i == 3 else ('hand', 30), GREY) + if i == 0: + a.lines([_wavy(200, 92, 370)], 6, CORAL, shade=True) + elif i == 1: + a.lines(_dashed(26, 90, 348, 290), 3, GREY, caps=False) + a.lines([_wavy(115, 100, 368), _wavy(285, 100, 368, ph=2)], 6, CORAL, shade=True) + elif i == 2: + a.lines(_dashed(18, 90, 168, 200) + _dashed(214, 90, 168, 200), 3, GREY, caps=False) + return a.build() + + +@cache +def spr_ring(): + a = Art(130, 130, 'ring') + a.circle(65, 65, 62, DARK) + for k in range(8): + ang = 2 * math.pi * k / 8 + a.poly(rot_rect_pts(65 + 46 * math.cos(ang), 65 + 46 * math.sin(ang), 22, 18, math.degrees(ang)), + (CORAL, MUSTARD, TEAL, BLUE)[k % 4], rough=0.3, shade=False) + a.circle(65, 65, 30, WHITE) + return a.build(pad=16) + + +def s_anywhere(c): + t = c.t + pts = [1.0, 6.0, 11.0, 16.0] + F, B = spr_fb('frontend', BRAND), spr_fb('backend', TEAL) + for i, (x, ts) in enumerate(zip(PANEL_X, pts)): + if not c.pop(spr_panel(i), x, PANEL_Y, ts, rot=(-2, 1.5, -1, 2)[i]): + continue + ox, oy = x - 200, PANEL_Y - 220 + sc = 0.2 + 0.8 * ease_back(prog(t, ts, 0.55)) + if sc < 0.97: + continue + if i == 0: + c.put(F, ox + 200, oy + 180, 0, 1, None, 1, 0.5) + c.put(B, ox + 200, oy + 270, 0, 1, None, 1, 0.5) + elif i == 1: + c.put(spr_fb('frontend', BRAND, 130), ox + 115, oy + 200, 0, 1, None, 1, 0.5) + c.put(spr_fb('backend', TEAL, 130), ox + 285, oy + 290, 0, 1, None, 1, 0.5) + ph = (t * 0.9) % 1 + c.put(spr_confetti(2, MUSTARD, 28), lerp(ox + 130, ox + 270, ph), lerp(oy + 225, oy + 265, ph) + - 60 * math.sin(math.pi * ph), ph * 180, 1, None, min(1, (1 - ph) * 6, ph * 6), 1.2) + elif i == 2: + c.put(spr_fb('frontend', BRAND, 130), ox + 102, oy + 160, 0, 1, None, 1, 0.5) + c.put(spr_fb('backend', TEAL, 130), ox + 298, oy + 230, 0, 1, None, 1, 0.5) + c.put(spr_ring(), ox + 200, oy + 330, -t * 70, 0.85, None, 1, 0.8) + else: + c.put(spr_laptop(SKY), ox + 105, oy + 170, 0, 0.5, None, 1, 0.6) + c.put(spr_phone(BG_PEACH, 230, 440), ox + 320, oy + 250, 4, 0.36, None, 1, 0.6) + c.put(spr_label('client', ('hand', 30), BRAND, WHITE, padx=12, pady=4), ox + 105, oy + 250, -3, + 1, None, 1, 0.5) + c.put(spr_label('server', ('hand', 30), TEAL, WHITE, padx=12, pady=4), ox + 320, oy + 352, 3, + 1, None, 1, 0.5) + for k in range(2): + ph = (t * 0.55 + k * 0.5) % 1 + px = lerp(ox + 140, ox + 300, ph) + py = lerp(oy + 150, oy + 220, ph) - 70 * math.sin(math.pi * ph) + c.put(spr_plane(MUSTARD if k else CORAL), px, py, -20 + 40 * ph, 0.45, None, + min(1, (1 - ph) * 6, ph * 6), 2.5) + c.pop(spr_label('client = frontend, inside your app', ('fredoka', 34, 'SemiBold'), BRAND, WHITE), + 560, 790, 21.8, rot=-2) + c.pop(spr_label('server = backend, next to the GPU', ('fredoka', 34, 'SemiBold'), TEAL, WHITE), + 1360, 800, 22.6, rot=2) + + +# ================================================================ scene 10: perks +@cache +def spr_perk(i): + title, body, color = [ + ('Fast split', 'Two threads or processes can even beat one: both sides work at once.', MUSTARD), + ('Run-ahead', 'Keep sending. Wait only for the answers you truly need.', CORAL), + ('Zero-copy', 'Big buffers and window images are shared, not copied.', BLUE), + ('Safe failure', "Server gone? Your app sees 'device lost', not a crash.", TEAL)][i] + a = Art(410, 500, f'perk{i}') + a.rrect(0, 0, 410, 500, 18, WHITE) + a.rrect(0, 0, 410, 86, 18, color, shade=False) + a.text(205, 44, title, ('fredoka', 42, 'SemiBold'), WHITE if i != 0 else INK) + cx, cy = 205, 190 + if i == 0: + a.lines([_wavy(cx - 50, cy - 70, cy + 70), _wavy(cx + 50, cy - 70, cy + 70, ph=2)], 7, CORAL, shade=True) + a.rrect(cx - 50 - 60, cy - 22, 120, 44, 12, BRAND) + a.rrect(cx + 50 - 60, cy + 4, 120, 44, 12, TEAL) + elif i == 1: + for k in range(3): + px, py, sc = cx - 110 + 85 * k, cy + 40 - 32 * k, 0.55 + a.poly([(px, py), (px + 170 * sc, py + 30 * sc), (px + 52 * sc, py + 30 * sc)], shade(CORAL, 1.25), rough=0.3) + a.poly([(px + 52 * sc, py + 30 * sc), (px + 170 * sc, py + 30 * sc), (px + 22 * sc, py + 70 * sc)], CORAL, rough=0.3) + elif i == 2: + a.rrect(cx - 150, cy - 70, 170, 130, 12, DARK) + a.rrect(cx - 20, cy - 50, 170, 130, 12, DARK) + a.rrect(cx - 60, cy - 34, 120, 90, 6, MUSTARD) + else: + a.poly([(cx - 70, cy - 80), (cx + 70, cy - 80), (cx + 70, cy), (cx, cy + 86), (cx - 70, cy)], TEAL) + a.lines([[(cx - 32, cy - 6), (cx - 6, cy + 22), (cx + 38, cy - 34)]], 14, WHITE, shade=True) + a.text(205, 300, wrap(body, ('hand', 36), 350), ('hand', 36), INK, v='t', spacing=4) + return a.build() + + +def s_perks(c): + for i, ts in enumerate([0.8, 6.8, 12.8, 18.8]): + bob = 4 * math.sin(c.t * 1.5 + i) + lift = 1 if c.t < ts + 0.6 or c.t > ts + 5.8 else 1.8 + c.pop(spr_perk(i), 255 + 470 * i, 480 + bob, ts, rot=(-2, 1.5, -1.5, 2)[i], elev=lift) + + +# ================================================================ scene 11: platforms +@cache +def spr_platform(i): + name, body, color = [ + ('Android', 'Its first home: Minecraft: Java Edition via launchers like FoldCraftLauncher.', TEAL), + ('Linux', 'EGL, GLX, Wayland and glvnd. Even a whole KDE Plasma desktop.', MUSTARD), + ('Windows', 'A drop-in opengl32.dll that speaks WGL.', BLUE), + ('macOS', 'CGL and NSOpenGL, on Vulkan via MoltenVK.', PLUM)][i] + a = Art(400, 560, f'plat{i}') + a.rrect(0, 0, 400, 560, 18, WHITE) + a.rrect(0, 0, 400, 230, 18, color, shade=False) + cx, cy = 200, 118 + if i == 0: + a.rrect(cx - 58, cy - 96, 116, 196, 18, DARK) + a.rrect(cx - 48, cy - 76, 96, 150, 6, BG_SKY, shade=False) + a.poly([(cx, cy - 40), (cx + 34, cy - 22), (cx, cy - 4), (cx - 34, cy - 22)], GREEN, rough=0.3) + a.poly([(cx - 34, cy - 22), (cx, cy - 4), (cx, cy + 36), (cx - 34, cy + 18)], DIRT, rough=0.3) + a.poly([(cx + 34, cy - 22), (cx, cy - 4), (cx, cy + 36), (cx + 34, cy + 18)], shade(DIRT, 0.75), rough=0.3) + elif i == 1: + a.rrect(cx - 120, cy - 78, 240, 160, 14, DARK) + a.text(cx - 92, cy - 30, '$ glmark2\n> _', ('mono', 26), (140, 220, 140), h='l', v='t', align='left', spacing=10) + elif i == 2: + a.rrect(cx - 120, cy - 80, 240, 164, 12, WHITE) + a.rrect(cx - 120, cy - 80, 240, 38, 12, DARK, shade=False) + a.text(cx, cy + 22, 'opengl32.dll', ('mono', 26), INK) + else: + a.rrect(cx - 110, cy - 82, 220, 144, 12, DARK) + a.rrect(cx - 98, cy - 70, 196, 120, 6, SKY, shade=False) + a.poly([(cx - 140, cy + 66), (cx + 140, cy + 66), (cx + 124, cy + 88), (cx - 124, cy + 88)], GREY) + a.text(200, 280, name, ('fredoka', 50, 'SemiBold'), INK) + a.text(200, 330, wrap(body, ('hand', 34), 340), ('hand', 34), INK, v='t', spacing=4) + return a.build() + + +def s_platforms(c): + t = c.t + for i, ts in enumerate([0.8, 6.8, 7.3, 7.8]): + hl = bump(t, 12.6, 6.6) if i == 1 else 0 + bob = 4 * math.sin(t * 1.5 + i) + c.pop(spr_platform(i), 285 + 450 * i, 480 + bob - 30 * hl, ts, rot=(-2, 1.5, -1.5, 2)[i] * (1 - hl), + s=1 + 0.06 * hl, elev=1 + 2 * hl) + if 12.9 <= t: + c.pop(spr_sticker('KDE Plasma\non a tablet', CORAL, r=96, size=30), 950, 180, 13.2, rot=10, + out=19.2) + + +# ================================================================ scene 12: outro +def s_outro(c): + confetti(c, 23, 0.6, n=18, box=(300, 200, 1620, 900)) + title_tiles(c, 330, 0.3) + c.unfold(spr_label('free & open source · LGPL-3.0', ('fredoka', 46, 'Medium'), WHITE, INK, padx=40), + 960, 530, 2.4, rot=-1) + c.unfold(spr_label('github.com/MobileGL-Dev/MobileGL', ('mono', 44), BRAND, WHITE, padx=36, pady=18), + 960, 645, 3.1, rot=1) + crane_fly(c, spr_crane(MUSTARD), 1.0, 4.6, (2200, 600), (300, 170), (1100, -60)) + c.pop(spr_label('Come fold with us!', ('fredoka', 64, 'SemiBold'), CORAL, WHITE, padx=50, pady=22), + 960, 820, 20.9, rot=-2) + + +# ================================================================ timeline +SCENES = [ + dict(fn=s_title, dur=16, bg=BG_CREAM, tag=None, caps=[ + (4.6, 9.6, 'Run desktop OpenGL apps where there is no desktop OpenGL driver.'), + (9.9, 15.0, "Let's unfold how it works. No deep internals, promise!")]), + dict(fn=s_problem, dur=20, bg=BG_SKY, tag=('The problem', CORAL), caps=[ + (0.6, 5.8, 'Lots of great software speaks desktop OpenGL: games, creative tools, whole desktops.'), + (6.1, 11.6, 'But phones and tablets usually ship only OpenGL ES or Vulkan drivers.'), + (11.9, 19.0, "Same GPU, different language: the app's calls have nobody to answer them.")]), + dict(fn=s_meet, dur=22, bg=BG_BUTTER, tag=('Meet MobileGL', BRAND), caps=[ + (0.5, 5.8, 'Meet MobileGL: a complete desktop OpenGL library that sits between your app and the driver.'), + (6.1, 11.4, 'Your app sees OpenGL 4.6 Core, with just a few corners still being finished.'), + (11.7, 16.8, 'Underneath, MobileGL speaks whatever the device understands: OpenGL ES or Vulkan.'), + (17.1, 21.2, "Your app doesn't change. It simply loads MobileGL as its OpenGL library.")]), + dict(fn=s_why, dur=28, bg=BG_MINT, tag=('Why it works', TEAL), caps=[ + (0.5, 5.8, 'Why can this work at all? Every modern GPU API is built from the same ingredients:'), + (6.1, 10.9, 'buffers, textures, shaders, and state like blending and depth testing.'), + (11.4, 17.0, 'OpenGL keeps that state in one big global switchboard you flip one switch at a time.'), + (17.3, 22.8, 'At each draw, MobileGL snapshots the switches that matter into an explicit recipe…'), + (23.1, 27.2, '…that a backend can replay on OpenGL ES or Vulkan.')]), + dict(fn=s_frontend, dur=26, bg=BG_PEACH, tag=('Step 1 · The frontend', CORAL), caps=[ + (0.5, 5.8, 'Step 1: the frontend. Every GL call your app makes lands here first.'), + (6.1, 11.8, 'It tracks every bind, every uniform, every switch, just like a desktop driver would.'), + (12.1, 17.8, 'It checks each call against the spec, so mistakes become proper GL errors, not crashes.'), + (18.1, 25.2, 'Nothing has touched the GPU yet. The frontend simply knows what the world should look like.')]), + dict(fn=s_shaders, dur=24, bg=BG_LAV, tag=('Shaders get folded', PLUM), caps=[ + (0.5, 5.8, 'Shaders get folded too. Your GLSL is compiled once, by glslang…'), + (6.1, 11.8, '…into SPIR-V: a compact, portable shader format.'), + (12.1, 17.8, 'Vulkan takes SPIR-V as-is. For OpenGL ES, SPIRV-Cross unfolds it into GLSL ES.'), + (18.1, 23.2, 'One shader, folded two ways, and your app never notices.')]), + dict(fn=s_records, dur=28, bg=BG_CREAM, tag=('Step 2 · Records', ORANGE), caps=[ + (0.5, 5.8, 'Step 2: before each draw, the frontend sends the backend only what changed.'), + (6.1, 11.8, 'Each change is a small record. The draw itself is a verb that uses them.'), + (12.1, 17.8, 'New objects get their handles up front, so creating things never waits for a reply.'), + (18.1, 23.6, 'Identical state blocks are shared instead of resent. Less to carry, less to check.'), + (23.9, 27.2, "The backend never peeks into the frontend's memory. Records are all it gets.")]), + dict(fn=s_backends, dur=24, bg=BG_SKY, tag=('Step 3 · Backends', TEAL), caps=[ + (0.5, 5.8, 'Step 3: a backend turns records into real driver calls.'), + (6.1, 11.8, 'Espryt speaks OpenGL ES. Magma speaks Vulkan. You pick one at startup.'), + (12.1, 17.8, 'Each backend is fully independent, so one can improve without disturbing the other.'), + (18.1, 23.2, 'Same app, same frontend. Choose whatever suits the device best.')]), + dict(fn=s_anywhere, dur=28, bg=BG_BUTTER, tag=('Records can travel', BRAND), caps=[ + (0.5, 5.6, "Now the fun part: records are just data, so they don't care where the backend lives."), + (5.9, 10.6, 'Run everything on one thread, or give frontend and backend a thread each…'), + (10.9, 15.6, '…put them in two processes that share a ring of memory…'), + (15.9, 21.2, "…or even on two machines: a PC can drive a phone's GPU over the network."), + (21.5, 27.2, 'The frontend side is the client. The backend side, next to the GPU, is the server.')]), + dict(fn=s_perks, dur=24, bg=BG_MINT, tag=('Built for the trip', ORANGE), caps=[ + (0.5, 6.3, "Splitting up isn't slow. Under heavy load it can even be faster than one thread."), + (6.6, 12.4, 'The client runs ahead and only waits when it really needs an answer back.'), + (12.7, 18.4, 'On the same machine, big buffers and window images are shared, not copied.'), + (18.7, 23.4, "And if the server goes away, your app sees 'device lost' instead of crashing.")]), + dict(fn=s_platforms, dur=26, bg=BG_LAV, tag=('Where it runs', PLUM), caps=[ + (0.5, 6.3, 'MobileGL grew up on Android: think Minecraft: Java Edition on a phone.'), + (6.6, 12.4, 'It also runs on Linux, slips into Windows as opengl32.dll, and works on macOS.'), + (12.7, 19.0, 'A favorite demo: a whole KDE Plasma desktop from a Linux container, rendered by MobileGL on an Android tablet.'), + (19.3, 25.2, 'Same library everywhere. Only the window-system glue changes.')]), + dict(fn=s_outro, dur=24, bg=BG_NAVY, tag=None, caps=[ + (4.2, 9.6, 'MobileGL is free, open source, and still growing.'), + (9.9, 15.4, 'Build it, pick a backend, and point your favorite OpenGL app at it.'), + (15.7, 20.6, "Something breaks? Tell us. That's the most useful thing you can do.")]), +] +TR = 0.8 +STARTS = [] +_acc = 0.0 +for _s in SCENES: + STARTS.append(_acc) + _acc += _s['dur'] +TOTAL = _acc +NFRAMES = int(TOTAL * FPS) + + +def render_frame(fi): + t = fi / FPS + i = max(k for k in range(len(SCENES)) if STARTS[k] <= t + 1e-9) + sc = SCENES[i] + lt = t - STARTS[i] + frame = background(sc['bg']).copy() + c = Ctx(frame, lt, fi, sc['dur']) + sc['fn'](c) + if sc['tag']: + draw_tag(c, *sc['tag']) + for k, (a, b, txt) in enumerate(sc['caps']): + draw_caption(c, a, b, txt, k) + if i + 1 < len(SCENES) and lt > sc['dur'] - TR: + draw_wipe(frame, SCENES[i + 1]['bg'], prog(lt, sc['dur'] - TR, TR)) + out = frame.convert('RGB') + fade = min(prog(t, 0, 0.7), 1 - prog(t, TOTAL - 1.6, 1.5)) + if fade < 1: + out = Image.blend(Image.new('RGB', out.size, (0, 0, 0)), out, fade) + return out + + +def _worker(fi): + return render_frame(fi).tobytes() + + +def write_meta(path): + caps, ev = [], [] + for st, sc in zip(STARTS, SCENES): + for a, b, txt in sc['caps']: + caps.append((st + a, st + b, T(txt))) + ev.append(st + sc['dur'] - TR) + json.dump(dict(total=TOTAL, starts=STARTS, wipes=ev[:-1], + pops=[0.3 + 0.16 * i for i in range(8)] + [STARTS[-1] + 0.3 + 0.16 * i for i in range(8)]), + open(path + '.json', 'w'), indent=1) + + def ts(x): + ms = int(round(x * 1000)) + return f'{ms // 3600000:02d}:{ms // 60000 % 60:02d}:{ms // 1000 % 60:02d},{ms % 1000:03d}' + with open(path + '.srt', 'w', encoding='utf-8') as f: + for n, (a, b, txt) in enumerate(caps, 1): + f.write(f'{n}\n{ts(a)} --> {ts(b)}\n{txt}\n\n') + + +OUT = os.path.join(HERE, 'out' if LANG == 'en' else 'out_' + LANG) +SUFFIX = '' if LANG == 'en' else '_' + LANG + +if __name__ == '__main__': + os.makedirs(OUT, exist_ok=True) + if sys.argv[1] == 'stills': + for s in sys.argv[2:]: + t = float(s) + render_frame(int(round(t * FPS))).save(os.path.join(OUT, f'still_{t:06.1f}.png')) + print('still', t) + elif sys.argv[1] == 'meta': + write_meta(os.path.join(OUT, 'mobilegl_intro' + SUFFIX)) + print('total', TOTAL) + elif sys.argv[1] == 'render': + write_meta(os.path.join(OUT, 'mobilegl_intro' + SUFFIX)) + ff = sys.argv[2] + dst = os.path.join(OUT, 'video_noaudio' + SUFFIX + '.mp4') + p = subprocess.Popen([ff, '-y', '-loglevel', 'error', '-f', 'rawvideo', '-pix_fmt', 'rgb24', + '-s', f'{W}x{H}', '-r', str(FPS), '-i', '-', '-r', '24', '-c:v', 'libx264', + '-preset', 'slow', '-crf', '18', '-tune', 'animation', '-pix_fmt', 'yuv420p', dst], + stdin=subprocess.PIPE) + t0 = time.time() + with Pool(max(2, os.cpu_count() - 2)) as pool: + for k, buf in enumerate(pool.imap(_worker, range(NFRAMES), chunksize=6)): + p.stdin.write(buf) + if k % 240 == 0: + print(f'{k}/{NFRAMES} {time.time() - t0:.0f}s', flush=True) + p.stdin.close() + p.wait() + print('done', time.time() - t0) diff --git a/intro-video/paper/zh.py b/intro-video/paper/zh.py new file mode 100644 index 00000000..23ed587c --- /dev/null +++ b/intro-video/paper/zh.py @@ -0,0 +1,145 @@ +"""Simplified-Chinese strings for the MobileGL intro (MGL_LANG=zh). Untranslated strings render as-is.""" +TR_ZH = { + # ---- captions + 'Run desktop OpenGL apps where there is no desktop OpenGL driver.': + '在没有桌面 OpenGL 驱动的地方,也能运行桌面 OpenGL 程序。', + "Let's unfold how it works. No deep internals, promise!": + '一起把它展开看看吧——保证不钻太深!', + 'Lots of great software speaks desktop OpenGL: games, creative tools, whole desktops.': + '很多优秀的软件都说“桌面 OpenGL”:游戏、创作工具,甚至整个桌面环境。', + 'But phones and tablets usually ship only OpenGL ES or Vulkan drivers.': + '可手机和平板通常只带 OpenGL ES 或 Vulkan 驱动。', + "Same GPU, different language: the app's calls have nobody to answer them.": + '同一块 GPU,却说着不同的语言:程序发出的调用,没人能接。', + 'Meet MobileGL: a complete desktop OpenGL library that sits between your app and the driver.': + '认识一下 MobileGL:一个完整的桌面 OpenGL 库,位于你的程序和驱动之间。', + 'Your app sees OpenGL 4.6 Core, with just a few corners still being finished.': + '程序看到的是 OpenGL 4.6 Core,只有少数边角还在完善中。', + 'Underneath, MobileGL speaks whatever the device understands: OpenGL ES or Vulkan.': + '在底层,MobileGL 说设备听得懂的语言:OpenGL ES 或 Vulkan。', + "Your app doesn't change. It simply loads MobileGL as its OpenGL library.": + '程序本身不用改,只需把 MobileGL 当作 OpenGL 库加载。', + 'Why can this work at all? Every modern GPU API is built from the same ingredients:': + '为什么这行得通?因为现代 GPU API 都由同样的“食材”构成:', + 'buffers, textures, shaders, and state like blending and depth testing.': + '缓冲区、纹理、着色器,以及混合、深度测试这类状态。', + 'OpenGL keeps that state in one big global switchboard you flip one switch at a time.': + 'OpenGL 把这些状态放在一块巨大的全局开关板上,一次拨一个开关。', + 'At each draw, MobileGL snapshots the switches that matter into an explicit recipe…': + '每次绘制时,MobileGL 把相关的开关拍成快照,写成一份明确的“配方”……', + '…that a backend can replay on OpenGL ES or Vulkan.': + '……再由后端照着配方,在 OpenGL ES 或 Vulkan 上重现。', + 'Step 1: the frontend. Every GL call your app makes lands here first.': + '第一步:前端。程序发出的每个 GL 调用都先到这里。', + 'It tracks every bind, every uniform, every switch, just like a desktop driver would.': + '它记录每一次绑定、每个 uniform、每个开关,就像桌面驱动那样。', + 'It checks each call against the spec, so mistakes become proper GL errors, not crashes.': + '它按规范检查每个调用,出错时返回标准的 GL 错误,而不是直接崩溃。', + 'Nothing has touched the GPU yet. The frontend simply knows what the world should look like.': + '此时 GPU 还没被碰过。前端只是清楚地知道,画面“应该”是什么样子。', + 'Shaders get folded too. Your GLSL is compiled once, by glslang…': + '着色器也要“折”一下。你的 GLSL 先由 glslang 编译一次……', + '…into SPIR-V: a compact, portable shader format.': + '……折成 SPIR-V:一种紧凑、可移植的着色器格式。', + 'Vulkan takes SPIR-V as-is. For OpenGL ES, SPIRV-Cross unfolds it into GLSL ES.': + 'Vulkan 直接使用 SPIR-V;对 OpenGL ES,则由 SPIRV-Cross 把它展开成 GLSL ES。', + 'One shader, folded two ways, and your app never notices.': + '同一个着色器,两种折法,程序却毫无察觉。', + 'Step 2: before each draw, the frontend sends the backend only what changed.': + '第二步:每次绘制前,前端只把“变了的部分”发给后端。', + 'Each change is a small record. The draw itself is a verb that uses them.': + '每处变化都是一条小小的“记录”,绘制本身则是使用这些记录的“动作”。', + 'New objects get their handles up front, so creating things never waits for a reply.': + '新对象的句柄在前端就分配好,创建东西时不必等待回复。', + 'Identical state blocks are shared instead of resent. Less to carry, less to check.': + '相同的状态块只发一次、共享使用。要搬的少了,要查的也少了。', + "The backend never peeks into the frontend's memory. Records are all it gets.": + '后端从不偷看前端的内存,它拿到的只有记录。', + 'Step 3: a backend turns records into real driver calls.': + '第三步:后端把记录变成真正的驱动调用。', + 'Espryt speaks OpenGL ES. Magma speaks Vulkan. You pick one at startup.': + 'Espryt 说 OpenGL ES,Magma 说 Vulkan,启动时任选其一。', + 'Each backend is fully independent, so one can improve without disturbing the other.': + '两个后端完全独立,改进一个不会影响另一个。', + 'Same app, same frontend. Choose whatever suits the device best.': + '同一个程序、同一个前端,选最适合设备的那个就好。', + "Now the fun part: records are just data, so they don't care where the backend lives.": + '有趣的部分来了:记录只是数据,它们不在乎后端住在哪里。', + 'Run everything on one thread, or give frontend and backend a thread each…': + '可以全部跑在一个线程里,也可以让前端和后端各占一个线程……', + '…put them in two processes that share a ring of memory…': + '……或者放进两个进程,共享一圈环形内存……', + "…or even on two machines: a PC can drive a phone's GPU over the network.": + '……甚至放在两台机器上:电脑可以通过网络驱动手机的 GPU。', + 'The frontend side is the client. The backend side, next to the GPU, is the server.': + '前端这一侧叫客户端,靠近 GPU 的后端一侧叫服务端。', + "Splitting up isn't slow. Under heavy load it can even be faster than one thread.": + '拆开并不会变慢。重负载下,它甚至比单线程更快。', + 'The client runs ahead and only waits when it really needs an answer back.': + '客户端会一路往前跑,只有真正需要回复时才停下来等。', + 'On the same machine, big buffers and window images are shared, not copied.': + '在同一台机器上,大缓冲区和窗口图像直接共享,不做拷贝。', + "And if the server goes away, your app sees 'device lost' instead of crashing.": + '就算服务端没了,程序看到的也只是“设备丢失”,而不是崩溃。', + 'MobileGL grew up on Android: think Minecraft: Java Edition on a phone.': + 'MobileGL 在 Android 上长大:想象一下在手机上玩 Minecraft Java 版。', + 'It also runs on Linux, slips into Windows as opengl32.dll, and works on macOS.': + '它也能跑在 Linux 上,在 Windows 上化身 opengl32.dll,在 macOS 上同样可用。', + 'A favorite demo: a whole KDE Plasma desktop from a Linux container, rendered by MobileGL on an Android tablet.': + '一个很受欢迎的演示:Linux 容器里的整套 KDE Plasma 桌面,在 Android 平板上由 MobileGL 渲染。', + 'Same library everywhere. Only the window-system glue changes.': + '处处都是同一个库,变的只是窗口系统那层“胶水”。', + 'MobileGL is free, open source, and still growing.': + 'MobileGL 自由、开源,而且仍在成长。', + 'Build it, pick a backend, and point your favorite OpenGL app at it.': + '编译它、选一个后端,然后让你喜欢的 OpenGL 程序用上它。', + "Something breaks? Tell us. That's the most useful thing you can do.": + '遇到问题?告诉我们——这就是你能提供的最大帮助。', + # ---- scene tags + 'The problem': '问题在哪', 'Meet MobileGL': '认识 MobileGL', 'Why it works': '为什么可行', + 'Step 1 · The frontend': '第一步 · 前端', 'Shaders get folded': '着色器的折法', + 'Step 2 · Records': '第二步 · 记录', 'Step 3 · Backends': '第三步 · 后端', + 'Records can travel': '记录可以远行', 'Built for the trip': '为远行而生', 'Where it runs': '在哪里运行', + # ---- labels + 'a desktop OpenGL implementation': '一个桌面 OpenGL 实现', + 'Your app': '你的程序', 'OpenGL ES driver': 'OpenGL ES 驱动', 'Vulkan driver': 'Vulkan 驱动', + 'no app\nchanges': '程序\n无需修改', + 'OpenGL: one big switchboard': 'OpenGL:一块大开关板', 'Explicit recipes': '明确的配方', + 'buffers': '缓冲区', 'textures': '纹理', 'shaders': '着色器', 'state': '状态', + 'blend': '混合', 'depth': '深度', 'cull': '剔除', 'stencil': '模板', 'texture 0': '纹理 0', + 'program': '程序', 'viewport': '视口', + 'Draw recipe #1': '绘制配方 #1', 'Draw recipe #2': '绘制配方 #2', 'Draw recipe #3': '绘制配方 #3', + 'shaders: A + B': '着色器:A + B', 'shaders: C + B': '着色器:C + B', + 'blend: off': '混合:关', 'blend: on': '混合:开', 'depth: less': '深度:less', 'depth: always': '深度:always', + 'cull: off': '剔除:关', 'vertex format #2': '顶点格式 #2', 'vertex format #5': '顶点格式 #5', + 'GL state': 'GL 状态', 'draws': '绘制', + 'Textures': '纹理', 'Uniforms': 'Uniform', 'Blend': '混合', 'Vertex arrays': '顶点数组', + 'Magma (Vulkan): uses it as-is': 'Magma(Vulkan):直接使用', + 'Espryt (OpenGL ES): GLSL ES': 'Espryt(OpenGL ES):转成 GLSL ES', + 'Frontend': '前端', 'Backend': '后端', + 'texture 7 on unit 0': '纹理 7 绑到单元 0', 'viewport 1920×1080': '视口 1920×1080', + 'DRAW': '绘制', 'uniform tint': 'uniform:tint', 'texture 12 on unit 1': '纹理 12 绑到单元 1', + 'new texture': '新纹理', 'only\nchanges': '只发\n变化', 'no\nwaiting': '无需\n等待', + 'sent\nonce': '只发\n一次', 'blend on · depth less': '混合开 · 深度 less', + 'speaks OpenGL ES': '说 OpenGL ES', 'speaks Vulkan': '说 Vulkan', 'fully\nseparate': '完全\n独立', + 'One thread': '单线程', 'Two threads': '双线程', 'Two processes': '双进程', 'Two machines': '两台机器', + 'spawn + shared memory': 'spawn + 共享内存', 'frontend': '前端', 'backend': '后端', + 'client': '客户端', 'server': '服务端', + 'client = frontend, inside your app': '客户端 = 前端,在你的程序里', + 'server = backend, next to the GPU': '服务端 = 后端,紧挨着 GPU', + 'Fast split': '拆开也快', 'Run-ahead': '提前跑', 'Zero-copy': '零拷贝', 'Safe failure': '安全失败', + 'Two threads or processes can even beat one: both sides work at once.': + '双线程或双进程甚至能胜过单线程:两边同时干活。', + 'Keep sending. Wait only for the answers you truly need.': '一直往前发,只等真正需要的回复。', + 'Big buffers and window images are shared, not copied.': '大缓冲区和窗口图像直接共享,不做拷贝。', + "Server gone? Your app sees 'device lost', not a crash.": '服务端没了?程序只会看到“设备丢失”,不会崩溃。', + 'Its first home: Minecraft: Java Edition via launchers like FoldCraftLauncher.': + '最初的家:通过 FoldCraftLauncher 等启动器运行 Minecraft Java 版。', + 'EGL, GLX, Wayland and glvnd. Even a whole KDE Plasma desktop.': + 'EGL、GLX、Wayland 和 glvnd,甚至能跑整套 KDE Plasma 桌面。', + 'A drop-in opengl32.dll that speaks WGL.': '即插即用的 opengl32.dll,支持 WGL。', + 'CGL and NSOpenGL, on Vulkan via MoltenVK.': 'CGL 与 NSOpenGL,经 MoltenVK 跑在 Vulkan 上。', + 'KDE Plasma\non a tablet': '平板上的\nKDE Plasma', + 'free & open source · LGPL-3.0': '自由开源 · LGPL-3.0', + 'Come fold with us!': '一起来折吧!', +}