reze-engine 0.42.3 → 0.50.0

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 (140) hide show
  1. package/README.md +40 -410
  2. package/dist/camera.d.ts +3 -0
  3. package/dist/camera.d.ts.map +1 -1
  4. package/dist/camera.js +33 -8
  5. package/dist/engine.d.ts +924 -29
  6. package/dist/engine.d.ts.map +1 -1
  7. package/dist/engine.js +4043 -278
  8. package/dist/graph/registry.d.ts +2 -2
  9. package/dist/graph/registry.d.ts.map +1 -1
  10. package/dist/graph/registry.js +1 -1
  11. package/dist/graph/slots.d.ts +0 -1
  12. package/dist/graph/slots.d.ts.map +1 -1
  13. package/dist/graph/slots.js +37 -9
  14. package/dist/hdr.d.ts +18 -0
  15. package/dist/hdr.d.ts.map +1 -0
  16. package/dist/hdr.js +162 -0
  17. package/dist/ibl.d.ts +19 -0
  18. package/dist/ibl.d.ts.map +1 -0
  19. package/dist/ibl.js +113 -0
  20. package/dist/ik-solver.d.ts +2 -1
  21. package/dist/ik-solver.d.ts.map +1 -1
  22. package/dist/index.d.ts +5 -1
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +10 -0
  25. package/dist/math.d.ts +20 -1
  26. package/dist/math.d.ts.map +1 -1
  27. package/dist/math.js +23 -16
  28. package/dist/midi-loader.d.ts +10 -0
  29. package/dist/midi-loader.d.ts.map +1 -0
  30. package/dist/midi-loader.js +247 -0
  31. package/dist/model.d.ts +2 -13
  32. package/dist/model.d.ts.map +1 -1
  33. package/dist/model.js +29 -0
  34. package/dist/param-track.d.ts +48 -0
  35. package/dist/param-track.d.ts.map +1 -0
  36. package/dist/param-track.js +80 -0
  37. package/dist/physics/types.d.ts.map +1 -1
  38. package/dist/physics/types.js +3 -0
  39. package/dist/reflection.d.ts +27 -0
  40. package/dist/reflection.d.ts.map +1 -0
  41. package/dist/reflection.js +93 -0
  42. package/dist/shaders/anchor-table.d.ts +56 -0
  43. package/dist/shaders/anchor-table.d.ts.map +1 -0
  44. package/dist/shaders/anchor-table.js +128 -0
  45. package/dist/shaders/audio-api.d.ts +3 -0
  46. package/dist/shaders/audio-api.d.ts.map +1 -0
  47. package/dist/shaders/audio-api.js +81 -0
  48. package/dist/shaders/cast-api.d.ts +2 -0
  49. package/dist/shaders/cast-api.d.ts.map +1 -0
  50. package/dist/shaders/cast-api.js +121 -0
  51. package/dist/shaders/cast-layout.d.ts +21 -0
  52. package/dist/shaders/cast-layout.d.ts.map +1 -0
  53. package/dist/shaders/cast-layout.js +20 -0
  54. package/dist/shaders/lights.d.ts +79 -0
  55. package/dist/shaders/lights.d.ts.map +1 -0
  56. package/dist/shaders/lights.js +269 -0
  57. package/dist/shaders/lyrics-api.d.ts +39 -0
  58. package/dist/shaders/lyrics-api.d.ts.map +1 -0
  59. package/dist/shaders/lyrics-api.js +187 -0
  60. package/dist/shaders/materials/common.d.ts +2 -4
  61. package/dist/shaders/materials/common.d.ts.map +1 -1
  62. package/dist/shaders/materials/common.js +87 -35
  63. package/dist/shaders/midi-api.d.ts +10 -0
  64. package/dist/shaders/midi-api.d.ts.map +1 -0
  65. package/dist/shaders/midi-api.js +114 -0
  66. package/dist/shaders/passes/composite.d.ts +31 -15
  67. package/dist/shaders/passes/composite.d.ts.map +1 -1
  68. package/dist/shaders/passes/composite.js +225 -135
  69. package/dist/shaders/passes/cull.d.ts +2 -0
  70. package/dist/shaders/passes/cull.d.ts.map +1 -0
  71. package/dist/shaders/passes/cull.js +138 -0
  72. package/dist/shaders/passes/field-blit.d.ts +26 -0
  73. package/dist/shaders/passes/field-blit.d.ts.map +1 -0
  74. package/dist/shaders/passes/field-blit.js +65 -0
  75. package/dist/shaders/passes/grid.d.ts +31 -0
  76. package/dist/shaders/passes/grid.d.ts.map +1 -0
  77. package/dist/shaders/passes/grid.js +169 -0
  78. package/dist/shaders/passes/ground.d.ts +13 -1
  79. package/dist/shaders/passes/ground.d.ts.map +1 -1
  80. package/dist/shaders/passes/ground.js +170 -25
  81. package/dist/shaders/passes/hosted-api.d.ts +57 -0
  82. package/dist/shaders/passes/hosted-api.d.ts.map +1 -0
  83. package/dist/shaders/passes/hosted-api.js +166 -0
  84. package/dist/shaders/passes/id-debug.d.ts +28 -0
  85. package/dist/shaders/passes/id-debug.d.ts.map +1 -0
  86. package/dist/shaders/passes/id-debug.js +74 -0
  87. package/dist/shaders/passes/particles.d.ts +66 -0
  88. package/dist/shaders/passes/particles.d.ts.map +1 -0
  89. package/dist/shaders/passes/particles.js +279 -0
  90. package/dist/shaders/passes/scene-contract.d.ts +128 -0
  91. package/dist/shaders/passes/scene-contract.d.ts.map +1 -0
  92. package/dist/shaders/passes/scene-contract.js +207 -0
  93. package/dist/shaders/passes/sim.d.ts +34 -0
  94. package/dist/shaders/passes/sim.d.ts.map +1 -0
  95. package/dist/shaders/passes/sim.js +169 -0
  96. package/dist/shaders/passes/trails.d.ts +59 -0
  97. package/dist/shaders/passes/trails.d.ts.map +1 -0
  98. package/dist/shaders/passes/trails.js +340 -0
  99. package/dist/shaders/score-api.d.ts +10 -0
  100. package/dist/shaders/score-api.d.ts.map +1 -0
  101. package/dist/shaders/score-api.js +114 -0
  102. package/dist/shadow-cascades.d.ts +45 -0
  103. package/dist/shadow-cascades.d.ts.map +1 -0
  104. package/dist/shadow-cascades.js +70 -0
  105. package/dist/vmd-loader.d.ts +3 -2
  106. package/dist/vmd-loader.d.ts.map +1 -1
  107. package/package.json +1 -1
  108. package/src/camera.ts +31 -8
  109. package/src/engine.ts +4535 -296
  110. package/src/graph/registry.ts +2 -2
  111. package/src/graph/slots.ts +37 -9
  112. package/src/hdr.ts +156 -0
  113. package/src/ibl.ts +115 -0
  114. package/src/ik-solver.ts +1 -1
  115. package/src/index.ts +12 -0
  116. package/src/math.ts +23 -17
  117. package/src/midi-loader.ts +246 -0
  118. package/src/model.ts +31 -3
  119. package/src/param-track.ts +83 -0
  120. package/src/physics/types.ts +4 -1
  121. package/src/reflection.ts +94 -0
  122. package/src/shaders/anchor-table.ts +147 -0
  123. package/src/shaders/audio-api.ts +82 -0
  124. package/src/shaders/cast-api.ts +123 -0
  125. package/src/shaders/cast-layout.ts +20 -0
  126. package/src/shaders/lights.ts +280 -0
  127. package/src/shaders/lyrics-api.ts +202 -0
  128. package/src/shaders/materials/common.ts +89 -35
  129. package/src/shaders/midi-api.ts +116 -0
  130. package/src/shaders/passes/composite.ts +244 -136
  131. package/src/shaders/passes/cull.ts +139 -0
  132. package/src/shaders/passes/grid.ts +178 -0
  133. package/src/shaders/passes/ground.ts +172 -25
  134. package/src/shaders/passes/hosted-api.ts +171 -0
  135. package/src/shaders/passes/id-debug.ts +75 -0
  136. package/src/shaders/passes/particles.ts +340 -0
  137. package/src/shaders/passes/scene-contract.ts +266 -0
  138. package/src/shaders/passes/trails.ts +390 -0
  139. package/src/shadow-cascades.ts +97 -0
  140. package/src/vmd-loader.ts +2 -2
@@ -1,3 +1,12 @@
1
+ import { RZ_LIGHT_STRUCT_WGSL } from "../lights"
2
+ import { anchorAliasWgsl } from "../anchor-table"
3
+ import { CAST_API } from "../cast-api"
4
+ import { clockApi, EFFECT_MATH_API, PARTICLE_STRUCT_WGSL, trailSlotsApi, viewportApi } from "./hosted-api"
5
+ import { EFFECT_ANCHORS, EFFECT_SUBJECTS, EFFECT_TRAIL_BASE, EFFECT_TRAIL_SAMPLES } from "../cast-layout"
6
+ import { audioApi } from "../audio-api"
7
+ import { lyricsApi, lyricsTextApi } from "../lyrics-api"
8
+ import { midiApi } from "../midi-api"
9
+ import { gridReadApi } from "./grid"
1
10
  // Composite: HDR scene + bloom pyramid → Filmic tone map → gamma → swapchain.
2
11
  // Bloom tint/intensity applied at combine (EEVEE treats them as combine-stage params, not prefilter).
3
12
  //
@@ -71,22 +80,11 @@ export function parseEffectAnchors(wgsl: string, max: number): { bone: string; t
71
80
  .slice(0, max)
72
81
  }
73
82
 
74
- /**
75
- * The caps the cast buffer is built to, shared by the shader below and by the
76
- * engine that fills it. Interpolated into the WGSL rather than written twice:
77
- * the layout arithmetic on both sides has to agree exactly, and two literals
78
- * that must match are two literals that eventually will not.
79
- *
80
- * All three are MINIMUMS. Raising one breaks nothing, because effects read
81
- * through accessors and loop to the count functions; lowering one does.
82
- */
83
- export const EFFECT_SUBJECTS = 4
84
- export const EFFECT_ANCHORS = 8
85
- export const EFFECT_TRAIL_SAMPLES = 128
86
- /** vec4 slot where the trails begin — after the subjects and the anchors. */
87
- export const EFFECT_TRAIL_BASE = EFFECT_SUBJECTS * 3 + EFFECT_ANCHORS * EFFECT_SUBJECTS * 3
83
+ // The cast's caps live in cast-layout.ts — re-exported here because half the
84
+ // engine imports them from this file and the constants did not move in meaning.
85
+ export { EFFECT_ANCHORS, EFFECT_SUBJECTS, EFFECT_TRAIL_BASE, EFFECT_TRAIL_SAMPLES } from "../cast-layout"
88
86
 
89
- export type CompositeEffectSource = {
87
+ type CompositeEffectSource = {
90
88
  /** The user's WGSL verbatim: helpers plus whichever entry points it defines. */
91
89
  wgsl: string
92
90
  /** Codegen'd `struct EffectParams {...}` + binding decl; empty when no params. */
@@ -95,6 +93,20 @@ export type CompositeEffectSource = {
95
93
  hasBackground: boolean
96
94
  /** Defines `fn foreground(...)` — mount over the finished frame. */
97
95
  hasForeground: boolean
96
+ /** Grid resolution when the effect declared `// @grid`, else 0. */
97
+ gridSize: number
98
+ /** Whether the scene pass carries the id attachment, so the field module can
99
+ * bind it. False emits accessors that answer 0 rather than nothing at all. */
100
+ ids?: boolean
101
+ /** How many of this effect's anchors asked for a trail — RZ_TRAIL_SLOTS,
102
+ * which a hosted particle or trail loop reads even in this module. REQUIRED,
103
+ * and deliberately not defaulted: a silent 0 is a ribbon loop that iterates
104
+ * nothing, which looks exactly like an effect that drew nothing. */
105
+ trailCount: number
106
+ /** This effect's local anchor slot → scene slot, from the shared table.
107
+ * Omitted or identity when it owns the table. The particle and trail modules
108
+ * have always taken this; the field module not taking it was the bug. */
109
+ alias?: number[]
98
110
  }
99
111
 
100
112
  const COMPOSITE_HEAD = /* wgsl */ `
@@ -162,13 +174,39 @@ override APPLY_GAMMA: bool = true;
162
174
  // vec4 slots: [0 .. 11] four subjects, three each (root+valid, hip, bounds);
163
175
  // then MAX_ANCHORS × four subjects, three each (pos+valid, vel, fwd).
164
176
  @group(0) @binding(11) var<storage, read> _rzCast: array<vec4f>;
165
-
177
+ // The FIELD LAYER: the user's background/foreground mounts, rendered at half
178
+ // resolution in their own pass (see buildFieldShader) and sampled here. Field
179
+ // effects are glows, fog, shafts, gradients — low-frequency by nature — and
180
+ // running them per quarter-pixel was the single largest per-frame cost an
181
+ // effect could add. Bilinear upsampling is invisible at that frequency.
182
+ // The field layer, ONE PAIR PER RESOLUTION. 15/16 are full res, 20/21 half.
183
+ // An effect draws into the pair it declared, so a starfield that upsamples
184
+ // perfectly no longer pays full-resolution pixels because a neighbour needed
185
+ // crisp edges. Both pairs are read every frame and combined full-over-half
186
+ // below; a pair NO effect draws into is bound to a 1x1 transparent texture
187
+ // instead of a target, so nothing has to clear a full-res pair to hand this
188
+ // shader the transparent black it would then read.
189
+ @group(0) @binding(15) var fieldBgTex: texture_2d<f32>;
190
+ @group(0) @binding(16) var fieldFgTex: texture_2d<f32>;
191
+ @group(0) @binding(20) var fieldBgHalfTex: texture_2d<f32>;
192
+ @group(0) @binding(21) var fieldFgHalfTex: texture_2d<f32>;
193
+
194
+ /** Two field layers into one, premultiplied OVER, top in front. A resolution
195
+ * boundary is a layer boundary: within a pair, effects blend in document
196
+ * order; across pairs, full res wins. (No backticks in here — this string is
197
+ * a TS template literal and one ends it mid-shader.) */
198
+ fn rzFieldMerge(top: vec4f, bot: vec4f) -> vec4f {
199
+ return vec4f(top.rgb + bot.rgb * (1.0 - top.a), top.a + bot.a * (1.0 - top.a));
200
+ }
166
201
  // Must match FILMIC_LUT_WIDTH in engine.ts (bakeFilmicLut).
167
202
  const FILMIC_LUT_W: f32 = 256.0;
168
203
 
169
204
  fn linearDepth(coord: vec2<i32>) -> f32 {
170
205
  let z = textureLoad(depthTex, coord, 0);
171
- // projA > 1 for every valid z in [0,1], so the divisor never crosses zero.
206
+ // projA and projB are m[10] and m[14] of whatever projection the camera built,
207
+ // so this one line inverts both conventions. Non-reversed puts projA just above
208
+ // 1; reversed puts it slightly below 0. Either way (z - projA) keeps its sign
209
+ // across the whole of z in [0,1], so the divisor never crosses zero.
172
210
  return clamp(dofU[2].y / (z - dofU[2].x), 0.05, 100000.0);
173
211
  }
174
212
 
@@ -256,7 +294,22 @@ fn viewTransform(c: vec3f) -> vec3f {
256
294
  if (mode > 0.5) { return vec3f(srgb_encode(c.r), srgb_encode(c.g), srgb_encode(c.b)); }
257
295
  return vec3f(filmic(c.r), filmic(c.g), filmic(c.b));
258
296
  }
297
+ `
259
298
 
299
+ /**
300
+ * The scene half of the effect API — camera, projection, cast, trails.
301
+ *
302
+ * Split out of COMPOSITE_HEAD so the SIM pass can have it too. One effect file
303
+ * is spliced into every module it has a mount in, so a file with both a grid and
304
+ * a foreground compiles its foreground inside the grid shader, where every
305
+ * rzCameraPos() in it has to resolve. Sharing the block is better than stubbing
306
+ * it twice, and it means a kernel can legitimately ask where a bone is — which
307
+ * is exactly what a wake wants.
308
+ *
309
+ * Depends on `viewU` and `_rzCast` being declared by the including module, and
310
+ * on nothing else: no textures, no samplers.
311
+ */
312
+ export const EFFECT_SCENE_API = /* wgsl */ `
260
313
  // ── The effect API ────────────────────────────────────────────────────────────
261
314
  //
262
315
  // Named rz*, for the engine. The prefix earns its place twice: user code is
@@ -302,104 +355,22 @@ fn rzProject(p: vec3f) -> vec3f {
302
355
  return vec3f(ndc * 0.5 + 0.5, z);
303
356
  }
304
357
 
305
- /** A character, as much of one as a shader needs. */
306
- struct RzSubject {
307
- /** On the FLOOR, under the body — where a ring or a magic circle belongs. */
308
- root: vec3f,
309
- /** At the hips, the middle of the body — where an aura belongs. */
310
- center: vec3f,
311
- /** Bounding sphere: xyz centre, w radius. Deliberately generous — cull with it. */
312
- bounds: vec4f,
313
- /** False past the end of the cast, and every field is then zero. */
314
- valid: bool,
315
- }
358
+ fn rzCamPos() -> vec3f { return rzCameraPos(); }
359
+ fn rzCameraRight() -> vec3f { return viewU[3].xyz; }
360
+ fn rzCameraUp() -> vec3f { return viewU[4].xyz; }
361
+ fn rzCameraForward() -> vec3f { return viewU[5].xyz; }
316
362
 
317
- /** One bone an effect asked for, by name, at the top of its own source. */
318
- struct RzAnchor {
319
- pos: vec3f,
320
- /** World units per second, from the previous frame. Direction for a trail,
321
- * magnitude for anything that should react to how hard someone is moving. */
322
- vel: vec3f,
323
- /** The bone's forward axis — which way a foot points, where a head looks. */
324
- fwd: vec3f,
325
- /** False when this rig has no such bone. Check it: the alternative is drawing
326
- * a hand effect at the world origin on every model that spells it differently. */
327
- valid: bool,
328
- }
363
+ // The cast — subjects, anchors, trails — is CAST_API, shared verbatim with the
364
+ // particle and trail modules. It used to be written out here, a second time, and
365
+ // the two copies had drifted: this one had rzAnchor and that one did not.
366
+ ${CAST_API}
329
367
 
330
- const RZ_MAX_ANCHORS: i32 = ${EFFECT_ANCHORS};
368
+ // The hashes, the noise and rzFalloff — EFFECT_MATH_API, the same text the
369
+ // particle and trail modules get. The field module used to be missing most of
370
+ // it, so a helper an author wrote for one mount failed to compile in another
371
+ // for no reason visible in the file.
372
+ ${EFFECT_MATH_API}${PARTICLE_STRUCT_WGSL}
331
373
 
332
- /**
333
- * Character i. Loop to rzSubjectCount(), never to a constant — the caps here are
334
- * MINIMUMS and are free to grow, which is only true while nobody hardcodes them.
335
- */
336
- fn rzSubject(i: i32) -> RzSubject {
337
- var s: RzSubject;
338
- s.valid = i >= 0 && i < rzSubjectCount();
339
- if (!s.valid) { return s; }
340
- let b = i * 3;
341
- s.root = _rzCast[b].xyz;
342
- s.center = _rzCast[b + 1].xyz;
343
- s.bounds = _rzCast[b + 2];
344
- return s;
345
- }
346
-
347
- /**
348
- * The slot-th bone this effect declared, on character subject.
349
- *
350
- * Slots are the order of the declarations at the top of your source:
351
- *
352
- * // @anchor 左手首
353
- * // @anchor 頭
354
- *
355
- * gives you slot 0 and slot 1. Any bone name the model has works; valid is
356
- * false when it does not have it, which is the normal case across rigs that
357
- * spell things differently.
358
- */
359
- fn rzAnchor(subject: i32, slot: i32) -> RzAnchor {
360
- var a: RzAnchor;
361
- a.valid = false;
362
- if (subject < 0 || subject >= rzSubjectCount() || slot < 0 || slot >= RZ_MAX_ANCHORS) { return a; }
363
- let b = ${EFFECT_SUBJECTS * 3} + (slot * ${EFFECT_SUBJECTS} + subject) * 3;
364
- a.valid = _rzCast[b].w > 0.5;
365
- a.pos = _rzCast[b].xyz;
366
- a.vel = _rzCast[b + 1].xyz;
367
- a.fwd = _rzCast[b + 2].xyz;
368
- return a;
369
- }
370
-
371
- const RZ_TRAIL_SAMPLES: i32 = ${EFFECT_TRAIL_SAMPLES};
372
-
373
- /**
374
- * How many path samples this anchor has. Zero unless it was declared with
375
- * trail, and it climbs from zero as the trail fills after the effect loads.
376
- *
377
- * Loop to THIS, never to RZ_TRAIL_SAMPLES: the cap is a minimum and is free to
378
- * grow, which stays true only while nobody hardcodes it.
379
- */
380
- fn rzTrailCount(subject: i32, slot: i32) -> i32 {
381
- if (subject < 0 || subject >= rzSubjectCount() || slot < 0 || slot >= RZ_MAX_ANCHORS) { return 0; }
382
- return i32(_rzCast[${EFFECT_SUBJECTS * 3} + (slot * ${EFFECT_SUBJECTS} + subject) * 3 + 2].w);
383
- }
384
-
385
- /**
386
- * Sample i of an anchor's path: xyz where it was, w how many seconds ago.
387
- *
388
- * i = 0 is NOW and they run backwards in time, so a ribbon is drawn by walking i
389
- * upward and fading on .w. Sampled at a fixed rate on the SCENE clock, not the
390
- * display's — so the path is identical in the editor, in an export, and in a
391
- * re-export, and its spacing does not change with framerate.
392
- *
393
- * This is what a hand trail wants instead of position and velocity. One position
394
- * and one velocity is a straight segment that jitters, because a velocity is a
395
- * difference between two frames; a path is what actually happened.
396
- */
397
- fn rzTrail(subject: i32, slot: i32, i: i32) -> vec4f {
398
- let n = rzTrailCount(subject, slot);
399
- if (i < 0 || i >= n) { return vec4f(0.0); }
400
- let base = ${EFFECT_TRAIL_BASE} + (slot * ${EFFECT_SUBJECTS} + subject) * RZ_TRAIL_SAMPLES;
401
- return _rzCast[base + i];
402
- }
403
374
 
404
375
  fn bgResolution() -> vec2f { return rzResolution(); }
405
376
  fn bgCameraPos() -> vec3f { return rzCameraPos(); }
@@ -446,6 +417,7 @@ fn rzWorldPos(ray: vec3f, depth: f32) -> vec3f {
446
417
  }
447
418
 
448
419
  fn bgWorldPos(ray: vec3f, depth: f32) -> vec3f { return rzWorldPos(ray, depth); }
420
+ ${RZ_LIGHT_STRUCT_WGSL}
449
421
 
450
422
  /** Color grading, applied to the tonemapped SCENE (not the background — see the
451
423
  * call site). The core is ASC CDL, the film-industry interchange standard:
@@ -570,6 +542,19 @@ const COMPOSITE_BODY = /* wgsl */ `
570
542
  let su = 0.5 + atan2(dir.x, dir.z) * 0.15915494309; // 1/(2π)
571
543
  let sv = 0.5 - asin(clamp(dir.y, -1.0, 1.0)) * 0.31830988618; // 1/π
572
544
  bgPm = textureSampleLevel(bgEquirect, bloomSamp, vec2f(su, sv), 0.0).rgb;
545
+ if (bg.w > 2.5) {
546
+ // Mode 3: the texels are scene-linear RADIANCE, not display wallpaper.
547
+ // Same exposure, same view transform, same user gamma as the scene —
548
+ // one film for everything in frame, which is what makes a sun roll off
549
+ // like a sun instead of clipping at texture white. bg.x carries the
550
+ // world STRENGTH (the colour slot is dead in equirect modes). The
551
+ // scene's grade stays scene-only, the documented rule above.
552
+ var sky = max(viewTransform(bgPm * bg.x * viewU[0].x), vec3f(0.0));
553
+ if (APPLY_GAMMA) {
554
+ sky = pow(sky, vec3f(viewU[0].y));
555
+ }
556
+ bgPm = sky;
557
+ }
573
558
  }
574
559
  BACKGROUND_CALL
575
560
  }
@@ -577,6 +562,11 @@ const COMPOSITE_BODY = /* wgsl */ `
577
562
  // expression, because the foreground mount composites onto it.
578
563
  var outRgb = disp * sceneAlpha + bgPm * (1.0 - sceneAlpha);
579
564
  var outA = sceneAlpha + bgA * (1.0 - sceneAlpha);
565
+ // Ribbons are NOT read here any more: they draw inside the scene pass, so
566
+ // they are already in disp — tone mapped, and bloomed, which they never
567
+ // were while this line existed. Sampling them here as well would draw them
568
+ // twice, and the layer nothing clears would go stale the moment an effect
569
+ // was removed.
580
570
  FOREGROUND_CALL
581
571
  return vec4f(outRgb, outA);
582
572
  }
@@ -587,9 +577,16 @@ const COMPOSITE_BODY = /* wgsl */ `
587
577
  // OVER onto the base layer. No `if` around it: the pipeline is rebuilt per
588
578
  // effect, so this text only exists in variants whose WGSL defines background().
589
579
  const BACKGROUND_CALL = /* wgsl */ `
590
- let bgUv = vec2f(fragCoord.x / fullSz.x, 1.0 - fragCoord.y / fullSz.y);
591
- let bgFx = clamp(background(dir, bgUv, viewU[6].x), vec4f(0.0), vec4f(1.0));
592
- bgPm = bgFx.rgb * bgFx.a + bgPm * (1.0 - bgFx.a);
580
+ // The field layer is PREMULTIPLIED: N effects blend into it in document
581
+ // order, and premultiplied is the only form in which repeated OVER composes
582
+ // associatively — straight alpha would need the divide back out on every
583
+ // draw. So rgb is already scaled by its own alpha and must not be again.
584
+ // With one effect drawing over a cleared target this is identical to the
585
+ // straight form it replaced.
586
+ let bgFx = rzFieldMerge(
587
+ clamp(textureSampleLevel(fieldBgTex, bloomSamp, fragCoord.xy / fullSz, 0.0), vec4f(0.0), vec4f(1.0)),
588
+ clamp(textureSampleLevel(fieldBgHalfTex, bloomSamp, fragCoord.xy / fullSz, 0.0), vec4f(0.0), vec4f(1.0)));
589
+ bgPm = bgFx.rgb + bgPm * (1.0 - bgFx.a);
593
590
  bgA = bgFx.a + bgA * (1.0 - bgFx.a);
594
591
  `
595
592
 
@@ -597,21 +594,14 @@ const BACKGROUND_CALL = /* wgsl */ `
597
594
  // base. Ungated by design: a foreground runs at every pixel, including the ones
598
595
  // the model covers, because covering them is the point.
599
596
  const FOREGROUND_CALL = /* wgsl */ `
600
- let fgUv = vec2f(fragCoord.x / fullSz.x, 1.0 - fragCoord.y / fullSz.y);
601
- // The scene's own depth, so the effect can tell what is in front of it: a
602
- // petal compares its distance against this and lets the model take the pixel,
603
- // and fog's alpha is nothing but a function of it. Pixels the scene never drew
604
- // read the far plane, so distance fog closes over the backdrop too.
605
- let fgFx = clamp(foreground(dir, fgUv, viewU[6].x, linearDepth(coord)), vec4f(0.0), vec4f(1.0));
606
- outRgb = fgFx.rgb * fgFx.a + outRgb * (1.0 - fgFx.a);
597
+ // Premultiplied, as the background layer above — same reason.
598
+ let fgFx = rzFieldMerge(
599
+ clamp(textureSampleLevel(fieldFgTex, bloomSamp, fragCoord.xy / fullSz, 0.0), vec4f(0.0), vec4f(1.0)),
600
+ clamp(textureSampleLevel(fieldFgHalfTex, bloomSamp, fragCoord.xy / fullSz, 0.0), vec4f(0.0), vec4f(1.0)));
601
+ outRgb = fgFx.rgb + outRgb * (1.0 - fgFx.a);
607
602
  outA = fgFx.a + outA * (1.0 - fgFx.a);
608
603
  `
609
604
 
610
- // Derivative builtins are illegal in non-uniform control flow (WGSL uniformity
611
- // analysis rejects the pipeline), so the coverage gate below can only wrap
612
- // effect code that doesn't use them. Checked textually at build time.
613
- const USES_DERIVATIVES = /\b(?:fwidth|dpdx|dpdy)(?:Fine|Coarse)?\s*\(/
614
-
615
605
  /** The condition on the background block (equirect sample + background effect).
616
606
  *
617
607
  * Two jobs. It skips the block behind pixels the model fully covers — the
@@ -620,33 +610,151 @@ const USES_DERIVATIVES = /\b(?:fwidth|dpdx|dpdy)(?:Fine|Coarse)?\s*\(/
620
610
  * most). And with no background effect compiled in, it also skips the block
621
611
  * entirely unless the equirect needs it.
622
612
  *
623
- * The equirect uses explicit-LOD sampling, which is always legal in non-uniform
624
- * flow; only derivative-using effects must keep uniform control flow and forgo
625
- * the coverage half. The test is textual over the whole file, so a foreground
626
- * that uses fwidth costs the background its gate — conservative, and only ever
627
- * in the direction of correctness. (The foreground mount itself sits in uniform
628
- * flow, so derivatives are always legal there.) */
613
+ * Everything the gate wraps is an explicit-LOD sample or a texture read, both
614
+ * always legal in non-uniform flow. It used to also wrap the user's code, which
615
+ * meant an effect using a derivative builtin had to forfeit the gate; that
616
+ * carve-out went with the field pass, and the last of it is below. */
629
617
  function backgroundCondition(effect?: CompositeEffectSource | null): string {
630
618
  // sceneAlpha, not alpha: the bokeh gather spreads coverage, so a pixel the
631
619
  // sharp scene fully covered can end up needing background behind its blur.
620
+ // (The old derivative carve-out is gone with the inline user code: the field
621
+ // pass runs the whole quad, which is uniform control flow by construction.)
632
622
  const coverage = "sceneAlpha < 0.999"
633
623
  if (!effect?.hasBackground) return `bg.w > 1.5 && ${coverage}`
634
- return USES_DERIVATIVES.test(effect.wgsl) ? "true" : coverage
624
+ return coverage
635
625
  }
636
626
 
637
627
  export function buildCompositeShader(effect?: CompositeEffectSource | null): string {
638
628
  const body = COMPOSITE_BODY.replace("BACKGROUND_COND", backgroundCondition(effect))
639
629
  .replace("BACKGROUND_CALL", effect?.hasBackground ? BACKGROUND_CALL.trim() : "")
640
630
  .replace("FOREGROUND_CALL", effect?.hasForeground ? FOREGROUND_CALL.trim() : "")
641
- if (!effect) return COMPOSITE_HEAD + body
631
+ // The composite is STATIC either way now: the user's code compiles in the
632
+ // field module alone, and the composite only decides whether to sample it.
633
+ return COMPOSITE_HEAD +
634
+ EFFECT_SCENE_API + anchorAliasWgsl(effect?.alias ?? []) + audioApi(0, 13) + midiApi(0, 19) + lyricsApi(0, 24) + body
635
+ }
636
+
637
+ /**
638
+ * The field pass: the user's background/foreground mounts at half resolution,
639
+ * into two rgba16f targets the composite bilinearly upsamples.
640
+ *
641
+ * The fragment reconstructs the FULL-resolution pixel it stands in for and runs
642
+ * the original derivation verbatim — same ndc, same ray, same uv, same depth
643
+ * read — so an effect cannot tell it moved; it is simply asked half as often
644
+ * in each direction.
645
+ */
646
+ /**
647
+ * Reading the scene's id attachment from a field effect — the consumer the MRT
648
+ * work exists for.
649
+ *
650
+ * A field effect covers the whole screen and has no idea what it is drawing
651
+ * over. These make it addressable: mask a glow to one character, dissolve one
652
+ * material, outline the thing someone selected. Without them the id buffer is
653
+ * written every frame and read by nobody.
654
+ *
655
+ * MULTISAMPLED AND UNRESOLVED, so it is read the way it is written —
656
+ * textureLoad of sample 0, the same rule linearDepth already follows. An
657
+ * averaged id belongs to nothing.
658
+ *
659
+ * When ids are OFF the buffer does not exist, so the accessors are still
660
+ * DECLARED and answer 0 — the reserved nothing. An effect that masks by id then
661
+ * masks nothing at all, which is a scene that renders rather than a shader that
662
+ * will not compile.
663
+ */
664
+ function idApi(on: boolean, group: number, binding: number): string {
665
+ if (!on) {
666
+ return /* wgsl */ `
667
+ fn rzObjectAt(uv: vec2f) -> u32 { return 0u; }
668
+ fn rzMaterialAt(uv: vec2f) -> u32 { return 0u; }
669
+ `
670
+ }
671
+ return /* wgsl */ `
672
+ @group(${group}) @binding(${binding}) var _rzIdTex: texture_multisampled_2d<u32>;
673
+
674
+ /** Which OBJECT drew this pixel — compare against rzSubjectId(i). 0 = nothing. */
675
+ fn rzObjectAt(uv: vec2f) -> u32 {
676
+ let sz = vec2f(textureDimensions(_rzIdTex));
677
+ let p = vec2<i32>(clamp(uv, vec2f(0.0), vec2f(1.0)) * sz);
678
+ return textureLoad(_rzIdTex, clamp(p, vec2<i32>(0), vec2<i32>(sz) - vec2<i32>(1)), 0).y;
679
+ }
680
+
681
+ /** Which MATERIAL drew it, within that object. 0 = nothing. */
682
+ fn rzMaterialAt(uv: vec2f) -> u32 {
683
+ let sz = vec2f(textureDimensions(_rzIdTex));
684
+ let p = vec2<i32>(clamp(uv, vec2f(0.0), vec2f(1.0)) * sz);
685
+ return textureLoad(_rzIdTex, clamp(p, vec2<i32>(0), vec2<i32>(sz) - vec2<i32>(1)), 0).x;
686
+ }
687
+ `
688
+ }
689
+
690
+ export function buildFieldShader(effect: CompositeEffectSource): string {
691
+ const bgLine = effect.hasBackground
692
+ ? "out.bg = clamp(background(dir, uv, _rzFieldClock.x), vec4f(0.0), vec4f(1.0));"
693
+ : ""
694
+ const fgLine = effect.hasForeground
695
+ ? "out.fg = clamp(foreground(dir, uv, _rzFieldClock.x, linearDepth(vec2<i32>(min(fx, fullSz - 1.0)))), vec4f(0.0), vec4f(1.0));"
696
+ : ""
642
697
  return (
643
698
  COMPOSITE_HEAD +
699
+ EFFECT_SCENE_API +
700
+ anchorAliasWgsl(effect.alias ?? []) +
701
+ audioApi(0, 13) +
702
+ midiApi(0, 19) +
703
+ lyricsApi(0, 24) +
704
+ // The words themselves — the atlas rides the grid's sampler, which is
705
+ // declared just below and resolves module-wide.
706
+ lyricsTextApi(0, 25, "_rzGridSamp") +
707
+ // The persistent grid, always bound — a 1×1 of zeroes when the effect has
708
+ // none, so rzGrid() is a function that always exists rather than one an
709
+ // author has to know whether they are allowed to call.
710
+ gridReadApi(0, 17, 18, effect.gridSize) +
711
+ clockApi("_rzFieldClock.x", "0.0") +
712
+ viewportApi("viewU[6].w") +
713
+ trailSlotsApi(effect.trailCount) +
714
+ idApi(effect.ids === true, 0, 23) +
644
715
  "\n// ── user effect (setEffect) ──\n" +
645
716
  effect.paramsDecl +
646
717
  "\n" +
647
718
  effect.wgsl +
648
719
  "\n" +
649
- body
720
+ /* wgsl */ `
721
+ @group(0) @binding(14) var<uniform> fieldU: vec4f;
722
+ /**
723
+ * THIS EFFECT'S OWN clock, seconds since it was installed.
724
+ *
725
+ * Per effect, and that is the whole point of it existing. The time argument
726
+ * used to come from viewU[6].x, which is measured from the FIRST installed
727
+ * effect's epoch — so every later effect started mid-stream, and an effect
728
+ * whose lightEmit read its own epoch disagreed with its own background()
729
+ * about what time it was. One buffer per effect, one answer.
730
+ */
731
+ @group(0) @binding(22) var<uniform> _rzFieldClock: vec4f;
732
+
733
+ @vertex fn fieldVs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
734
+ let x = f32((vi & 1u) << 2u) - 1.0;
735
+ let y = f32((vi & 2u) << 1u) - 1.0;
736
+ return vec4f(x, y, 0.0, 1.0);
737
+ }
738
+
739
+ struct FieldOut {
740
+ @location(0) bg: vec4f,
741
+ @location(1) fg: vec4f,
742
+ }
743
+
744
+ @fragment fn fieldFs(@builtin(position) fragCoord: vec4f) -> FieldOut {
745
+ let fullSz = fieldU.zw;
746
+ let fx = fragCoord.xy * (fullSz / max(fieldU.xy, vec2f(1.0)));
747
+ let ndc = vec2f(fx.x / fullSz.x * 2.0 - 1.0, 1.0 - fx.y / fullSz.y * 2.0);
748
+ let dir = normalize(viewU[5].xyz + ndc.x * viewU[3].w * viewU[3].xyz + ndc.y * viewU[4].w * viewU[4].xyz);
749
+ let uv = vec2f(fx.x / fullSz.x, 1.0 - fx.y / fullSz.y);
750
+ var out: FieldOut;
751
+ out.bg = vec4f(0.0);
752
+ out.fg = vec4f(0.0);
753
+ ${bgLine}
754
+ ${fgLine}
755
+ return out;
756
+ }
757
+ `
650
758
  )
651
759
  }
652
760
 
@@ -0,0 +1,139 @@
1
+ // GPU frustum cull writing indirect draw arguments.
2
+ //
3
+ // One dispatch covers every material draw in the scene and writes THREE
4
+ // argument buffers — the camera pass, the shadow pass and the mirror pass —
5
+ // because they differ only in which frustum they test against, and splitting
6
+ // them into separate dispatches would read the same metadata each time. See
7
+ // docs/frame-graph.md.
8
+ //
9
+ // The MIRROR frustum is the camera frustum reflected about the floor plane: an
10
+ // object is visible in the mirror exactly when its reflection is visible to
11
+ // the camera, i.e. when the object itself lies in the reflected frustum. The
12
+ // camera args would be WRONG for it — a close-up of the floor shows a dancer's
13
+ // reflection while the dancer herself is out of frame. When no reflection is
14
+ // active the CPU writes the camera planes into the mirror slots, so the args
15
+ // stay sane for bundles that never execute.
16
+ //
17
+ // The compute writes ONLY `instanceCount` (word 1 of each 5-word record). The
18
+ // other four words — indexCount, firstIndex, baseVertex, firstInstance — are
19
+ // seeded by the CPU when the draw list changes, which is the same moment the
20
+ // metadata buffer is uploaded. Nothing about them varies per frame, so shipping
21
+ // them through the compute would be a per-frame write in exchange for nothing.
22
+ //
23
+ // Two bound kinds, because a skinned mesh has no usable model-space bounds:
24
+ // RIGID — every bone of the model shares one skin matrix (a stage, or a
25
+ // character still in its bind pose), so each material's load-time
26
+ // model-space AABB is valid and gets transformed by that matrix.
27
+ // This is what makes a 200-material stage worth culling at all.
28
+ // SKINNED — animation invalidates per-material bounds, so the whole model is
29
+ // one world-space sphere derived from its posed bone positions.
30
+ //
31
+ // Planes are NORMALIZED on the CPU. The AABB test would tolerate unnormalized
32
+ // planes (distance and extent scale together), the sphere test would not.
33
+
34
+ export const CULL_COMPUTE_WGSL = /* wgsl */ `
35
+ struct DrawMeta {
36
+ lo: vec3f, // model-space AABB min — read only for RIGID models
37
+ model: u32, // index into models[]
38
+ hi: vec3f, // model-space AABB max
39
+ flags: u32, // bit0 = casts shadow
40
+ };
41
+
42
+ struct ModelRec {
43
+ xform: mat4x4f, // model space -> world; meaningful only when RIGID
44
+ sphere: vec4f, // world-space centre.xyz + radius.w; meaningful when SKINNED
45
+ flags: u32, // bit0 = visible, bit1 = rigid
46
+ pad0: u32,
47
+ pad1: u32,
48
+ pad2: u32,
49
+ };
50
+
51
+ // Camera planes at 0..5, light planes at 6..11, mirror planes at 12..17.
52
+ // counts.x = draw count, counts.y = 0 to pass everything (setCullEnabled).
53
+ struct Frusta {
54
+ planes: array<vec4f, 18>,
55
+ counts: vec4u,
56
+ };
57
+
58
+ @group(0) @binding(0) var<storage, read> metas: array<DrawMeta>;
59
+ @group(0) @binding(1) var<storage, read> models: array<ModelRec>;
60
+ @group(0) @binding(2) var<uniform> frusta: Frusta;
61
+ @group(0) @binding(3) var<storage, read_write> cameraArgs: array<u32>;
62
+ @group(0) @binding(4) var<storage, read_write> shadowArgs: array<u32>;
63
+ // 1 = the user hid this material, or a material morph drove its alpha to zero.
64
+ // Here rather than in the encode loop because a render bundle bakes its draw
65
+ // list: a face VMD rewrites the morph-hidden set every frame, so a bundle that
66
+ // invalidated on it would re-record every frame and cost more than it saves.
67
+ @group(0) @binding(5) var<storage, read> hidden: array<u32>;
68
+ @group(0) @binding(6) var<storage, read_write> mirrorArgs: array<u32>;
69
+
70
+ const DRAW_CASTS_SHADOW: u32 = 1u;
71
+ const MODEL_VISIBLE: u32 = 1u;
72
+ const MODEL_RIGID: u32 = 2u;
73
+
74
+ // Signed distance of the AABB's most-positive corner: the centre distance plus
75
+ // the extent projected onto |n|. Negative means every corner is behind the
76
+ // plane, which is the only case that can reject.
77
+ fn aabbVisible(base: u32, c: vec3f, e: vec3f) -> bool {
78
+ for (var i = 0u; i < 6u; i = i + 1u) {
79
+ let p = frusta.planes[base + i];
80
+ if (dot(p.xyz, c) + p.w + dot(e, abs(p.xyz)) < 0.0) { return false; }
81
+ }
82
+ return true;
83
+ }
84
+
85
+ fn sphereVisible(base: u32, s: vec4f) -> bool {
86
+ for (var i = 0u; i < 6u; i = i + 1u) {
87
+ let p = frusta.planes[base + i];
88
+ if (dot(p.xyz, s.xyz) + p.w + s.w < 0.0) { return false; }
89
+ }
90
+ return true;
91
+ }
92
+
93
+ @compute @workgroup_size(64)
94
+ fn cs(@builtin(global_invocation_id) gid: vec3<u32>) {
95
+ let i = gid.x;
96
+ if (i >= frusta.counts.x) { return; }
97
+
98
+ // Named dm, not meta: "meta" is a WGSL reserved word (reserved for future use,
99
+ // so it parses nowhere). No backticks anywhere in this string either — it is a
100
+ // template literal, and one would end the shader mid-comment.
101
+ let dm = metas[i];
102
+ let m = models[dm.model];
103
+ let visible = (m.flags & MODEL_VISIBLE) != 0u;
104
+
105
+ var inCamera = false;
106
+ var inLight = false;
107
+ var inMirror = false;
108
+ if (visible) {
109
+ if ((m.flags & MODEL_RIGID) != 0u) {
110
+ let c = (dm.lo + dm.hi) * 0.5;
111
+ let e = (dm.hi - dm.lo) * 0.5;
112
+ let wc = (m.xform * vec4f(c, 1.0)).xyz;
113
+ // Extent of the transformed box: the abs of the linear part applied to the
114
+ // half-extent. Exact for the axis-aligned case, conservative for a rotation.
115
+ let a = mat3x3f(abs(m.xform[0].xyz), abs(m.xform[1].xyz), abs(m.xform[2].xyz));
116
+ let we = a * e;
117
+ inCamera = aabbVisible(0u, wc, we);
118
+ inLight = aabbVisible(6u, wc, we);
119
+ inMirror = aabbVisible(12u, wc, we);
120
+ } else {
121
+ inCamera = sphereVisible(0u, m.sphere);
122
+ inLight = sphereVisible(6u, m.sphere);
123
+ inMirror = sphereVisible(12u, m.sphere);
124
+ }
125
+ }
126
+
127
+ // Culling off (setCullEnabled) passes everything, so a scene can be A/B'd
128
+ // against its own unculled self without rebuilding anything. The pass still
129
+ // runs, so the diagnostics keep reporting. It bypasses the FRUSTUM only —
130
+ // a hidden material is not culled, it is switched off, and answering "is this
131
+ // gone because of culling?" requires the two to stay separable.
132
+ let off = frusta.counts.y == 0u;
133
+ let shown = hidden[i] == 0u;
134
+ let castsShadow = (dm.flags & DRAW_CASTS_SHADOW) != 0u;
135
+ cameraArgs[i * 5u + 1u] = select(0u, 1u, (inCamera || off) && shown);
136
+ shadowArgs[i * 5u + 1u] = select(0u, 1u, ((inLight && castsShadow) || off) && shown);
137
+ mirrorArgs[i * 5u + 1u] = select(0u, 1u, (inMirror || off) && shown);
138
+ }
139
+ `