animader-cli 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/LICENSE +121 -0
  2. package/adapters/build-node.mjs +265 -0
  3. package/adapters/devserver.mjs +481 -0
  4. package/adapters/devsession.mjs +619 -0
  5. package/adapters/export-node.mjs +603 -0
  6. package/adapters/export-run.mjs +126 -0
  7. package/adapters/generations.mjs +183 -0
  8. package/adapters/http.mjs +294 -0
  9. package/adapters/launch.mjs +83 -0
  10. package/adapters/node-fs.mjs +80 -0
  11. package/adapters/pngine.mjs +87 -0
  12. package/adapters/snapshot.mjs +143 -0
  13. package/adapters/target.mjs +104 -0
  14. package/adapters/watch.mjs +355 -0
  15. package/adapters/webaudio.mjs +102 -0
  16. package/bin/animader.mjs +31 -0
  17. package/hello/join.js +46 -0
  18. package/hello/run.js +259 -0
  19. package/lib/build.mjs +126 -0
  20. package/lib/buildloop.mjs +254 -0
  21. package/lib/contract.mjs +371 -0
  22. package/lib/errors.mjs +51 -0
  23. package/lib/export.mjs +445 -0
  24. package/lib/new.mjs +304 -0
  25. package/lib/pipeline.mjs +247 -0
  26. package/lib/receipt.mjs +331 -0
  27. package/lib/rmtree.mjs +49 -0
  28. package/lib/routes.mjs +170 -0
  29. package/lib/score.mjs +434 -0
  30. package/lib/show.mjs +458 -0
  31. package/lib/soundtrack.mjs +67 -0
  32. package/lib/vendor.mjs +99 -0
  33. package/lib/wav.mjs +197 -0
  34. package/package.json +47 -4
  35. package/player/boot.mjs +792 -0
  36. package/player/controller.mjs +366 -0
  37. package/player/devchannel.mjs +306 -0
  38. package/player/index.html +35 -0
  39. package/player/page.mjs +134 -0
  40. package/player/player.css +393 -0
  41. package/player/release.mjs +76 -0
  42. package/src/commands/build.mjs +201 -0
  43. package/src/commands/dev.mjs +214 -0
  44. package/src/commands/doctor.mjs +348 -0
  45. package/src/commands/export.mjs +158 -0
  46. package/src/commands/new.mjs +164 -0
  47. package/src/commands/project.mjs +46 -0
  48. package/src/config.mjs +110 -0
  49. package/src/router.mjs +149 -0
  50. package/src/toolchain.mjs +691 -0
  51. package/starters/circle/README.md +50 -0
  52. package/starters/circle/animader.json +4 -0
  53. package/starters/circle/gitignore +9 -0
  54. package/starters/circle/kernels/circle.wgsl +32 -0
  55. package/starters/circle/program.sjon +34 -0
  56. package/starters/circle/timeline.piece.sjon +26 -0
  57. package/starters/ember/bake/spans.bin +0 -0
  58. package/starters/ember/kernels/curl.wgsl +10 -0
  59. package/starters/ember/kernels/ember.wgsl +10 -0
  60. package/starters/ember/kernels/hash.wgsl +12 -0
  61. package/starters/ember/kernels/simplex.wgsl +27 -0
  62. package/starters/ember/program.sjon +328 -0
  63. package/starters/ember/timeline.piece.sjon +34 -0
  64. package/toolchain/licenses/animader.txt +121 -0
  65. package/toolchain/licenses/esbuild.txt +21 -0
  66. package/toolchain/licenses/pacer.txt +121 -0
  67. package/toolchain/licenses/pngine.txt +121 -0
  68. package/toolchain/manifest.json +212 -0
  69. package/toolchain/prepare.mjs +446 -0
  70. package/toolchain/uniform-type-name.js +184 -0
  71. package/README.md +0 -3
@@ -0,0 +1,50 @@
1
+ # An Animader project
2
+
3
+ This folder was created by `animader new` with the circle starter: one
4
+ circle kernel, an eight-second path with beat pulses, and a complete
5
+ timeline. Three authored files carry the piece:
6
+
7
+ - `program.sjon` — the GPU program: the buffers, the circle kernel and
8
+ the passes that run each frame.
9
+ - `timeline.piece.sjon` — the musical timeline: tempo, meter, sections
10
+ and the lanes that animate the program's fields.
11
+ - one soundtrack file — `soundtrack.wav`, the labeled test tone (not
12
+ music).
13
+
14
+ ## Host duration
15
+
16
+ `animader.json` holds the host settings. `seconds` is the real playback
17
+ length the host uses. The musical lengths inside the timeline (bars,
18
+ beats, tempo) are musical time; the host plays the `seconds` it finds in
19
+ `animader.json`, or an explicit `--seconds` you pass.
20
+
21
+ ## The workflow this project is shaped for
22
+
23
+ `animader dev` (play the project while you edit), `animader build`
24
+ (validate and write the generated output) and `animader export --out dist`
25
+ (write a standalone release to a fresh folder) are the commands this
26
+ layout targets. They are provided by the `animader` CLI — run
27
+ `animader --help` to see which commands your version provides.
28
+
29
+ ## Editing
30
+
31
+ Edit `program.sjon` or `timeline.piece.sjon` in any editor. The dev
32
+ server keeps running until you stop it: press Ctrl-C in its terminal
33
+ before running other project commands.
34
+
35
+ ## Replacing the soundtrack
36
+
37
+ Move your own audio into this folder as `soundtrack.wav`,
38
+ `soundtrack.ogg` or `soundtrack.opus`. Exactly one recognized soundtrack
39
+ may exist here at a time, so move the replaced file out of the project
40
+ yourself — the host never deletes author audio. The generated
41
+ `soundtrack.wav` is a labeled test tone, not music.
42
+
43
+ ## Generated output and exports
44
+
45
+ `out/` holds generated build output. `.gitignore` already ignores
46
+ `out/`, the default `dist/` export, its staging directory and its
47
+ detached size report; an export to another name needs matching ignore
48
+ entries of its own (for `--out release`: `release/`, `release.next`,
49
+ `release.size.json`). Exports write to a fresh folder and never
50
+ overwrite an existing one.
@@ -0,0 +1,4 @@
1
+ {
2
+ "format": "animader-project/1",
3
+ "seconds": 8
4
+ }
@@ -0,0 +1,9 @@
1
+ # Generated build output.
2
+ out/
3
+
4
+ # The default export destination, its staging directory and its detached
5
+ # size report. An export to another name needs its own ignore entries,
6
+ # for example release/, release.next and release.size.json for --out release.
7
+ dist/
8
+ dist.next
9
+ dist.size.json
@@ -0,0 +1,32 @@
1
+ struct Params {
2
+ beat: f32,
3
+ scene: f32,
4
+ x: f32,
5
+ radius: f32,
6
+ }
7
+ struct Inputs {
8
+ time: f32,
9
+ width: f32,
10
+ height: f32,
11
+ aspect: f32,
12
+ }
13
+ @group(0) @binding(0) var<uniform> params: Params;
14
+ @group(0) @binding(1) var<uniform> inputs: Inputs;
15
+
16
+ @vertex
17
+ fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f {
18
+ let x = f32(i & 1u) * 4.0 - 1.0;
19
+ let y = f32(i >> 1u) * 4.0 - 1.0;
20
+ return vec4f(x, y, 0.0, 1.0);
21
+ }
22
+
23
+ @fragment
24
+ fn fs(@builtin(position) pos: vec4f) -> @location(0) vec4f {
25
+ let uv = pos.xy / vec2f(inputs.width, inputs.height);
26
+ let p = (uv - vec2f(params.x, 0.5)) * vec2f(inputs.aspect, 1.0);
27
+ let aa = 1.0 / inputs.height;
28
+ let pulse = 1.0 + 0.15 * (1.0 - smoothstep(0.0, 0.30, fract(params.beat)));
29
+ let r = params.radius * pulse;
30
+ let circle = 1.0 - smoothstep(r - aa, r + aa, length(p));
31
+ return vec4f(mix(vec3f(0.96, 0.95, 0.92), vec3f(0.12, 0.12, 0.15), circle), 1.0);
32
+ }
@@ -0,0 +1,34 @@
1
+ ; program.sjon — the circle program from TUTORIAL.md step 4: one fullscreen
2
+ ; triangle, one fragment-shaded circle, one pass per frame. The defaults
3
+ ; feed Params in kernels/circle.wgsl in order: beat, scene, x, radius.
4
+
5
+ (data :name defaults :float32 [0.0 0.0 0.20 0.045])
6
+ (buffer :name params :usage [uniform copy-dst] :data defaults)
7
+
8
+ (buffer :name inputs :size 16 :usage [uniform copy-dst])
9
+ (queue :name writeInputs
10
+ (write-buffer :buffer inputs :offset 0 :data pngine-inputs))
11
+
12
+ (shader-module :name circleCode :file "kernels/circle.wgsl")
13
+
14
+ (render-pipeline :name circlePipe :layout auto
15
+ (vertex :module circleCode :entry vs)
16
+ (fragment :module circleCode :entry fs
17
+ (color-target :format preferred-canvas-format))
18
+ (primitive :topology triangle-list))
19
+
20
+ (bind-group :name circleBinds :layout circlePipe :group 0
21
+ (entry :binding 0 :buffer params)
22
+ (entry :binding 1 :buffer inputs))
23
+
24
+ (render-pass :name picture
25
+ (color-attachment :view context-current-texture
26
+ :clear-value [0.96 0.95 0.92 1.0]
27
+ :load-op clear :store-op store)
28
+ :pipeline circlePipe
29
+ :bind-groups [circleBinds]
30
+ (draw :vertex-count 3))
31
+
32
+ (frame :name main
33
+ :before [writeInputs]
34
+ :perform [picture])
@@ -0,0 +1,26 @@
1
+ ; timeline.piece.sjon — TUTORIAL.md steps 5 and 6: four bars of 4/4 at
2
+ ; 120 qpm (eight seconds), the score's beat handed to the shader, the
3
+ ; circle's journey across the canvas and its radius pulse on every beat.
4
+
5
+ (animader :name circle-journey :version 1
6
+ :default-world live :worlds [live])
7
+
8
+ (world :name live
9
+ :time live :params defaults :input live :viewport [1280 720]
10
+ :assets project :state live)
11
+
12
+ (timeline :title "A circle, for now" :length 4bar :end hold)
13
+ (tempo-point :at 0q :qpm 120)
14
+ (meter-point :at 0q :sig [4 4])
15
+ (section :name one :at 0bar :dur 4bar)
16
+
17
+ (lane :name beat-ramp :kind formula :target beat :expr beat)
18
+
19
+ (lane :name journey-x :kind curve :target x
20
+ (keyframe :at 0b :value 0.20 :ease linear)
21
+ (keyframe :at 16b :value 0.80 :ease linear))
22
+
23
+ (lane :name size :kind curve :target radius
24
+ (keyframe :at 0b :value 0.045 :ease linear)
25
+ (keyframe :at 8b :value 0.080 :ease in-out-sine)
26
+ (keyframe :at 16b :value 0.045 :ease in-out-sine))
Binary file
@@ -0,0 +1,10 @@
1
+ // The curl of a simplex field: a divergence-free velocity, the thing
2
+ // `evolve`'s `(curl :scale s :amount a)` step samples. Two finite
3
+ // differences of the scalar field, rotated a quarter turn.
4
+ fn curl(p: vec2f, scale: f32, t: f32) -> vec2f {
5
+ let e = 0.01;
6
+ let q = p * scale + vec2f(t * 0.15, -t * 0.11);
7
+ let dx = simplex(q + vec2f(e, 0.0)) - simplex(q - vec2f(e, 0.0));
8
+ let dy = simplex(q + vec2f(0.0, e)) - simplex(q - vec2f(0.0, e));
9
+ return vec2f(dy, -dx) / (2.0 * e);
10
+ }
@@ -0,0 +1,10 @@
1
+ // The author's own kernel: a colour for a particle from its speed, black
2
+ // body from a deep red through orange to a pale yellow, the way an ember
3
+ // brightens when it moves.
4
+ fn ember(speed: f32) -> vec3f {
5
+ let s = clamp(speed * 6.0, 0.0, 1.0);
6
+ let cold = vec3f(0.55, 0.08, 0.02);
7
+ let warm = vec3f(1.0, 0.45, 0.08);
8
+ let hot = vec3f(1.0, 0.92, 0.6);
9
+ return select(mix(cold, warm, s * 2.0), mix(warm, hot, s * 2.0 - 1.0), s > 0.5);
10
+ }
@@ -0,0 +1,12 @@
1
+ // A small integer hash to [0, 1). PCG-style, deterministic on every
2
+ // device, which is what a seed table wants: the same particle draws the
3
+ // same home on every run.
4
+ fn hash_u32(x: u32) -> u32 {
5
+ var h = x * 747796405u + 2891336453u;
6
+ h = ((h >> ((h >> 28u) + 4u)) ^ h) * 277803737u;
7
+ return (h >> 22u) ^ h;
8
+ }
9
+
10
+ fn hash(x: u32) -> f32 {
11
+ return f32(hash_u32(x)) / 4294967296.0;
12
+ }
@@ -0,0 +1,27 @@
1
+ // 2D simplex noise in [-1, 1], Ashima's, the field `haze` ramps and the
2
+ // field `curl` differentiates. `position` in, one value out.
3
+ fn mod289_2(x: vec2f) -> vec2f { return x - floor(x * (1.0 / 289.0)) * 289.0; }
4
+ fn mod289_3(x: vec3f) -> vec3f { return x - floor(x * (1.0 / 289.0)) * 289.0; }
5
+ fn permute3(x: vec3f) -> vec3f { return mod289_3(((x * 34.0) + 1.0) * x); }
6
+
7
+ fn simplex(v: vec2f) -> f32 {
8
+ let C = vec4f(0.211324865405187, 0.366025403784439, -0.577350269189626, 0.024390243902439);
9
+ var i = floor(v + dot(v, C.yy));
10
+ let x0 = v - i + dot(i, C.xx);
11
+ var i1 = vec2f(0.0, 1.0);
12
+ if (x0.x > x0.y) { i1 = vec2f(1.0, 0.0); }
13
+ var x12 = x0.xyxy + C.xxzz;
14
+ x12 = vec4f(x12.xy - i1, x12.zw);
15
+ i = mod289_2(i);
16
+ let p = permute3(permute3(i.y + vec3f(0.0, i1.y, 1.0)) + i.x + vec3f(0.0, i1.x, 1.0));
17
+ var m = max(vec3f(0.5) - vec3f(dot(x0, x0), dot(x12.xy, x12.xy), dot(x12.zw, x12.zw)), vec3f(0.0));
18
+ m = m * m;
19
+ m = m * m;
20
+ let x = 2.0 * fract(p * C.www) - 1.0;
21
+ let h = abs(x) - 0.5;
22
+ let ox = floor(x + 0.5);
23
+ let a0 = x - ox;
24
+ m = m * (1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h));
25
+ let g = vec3f(a0.x * x0.x + h.x * x0.y, a0.yz * x12.xz + h.yz * x12.yw);
26
+ return 130.0 * dot(m, g);
27
+ }
@@ -0,0 +1,328 @@
1
+ ; program.sjon — the hello piece, written by hand in pngine's forms. The twin
2
+ ; of `piece.sjon`, which says the same thing in animader's vocabulary; this
3
+ ; file is what the CLI runs. Every place the writing was tedious is a line in
4
+ ; TEDIUM.md, with the verb that would have absorbed it.
5
+ ;
6
+ ; pngine validate program.sjon
7
+ ; pngine render -f -s 1280x720 -t 1.44 -o out.png program.sjon ; one tick, at t
8
+ ; pngine inspect program.sjon --deep --phase frame --frames 0,1,10,60 --time-step 0.01 --json
9
+ ;
10
+ ; The word EMBER is a span table baked by canto (bake/spans.bin). A seed
11
+ ; pass draws every particle a home inside the letters; a compute step moves
12
+ ; the particles by a curl field, a spring back home and the pointer; a draw
13
+ ; paints them as sprites into a texture; a feedback pass keeps their trails;
14
+ ; a last pass composes the trails over a haze and tonemaps to the canvas.
15
+
16
+ ; density: the one structural number. `(define …)` reaches every numeric slot
17
+ ; and never the WGSL, so the shaders read the buffer's own length instead.
18
+ (define :name N :value 24000)
19
+
20
+ ; --- kernels, checked inside the modules that assemble them ---
21
+ (wgsl :name hash :file "kernels/hash.wgsl")
22
+ (wgsl :name simplex :file "kernels/simplex.wgsl")
23
+ (wgsl :name curl :file "kernels/curl.wgsl" :imports [simplex])
24
+ (wgsl :name ember :file "kernels/ember.wgsl")
25
+
26
+ ; --- the params: PROGRAM defaults in a uniform the world may overwrite ---
27
+ ; grain, heat, and two pads. Filled from the data at creation and never
28
+ ; written by the frame, so a host write to `params` (the knob, the score)
29
+ ; lands and stays. (`:init` takes compute passes only, so a `(queue …)`
30
+ ; could not do this once.)
31
+ (data :name paramDefaults :float32 [4.7 0.6 0.0 0.0])
32
+ (buffer :name params :usage [uniform copy-dst] :data paramDefaults)
33
+
34
+ ; --- what the world supplies every frame: time and the pointer ---
35
+ ; The struct's members are named t, w, h, asp because the sugar passes
36
+ ; below build their own `pngine` uniform with time/width/height/aspect, and
37
+ ; a name shared by two buffers makes `setUniform` ambiguous (graph_compose).
38
+ ; `ptr` and `target` are reserved words in WGSL; naga says so where
39
+ ; wgslender lets `ptr` pass.
40
+ (buffer :name inputs :size 16 :usage [uniform copy-dst])
41
+ (queue :name writeInputs (write-buffer :buffer inputs :offset 0 :data pngine-inputs))
42
+ (buffer :name pointerBuf :size 48 :usage [uniform copy-dst])
43
+ (queue :name writePointer (write-buffer :buffer pointerBuf :offset 0 :data pointer-inputs))
44
+
45
+ ; --- letters: the span table, bytes verbatim, read by the seed pass ---
46
+ (data :name lettersTable (bytes :file "bake/spans.bin"))
47
+ (buffer :name letters :usage [storage] :data lettersTable)
48
+
49
+ ; --- dust: one record per particle, home, position, velocity, seed, pad ---
50
+ (buffer :name dust :size (* N 32) :usage [vertex storage] :pool 2)
51
+
52
+ ; seeds: `pixel → element`. Each invocation draws a cell of the word's
53
+ ; coverage, area-weighted, and makes it the particle's home. The table's
54
+ ; row cumulative is inclusive (zlug SPN1); row 0 is the top of the box.
55
+ (shader-module :name seedCode :imports [hash] :code """
56
+ struct Particle { home : vec2f, pos : vec2f, vel : vec2f, seed : f32, pad : f32 }
57
+ @group(0) @binding(0) var<storage, read> table : array<u32>;
58
+ @group(0) @binding(1) var<storage, read_write> a : array<Particle>;
59
+ @group(0) @binding(2) var<storage, read_write> b : array<Particle>;
60
+
61
+ // The bake's box, [0, 3.248] x [-0.244, 0.928] em, as an aspect. Baked
62
+ // into the WGSL because a `(define …)` cannot reach it; see bake/provenance.txt.
63
+ const BOX_ASPECT : f32 = 2.772;
64
+
65
+ @compute @workgroup_size(64)
66
+ fn main(@builtin(global_invocation_id) id : vec3u) {
67
+ let i = id.x;
68
+ if (i >= arrayLength(&a)) { return; }
69
+ let rows = table[1] & 0xffffu;
70
+ let cols = table[1] >> 16u;
71
+ let count = table[2];
72
+ let total = table[3];
73
+ let goal = min(u32(hash(i) * f32(total)), total - 1u);
74
+ // the first row whose inclusive cumulative passes the target
75
+ var lo = 0u;
76
+ var hi = rows - 1u;
77
+ while (lo < hi) {
78
+ let mid = (lo + hi) / 2u;
79
+ if (table[4u + mid * 2u + 1u] > goal) { hi = mid; } else { lo = mid + 1u; }
80
+ }
81
+ let r = lo;
82
+ let first = table[4u + r * 2u];
83
+ var next = count;
84
+ if (r + 1u < rows) { next = table[4u + (r + 1u) * 2u]; }
85
+ var before = 0u;
86
+ if (r > 0u) { before = table[4u + (r - 1u) * 2u + 1u]; }
87
+ var rem = goal - before;
88
+ var col = 0u;
89
+ for (var s = first; s < next; s = s + 1u) {
90
+ let w = table[4u + rows * 2u + s];
91
+ let x0 = w & 0xffffu;
92
+ let x1 = w >> 16u;
93
+ let width = x1 - x0;
94
+ if (rem < width) { col = x0 + rem; break; }
95
+ rem = rem - width;
96
+ col = x1 - 1u;
97
+ }
98
+ let u = (f32(col) + hash(i * 3u + 1u)) / f32(cols);
99
+ let v = (f32(r) + hash(i * 3u + 2u)) / f32(rows);
100
+ // title space: the word one unit wide, centred, y up
101
+ let home = vec2f((u - 0.5) * 1.0, (0.5 - v) / BOX_ASPECT);
102
+ var p : Particle;
103
+ p.home = home;
104
+ p.pos = home;
105
+ p.vel = vec2f(0.0, 0.0);
106
+ p.seed = hash(i * 3u + 3u);
107
+ p.pad = 0.0;
108
+ a[i] = p;
109
+ b[i] = p;
110
+ }
111
+ """)
112
+
113
+ ; dust: `element → element`. The curl step scaled by heat, the spring that
114
+ ; reassembles the word, the repel from the pointer, in title space, where
115
+ ; `title` is the space that rotates with time and carries the user's nudge.
116
+ (shader-module :name stepCode :imports [curl] :code """
117
+ struct Particle { home : vec2f, pos : vec2f, vel : vec2f, seed : f32, pad : f32 }
118
+ struct Params { grain : f32, heat : f32, pad0 : f32, pad1 : f32 }
119
+ struct Inputs { t : f32, w : f32, h : f32, asp : f32 }
120
+ struct Pointer { x : f32, y : f32, clickX : f32, clickY : f32, dx : f32, dy : f32,
121
+ buttons : f32, pressure : f32, modifiers : f32, scrollX : f32, scrollY : f32, pad : f32 }
122
+ @group(0) @binding(0) var<uniform> params : Params;
123
+ @group(0) @binding(1) var<uniform> inputs : Inputs;
124
+ @group(0) @binding(2) var<uniform> pointer : Pointer;
125
+ @group(0) @binding(3) var<storage, read> src : array<Particle>;
126
+ @group(0) @binding(4) var<storage, read_write> dst : array<Particle>;
127
+
128
+ // (space :name title :transform (trs :rotate (* time .05) :scale 1.1) :user (translate [.12 -.03]))
129
+ fn title(p : vec2f, t : f32) -> vec2f {
130
+ let a = t * 0.05;
131
+ let c = cos(a);
132
+ let s = sin(a);
133
+ let r = vec2f(p.x * c - p.y * s, p.x * s + p.y * c) * 1.1;
134
+ return r + vec2f(0.12, -0.03);
135
+ }
136
+
137
+ @compute @workgroup_size(64)
138
+ fn main(@builtin(global_invocation_id) id : vec3u) {
139
+ let i = id.x;
140
+ if (i >= arrayLength(&src)) { return; }
141
+ var p = src[i];
142
+ let t = inputs.t;
143
+ // A second is a hundred ticks and this pass runs once per tick, so the
144
+ // step is a hundredth whatever the paint rate (docs/05-model.md, the tick
145
+ // and the paint). One frame of the program is one tick; the player steps
146
+ // the due ticks and paints the last.
147
+ let dt = 0.01;
148
+ let goal = title(p.home, t);
149
+ // (curl :scale 3 :amount heat). The amount is per tick, a hundred a
150
+ // second: the curl's gradient is order one, and the spring below wins
151
+ // at rest, so the word breathes and reassembles rather than dissolves.
152
+ let flow = curl(p.pos + vec2f(p.seed * 0.01, 0.0), 3.0, t) * params.heat * 0.06;
153
+ // the reassembling: a spring to the home the title space carries
154
+ let spring = (goal - p.pos) * 6.0;
155
+ // (repel :from (convert pointer :to title) :radius .18): the pointer arrives in
156
+ // canvas uv, y down; title space is the sprite space below, y up
157
+ var repel = vec2f(0.0, 0.0);
158
+ if (pointer.x > 0.0 || pointer.y > 0.0) {
159
+ let at = vec2f((pointer.x * 2.0 - 1.0) / 1.3, (1.0 - pointer.y * 2.0) / (1.3 * inputs.asp));
160
+ let away = p.pos - at;
161
+ let d = length(away);
162
+ if (d < 0.18) { repel = normalize(away + vec2f(0.0001, 0.0)) * (0.18 - d) * 30.0; }
163
+ }
164
+ p.vel = (p.vel + (flow + spring + repel) * dt) * 0.92;
165
+ p.pos = p.pos + p.vel * dt;
166
+ dst[i] = p;
167
+ }
168
+ """)
169
+
170
+ ; sparks: `element → surface`. Every particle as a sprite, its colour from
171
+ ; its speed through the author's `ember` kernel, drawn into a texture.
172
+ (data :name quad :float32 [-1.0 -1.0 1.0 -1.0 -1.0 1.0 -1.0 1.0 1.0 -1.0 1.0 1.0])
173
+ (buffer :name quadVB :usage [vertex] :data quad)
174
+
175
+ (shader-module :name sparkCode :imports [ember] :code """
176
+ struct Inputs { t : f32, w : f32, h : f32, asp : f32 }
177
+ @group(0) @binding(0) var<uniform> inputs : Inputs;
178
+ struct VsOut { @builtin(position) pos : vec4f, @location(0) uv : vec2f, @location(1) color : vec3f }
179
+
180
+ @vertex
181
+ fn vs(@location(0) home : vec2f, @location(1) pos : vec2f, @location(2) vel : vec2f,
182
+ @location(3) corner : vec2f) -> VsOut {
183
+ // (paint … :size .004): title space to clip space, the word 1.3 wide,
184
+ // which keeps it inside the frame with the title's scale and nudge
185
+ let size = 0.004 * 1.3;
186
+ let centre = vec2f(pos.x * 1.3, pos.y * 1.3 * inputs.asp);
187
+ var out : VsOut;
188
+ out.pos = vec4f(centre + corner * vec2f(size, size * inputs.asp), 0.0, 1.0);
189
+ out.uv = corner;
190
+ out.color = ember(length(vel));
191
+ return out;
192
+ }
193
+
194
+ @fragment
195
+ fn fs(in : VsOut) -> @location(0) vec4f {
196
+ // The deposit per tick. The trails keep 0.965 of it a tick, a hundred
197
+ // ticks a second, so a still particle sums to about 28 deposits; this
198
+ // keeps the letters short of white and lets a moving one glow.
199
+ let d = length(in.uv);
200
+ let a = smoothstep(1.0, 0.3, d) * 0.035;
201
+ return vec4f(in.color * a, a);
202
+ }
203
+ """)
204
+
205
+ (texture :name sparksTex :size canvas :format rgba16float
206
+ :usage [render-attachment texture-binding])
207
+
208
+ (render-pipeline :name sparkPipe :layout auto
209
+ (vertex :module sparkCode :entry vs
210
+ (vertex-buffer :array-stride 32 :step-mode instance
211
+ (attribute :shader-location 0 :offset 0 :format float32x2)
212
+ (attribute :shader-location 1 :offset 8 :format float32x2)
213
+ (attribute :shader-location 2 :offset 16 :format float32x2))
214
+ (vertex-buffer :array-stride 8 :step-mode vertex
215
+ (attribute :shader-location 3 :offset 0 :format float32x2)))
216
+ (fragment :module sparkCode :entry fs
217
+ (color-target :format rgba16float
218
+ (blend (color :src-factor one :dst-factor one :operation add)
219
+ (alpha :src-factor one :dst-factor one :operation add))))
220
+ (primitive :topology triangle-list))
221
+
222
+ (bind-group :name sparkBinds :layout sparkPipe :group 0
223
+ (entry :binding 0 :buffer inputs))
224
+
225
+ (render-pass :name sparks
226
+ (color-attachment :view sparksTex :clear-value [0.0 0.0 0.0 0.0] :load-op clear :store-op store)
227
+ :pipeline sparkPipe
228
+ :bind-groups [sparkBinds]
229
+ :vertex-buffers [dust quadVB]
230
+ :vertex-buffers-pool-offsets [1 0]
231
+ (draw :vertex-count 6 :instance-count N))
232
+
233
+ ; ground: `pixel → surface`. The haze, a ramp over simplex noise at the
234
+ ; grain the params hold, as a hand-written fullscreen pass, because a
235
+ ; `(pass …)` cannot bind the params buffer.
236
+ (shader-module :name groundCode :imports [simplex] :code """
237
+ struct Params { grain : f32, heat : f32, pad0 : f32, pad1 : f32 }
238
+ struct Inputs { t : f32, w : f32, h : f32, asp : f32 }
239
+ @group(0) @binding(0) var<uniform> params : Params;
240
+ @group(0) @binding(1) var<uniform> inputs : Inputs;
241
+
242
+ @vertex
243
+ fn vs(@builtin(vertex_index) i : u32) -> @builtin(position) vec4f {
244
+ let x = f32(i32(i & 1u) * 4 - 1);
245
+ let y = f32(i32(i >> 1u) * 4 - 1);
246
+ return vec4f(x, y, 0.0, 1.0);
247
+ }
248
+
249
+ @fragment
250
+ fn fs(@builtin(position) pos : vec4f) -> @location(0) vec4f {
251
+ let uv = pos.xy / vec2f(inputs.w, inputs.h);
252
+ let p = vec2f(uv.x * inputs.asp, uv.y);
253
+ // (field :name haze :value (ramp (simplex position :scale grain)))
254
+ let n = simplex(p * params.grain + vec2f(inputs.t * 0.02, 0.0)) * 0.5 + 0.5;
255
+ let ramp = mix(vec3f(0.015, 0.008, 0.02), vec3f(0.14, 0.06, 0.05), n * n);
256
+ return vec4f(ramp, 1.0);
257
+ }
258
+ """)
259
+
260
+ (texture :name groundTex :size canvas :format rgba8unorm
261
+ :usage [render-attachment texture-binding])
262
+
263
+ (render-pipeline :name groundPipe :layout auto
264
+ (vertex :module groundCode :entry vs)
265
+ (fragment :module groundCode :entry fs (color-target :format rgba8unorm))
266
+ (primitive :topology triangle-list))
267
+
268
+ (bind-group :name groundBinds :layout groundPipe :group 0
269
+ (entry :binding 0 :buffer params)
270
+ (entry :binding 1 :buffer inputs))
271
+
272
+ (render-pass :name ground
273
+ (color-attachment :view groundTex :clear-value [0.0 0.0 0.0 1.0] :load-op clear :store-op store)
274
+ :pipeline groundPipe
275
+ :bind-groups [groundBinds]
276
+ (draw :vertex-count 3))
277
+
278
+ ; --- the compute pipelines and passes ---
279
+ (compute-pipeline :name seedPipe :layout auto (compute :module seedCode :entry main))
280
+ (bind-group :name seedBinds :layout seedPipe :group 0 :pool 2
281
+ (entry :binding 0 :buffer letters)
282
+ (entry :binding 1 :buffer dust :ping-pong 0)
283
+ (entry :binding 2 :buffer dust :ping-pong 1))
284
+ (compute-pass :name seed :pipeline seedPipe :bind-groups [seedBinds]
285
+ (dispatch :workgroups [(ceil (/ N 64))]))
286
+
287
+ (compute-pipeline :name stepPipe :layout auto (compute :module stepCode :entry main))
288
+ (bind-group :name stepBinds :layout stepPipe :group 0 :pool 2
289
+ (entry :binding 0 :buffer params)
290
+ (entry :binding 1 :buffer inputs)
291
+ (entry :binding 2 :buffer pointerBuf)
292
+ (entry :binding 3 :buffer dust :ping-pong 0)
293
+ (entry :binding 4 :buffer dust :ping-pong 1))
294
+ (compute-pass :name step :pipeline stepPipe :bind-groups [stepBinds]
295
+ (dispatch :workgroups [(ceil (/ N 64))]))
296
+
297
+ ; trails and final: `surface → memory` and `surface, surface → surface`.
298
+ ; The sugar tier reads the two textures by name; `prev_trails` is last
299
+ ; frame's output, decayed.
300
+ (pass-graph :name look
301
+ (pass :name trails :feedback true :reads [sparksTex] :format rgba16float :code """
302
+ @fragment fn fs(@builtin(position) pos: vec4f) -> @location(0) vec4f {
303
+ // The pass addresses its canvas-sized textures through pngine's own
304
+ // extent; the group's list therefore carries `trails__uq` in front of
305
+ // it, and the frame's solo text names that queue.
306
+ let uv = pos.xy / vec2f(pngine.width, pngine.height);
307
+ let at = vec2i(uv * vec2f(pngine.width, pngine.height));
308
+ // (feedback :name trails :source sparks :decay .965)
309
+ let prev = textureLoad(prev_trails, at, 0).rgb * 0.965;
310
+ let now = textureLoad(sparksTex, at, 0).rgb;
311
+ return vec4f(prev + now, 1.0);
312
+ }
313
+ """)
314
+ (pass :name final :reads [groundTex] :code """
315
+ @fragment fn fs(@builtin(position) pos: vec4f) -> @location(0) vec4f {
316
+ let uv = pos.xy / vec2f(pngine.width, pngine.height);
317
+ // (compose :name final :a ground :b trails), then a tonemap to the canvas
318
+ let g = textureSample(groundTex, samp, uv).rgb;
319
+ let t = textureSample(trails, samp, uv).rgb;
320
+ let hdr = g + t;
321
+ return vec4f(hdr / (hdr + vec3f(1.0)), 1.0);
322
+ }
323
+ """))
324
+
325
+ (frame :name main
326
+ :init [seed]
327
+ :before [writeInputs writePointer]
328
+ :perform [step sparks ground look])
@@ -0,0 +1,34 @@
1
+ ; The hello piece's derived spelling, the browser seat: the aggregate
2
+ ; carries tempura's own roots inline, so one fetch from the page is the
3
+ ; whole document the boundary walks (`PieceHost.score`, the wasm
4
+ ; `anm_piece_score`). A wasm boundary has no filesystem, so the split
5
+ ; spelling's `:timeline` file cannot reach it; these inline roots can. The
6
+ ; page opens this file with `?seat=timeline` and drives the derived score
7
+ ; through the same compile, evaluate and route path as its authored score
8
+ ; (`player/player.js`, `player/seat.js`). The param's appended control and
9
+ ; the `warmth` lane both name registry-0 `heat`, and pacer writes the
10
+ ; control after every track, so `heat` is the control's value on every
11
+ ; frame, idle at 0.6 and held at the slider's value, while the `churn`
12
+ ; lane's own `grain` target moves the picture (the tenth ruling,
13
+ ; 2026-10-07). The gate's bytes and numbers live in
14
+ ; `npm/animader/test/derived-seat.test.js`; the browser pass replays them
15
+ ; through `player/check.mjs` case 14.
16
+ (animader :name ember :version 1
17
+ :default-world live
18
+ :worlds [live])
19
+
20
+ (param :name heat :default 0.6 :range [0 2])
21
+
22
+ (world :name live
23
+ :time live :params defaults :input live :viewport [1280 720]
24
+ :assets project :state live)
25
+
26
+ (timeline :title "ember, the mix" :length 4bar :end hold)
27
+ (tempo-point :at 0q :qpm 120)
28
+ (meter-point :at 0q :sig [4 4])
29
+ (lane :name warmth :kind curve :target heat
30
+ (keyframe :at 0q :value 0.1)
31
+ (keyframe :at 4bar :value 0.9))
32
+ (lane :name churn :kind curve :target grain
33
+ (keyframe :at 0q :value 4.7)
34
+ (keyframe :at 4bar :value 9.4))