[Feat] (Video): add the paper-cutout intro and hype reel sources

This commit is contained in:
2026-10-11 12:18:32 -04:00
parent 2afeec95cd
commit cef575916e
35 changed files with 7102 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
# renders, audio and downloaded fonts are generated, not sources
out/
out_*/
fonts/
__pycache__/
*.mp4
*.wav
*.png
*.jpg
*.srt
*.json
+9
View File
@@ -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. |
+76
View File
@@ -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).
+17
View File
@@ -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<hi)])**2)+1e-9)
for name,a,b in S.SECTIONS:
seg = m[int(S.tb(a)*sr):int(S.tb(b)*sr)]
rms = 20*np.log10(np.sqrt(np.mean(seg**2)))
print(f'{name:7s} rms {rms:6.1f} sub {band(seg,20,80):5.1f} low {band(seg,80,300):5.1f} mid {band(seg,300,2000):5.1f} pres {band(seg,2000,6000):5.1f} air {band(seg,6000,16000):5.1f} width {np.std(x[int(S.tb(a)*sr):int(S.tb(b)*sr),0]-x[int(S.tb(a)*sr):int(S.tb(b)*sr),1])/np.std(seg)+1e-9:.2f}')
# spectrogram image
f,t,Z = stft(m[::2], sr/2, nperseg=2048, noverlap=1024)
P = 20*np.log10(np.abs(Z)+1e-7); P = np.clip((P+100)/80,0,1)
from PIL import Image
img = (P[::-1][-700:]*255).astype(np.uint8)
im = Image.fromarray(img).resize((1800, 500)); im.save('out/spec.png')
+124
View File
@@ -0,0 +1,124 @@
"""Easing, palette and per-frame timing helpers shared by the scenes."""
import math
from engine import hexc, mix, with_alpha, scale_rgb
import score as S
# --- palette (sampled from the MobileGlues icon, pushed for contrast) --------------------------------
INK = hexc('#06110a') # near-black green: dark plates
INK2 = hexc('#0d2213') # panels on dark
MOSS = hexc('#1d4a25') # deep forest
LEAF = hexc('#4e9a3e') # icon outline green
GLUE = hexc('#6edb55') # icon highlight lime: the signal colour
NEON = hexc('#8cff62', 2.6) # emissive lime (blooms)
MINT = hexc('#a0e28c') # icon background: light plates
PALE = hexc('#d4f0c9') # pale top face
BONE = hexc('#f1f7ec') # type on dark
CORAL = hexc('#ff6a55') # rare accent: errors / device lost
CORAL_HOT = hexc('#ff6a55', 2.2)
AMBER = hexc('#e7a94f') # speckle tan, tiny accents
CLEAR = (0.0, 0.0, 0.0, 0.0)
def a(c, al):
return with_alpha(c, al)
# --- easing ----------------------------------------------------------------------------------------
def clamp01(x):
return 0.0 if x < 0 else 1.0 if x > 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))
+180
View File
@@ -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]))
+212
View File
@@ -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
+732
View File
@@ -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);
}
'''
+22
View File
@@ -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)
+377
View File
@@ -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)])
+576
View File
@@ -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')
+135
View File
@@ -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=<path to opengl32.dll> 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))
+10
View File
@@ -0,0 +1,10 @@
numpy
scipy
numba
pillow
moderngl
glcontext
uharfbuzz
freetype-py
soundfile
pyloudnorm
+135
View File
@@ -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
+58
View File
@@ -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
+215
View File
@@ -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
+215
View File
@@ -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
+208
View File
@@ -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
+327
View File
@@ -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
+197
View File
@@ -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
+126
View File
@@ -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
+74
View File
@@ -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
+217
View File
@@ -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')
+245
View File
@@ -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;
}
+63
View File
@@ -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;
}
+21
View File
@@ -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)}
+67
View File
@@ -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)
+51
View File
@@ -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.
+106
View File
@@ -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')
+37
View File
@@ -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)
+292
View File
@@ -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])
+618
View File
@@ -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)
+3
View File
@@ -0,0 +1,3 @@
numpy
scipy
pillow
File diff suppressed because it is too large Load Diff
+145
View File
@@ -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!': '一起来折吧!',
}