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
@@ -0,0 +1,82 @@
1
+ // The audio data interface, as WGSL — shared verbatim by every effect module.
2
+ //
3
+ // The buffer is PRECOMPUTED for the whole track and sampled by time, never fed
4
+ // live from an AnalyserNode. That is not an optimisation: an export steps the
5
+ // engine frame by frame instead of playing in real time, so a live analyser
6
+ // would return silence during a render and every audio-reactive effect would
7
+ // quietly vanish from the exported video — the same class of bug the scene
8
+ // clock exists to prevent. Layout: an 8-float header [frames, bands,
9
+ // secondsPerFrame, audioTime, playing, 3 spare] then per frame
10
+ // [level, onset, band0..bandN-1]. audioTime and playing are rewritten every frame by
11
+ // whoever owns playback — the editor's audio clock, the viewer's, or the export
12
+ // loop — so "now" is always the clock that is actually running.
13
+ //
14
+ // Every module binds the SAME buffer, so a spawn rule in a particle effect and
15
+ // a bar in a background read identical numbers for the same instant.
16
+
17
+ /** The rzAudio* accessors, with the buffer declared at the given binding. */
18
+ export function audioApi(group: number, binding: number): string {
19
+ return /* wgsl */ `
20
+ @group(${group}) @binding(${binding}) var<storage, read> _rzAudio: array<f32>;
21
+
22
+ /** Frames of analysis available; 0 when the scene has no audio. */
23
+ fn rzAudioFrames() -> i32 { return i32(_rzAudio[0]); }
24
+ /** 1 while the track is actually PLAYING, 0 paused or absent — so an effect can
25
+ * go calm instead of oscillating over a frozen spectrum. */
26
+ fn rzAudioPlaying() -> f32 { return select(0.0, _rzAudio[4], i32(_rzAudio[0]) > 0); }
27
+ /** Where the track is NOW, in seconds — the clock rzAudioLevelAt offsets hang
28
+ * from. An effect that detects an onset k seconds back can anchor a ring to
29
+ * the ABSOLUTE moment of the hit and age it continuously. */
30
+ fn rzAudioTime() -> f32 { return _rzAudio[3]; }
31
+ /** Bands per frame — log-spaced, bass first. */
32
+ fn rzAudioBandCount() -> i32 { return i32(_rzAudio[1]); }
33
+
34
+ fn _rzAudioFrameAt(offset: f32) -> i32 {
35
+ let frames = i32(_rzAudio[0]);
36
+ let t = _rzAudio[3] + offset;
37
+ return clamp(i32(t / max(_rzAudio[2], 1e-5)), 0, frames - 1);
38
+ }
39
+
40
+ /**
41
+ * Loudness at now + offset seconds, 0..1.
42
+ *
43
+ * The offset is what makes a WAVEFORM drawable: a column at x samples the
44
+ * envelope at (x - 0.5) * window seconds, and the playhead is the centre of the
45
+ * screen by construction. Offsets past either end clamp to the track's edges.
46
+ */
47
+ fn rzAudioLevelAt(offset: f32) -> f32 {
48
+ let frames = i32(_rzAudio[0]);
49
+ if (frames <= 0) { return 0.0; }
50
+ let bands = i32(_rzAudio[1]);
51
+ return _rzAudio[8 + _rzAudioFrameAt(offset) * (bands + 2)];
52
+ }
53
+
54
+ /**
55
+ * The KICK track: how hard the bass is rising at now + offset, 0..1 —
56
+ * precomputed, so a beat-triggered effect is one comparison instead of a
57
+ * per-pixel history scan. Scan a short window of NEGATIVE offsets to find
58
+ * recent hits and age things from them (quantise the hit to the analysis grid
59
+ * so ages stay continuous).
60
+ */
61
+ fn rzAudioOnsetAt(offset: f32) -> f32 {
62
+ let frames = i32(_rzAudio[0]);
63
+ if (frames <= 0) { return 0.0; }
64
+ let bands = i32(_rzAudio[1]);
65
+ return _rzAudio[8 + _rzAudioFrameAt(offset) * (bands + 2) + 1];
66
+ }
67
+ fn rzAudioOnset() -> f32 { return rzAudioOnsetAt(0.0); }
68
+ /** Loudness now, 0..1. */
69
+ fn rzAudioLevel() -> f32 { return rzAudioLevelAt(0.0); }
70
+
71
+ /** Band i at now + offset seconds, 0..1. Band 0 is the deepest bass. */
72
+ fn rzAudioBandAt(i: i32, offset: f32) -> f32 {
73
+ let frames = i32(_rzAudio[0]);
74
+ if (frames <= 0) { return 0.0; }
75
+ let bands = i32(_rzAudio[1]);
76
+ if (i < 0 || i >= bands) { return 0.0; }
77
+ return _rzAudio[8 + _rzAudioFrameAt(offset) * (bands + 2) + 2 + i];
78
+ }
79
+ /** Band i now — the visualiser call. */
80
+ fn rzAudioBand(i: i32) -> f32 { return rzAudioBandAt(i, 0.0); }
81
+ `
82
+ }
@@ -0,0 +1,123 @@
1
+ // The cast, as data — the sibling of the audio and score interfaces, and shaped
2
+ // like them: one shared buffer, read through accessors, never touched directly.
3
+ //
4
+ // WHY THIS FILE EXISTS. There were two of these. The field, grid and lightEmit
5
+ // modules read the cast through one implementation; the particle and trail
6
+ // modules read the SAME BUFFER through another, written separately, and the
7
+ // particle one had no rzAnchor at all — so a particle effect could ask where a
8
+ // trail had been but not where a wrist is. Neither was wrong; they had simply
9
+ // never been the same code, and the split was invisible until an effect used a
10
+ // mount from each family and its own file stopped compiling in one of them.
11
+ //
12
+ // The two differed only in how the layout reached them: one baked the engine's
13
+ // constants, the other took them as a CastLayout. Every caller of that layout
14
+ // passed the same five constants, so the parameterisation described a freedom
15
+ // that did not exist. Baking them makes this a constant string, which is what
16
+ // lets both families share it without either one deciding the shape.
17
+ //
18
+ // WHAT A HOST MUST SUPPLY. Three names, and deliberately only three:
19
+ //
20
+ // _rzCast the buffer, at whatever binding the module puts it on
21
+ // _rzSlot(i) the effect's local slot → the scene's, from its alias
22
+ // rzSubjectCount() how many subjects are live
23
+ //
24
+ // The last is a host's because the two families genuinely disagree on it: the
25
+ // field module reads a count the engine wrote into the view uniform, and the
26
+ // particle module scans the buffer, because it has no view uniform to read.
27
+ // They agree in value. Unifying them would be a behaviour change to every
28
+ // shipped effect for no gain, so the seam stays and is named here instead.
29
+
30
+ import { EFFECT_ANCHORS, EFFECT_SUBJECTS, EFFECT_TRAIL_BASE, EFFECT_TRAIL_SAMPLES } from "./cast-layout"
31
+
32
+ export const CAST_API = /* wgsl */ `
33
+ const RZ_SUBJECTS: i32 = ${EFFECT_SUBJECTS};
34
+ const RZ_SAMPLES: i32 = ${EFFECT_TRAIL_SAMPLES};
35
+ /** The anchor ADDRESS SPACE — how many an effect may declare, not how many it
36
+ * did. RZ_TRAIL_SLOTS is the per-effect number and is not this one; the two
37
+ * being one number was the old trail bug. */
38
+ const RZ_MAX_ANCHORS: i32 = ${EFFECT_ANCHORS};
39
+ const RZ_TRAIL_SAMPLES: i32 = ${EFFECT_TRAIL_SAMPLES};
40
+
41
+ struct RzSubject {
42
+ /** On the FLOOR, under the body — where a ring or a magic circle belongs. */
43
+ root: vec3f,
44
+ /** At the hips, the middle of the body — where an aura belongs. */
45
+ center: vec3f,
46
+ /** Bounding sphere: xyz centre, w radius. Deliberately generous — cull with it. */
47
+ bounds: vec4f,
48
+ /** False past the end of the cast, and every field is then zero. */
49
+ valid: bool,
50
+ }
51
+
52
+ struct RzAnchor {
53
+ pos: vec3f,
54
+ /** World units per second, from the previous frame. Direction for a trail,
55
+ * magnitude for anything that should react to how hard someone is moving. */
56
+ vel: vec3f,
57
+ /** The bone's forward axis — which way a foot points, where a head looks. */
58
+ fwd: vec3f,
59
+ /** False when this rig has no such bone. Check it: the alternative is drawing
60
+ * a hand effect at the world origin on every model that spells it differently. */
61
+ valid: bool,
62
+ }
63
+
64
+ /** Which model this is, stable across a scene — for per-subject variation. */
65
+ fn rzSubjectId(i: i32) -> u32 {
66
+ if (i < 0 || i >= rzSubjectCount()) { return 0u; }
67
+ return u32(_rzCast[i * 3 + 1].w);
68
+ }
69
+
70
+ fn rzSubject(i: i32) -> RzSubject {
71
+ var s: RzSubject;
72
+ s.valid = i >= 0 && i < rzSubjectCount();
73
+ if (!s.valid) { return s; }
74
+ let b = i * 3;
75
+ s.root = _rzCast[b].xyz;
76
+ s.center = _rzCast[b + 1].xyz;
77
+ s.bounds = _rzCast[b + 2];
78
+ return s;
79
+ }
80
+
81
+ /**
82
+ * Where a named bone is, this frame.
83
+ *
84
+ * The slot is the author's own: the Nth @anchor in their file, in the order
85
+ * they wrote them. _rzSlot turns that into the scene's address, which is what
86
+ * keeps two effects that both anchor to a wrist from reading each other's.
87
+ */
88
+ fn rzAnchor(subject: i32, slot: i32) -> RzAnchor {
89
+ var a: RzAnchor;
90
+ a.valid = false;
91
+ let g = _rzSlot(slot);
92
+ if (subject < 0 || subject >= rzSubjectCount() || g < 0 || g >= RZ_MAX_ANCHORS) { return a; }
93
+ let b = ${EFFECT_SUBJECTS * 3} + (g * ${EFFECT_SUBJECTS} + subject) * 3;
94
+ a.valid = _rzCast[b].w > 0.5;
95
+ a.pos = _rzCast[b].xyz;
96
+ a.vel = _rzCast[b + 1].xyz;
97
+ a.fwd = _rzCast[b + 2].xyz;
98
+ return a;
99
+ }
100
+
101
+ /**
102
+ * How many samples of a path are recorded — 0 for an anchor that asked for no
103
+ * trail, and for one that has not moved yet.
104
+ *
105
+ * Bounded by the anchor cap, NOT by how many anchors asked for a trail. Those
106
+ * are different index spaces: storage is addressed by anchor slot, so an
107
+ * untrailed @anchor followed by a trailed one put the trail at index 1 with a
108
+ * bound of 1 and rzTrail returned zero — a ribbon that silently did not draw.
109
+ */
110
+ fn rzTrailCount(subject: i32, slot: i32) -> i32 {
111
+ let g = _rzSlot(slot);
112
+ if (subject < 0 || subject >= rzSubjectCount() || g < 0 || g >= RZ_MAX_ANCHORS) { return 0; }
113
+ return i32(_rzCast[${EFFECT_SUBJECTS * 3} + (g * ${EFFECT_SUBJECTS} + subject) * 3 + 2].w);
114
+ }
115
+
116
+ /** Sample i of a path: xyz where it was, w how many seconds ago. i = 0 is now. */
117
+ fn rzTrail(subject: i32, slot: i32, i: i32) -> vec4f {
118
+ let n = rzTrailCount(subject, slot);
119
+ if (i < 0 || i >= n) { return vec4f(0.0); }
120
+ let base = ${EFFECT_TRAIL_BASE} + (_rzSlot(slot) * ${EFFECT_SUBJECTS} + subject) * RZ_TRAIL_SAMPLES;
121
+ return _rzCast[base + i];
122
+ }
123
+ `
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The caps the cast buffer is built to, shared by every shader that reads it and
3
+ * by the engine that fills it. Interpolated into the WGSL rather than written
4
+ * twice: the layout arithmetic on both sides has to agree exactly, and two
5
+ * literals that must match are two literals that eventually will not.
6
+ *
7
+ * All three are MINIMUMS. Raising one breaks nothing, because effects read
8
+ * through accessors and loop to the count functions; lowering one does.
9
+ *
10
+ * THEIR OWN FILE, and it imports nothing: cast-api.ts needs them to write the
11
+ * accessors, composite.ts needs them for the rest of its module, and cast-api
12
+ * is spliced into composite. Left where they were, that is an import cycle,
13
+ * and the first thing to touch the cycle throws ReferenceError at module load —
14
+ * the whole engine failing to start on an import order nobody chose.
15
+ */
16
+ export const EFFECT_SUBJECTS = 4
17
+ export const EFFECT_ANCHORS = 8
18
+ export const EFFECT_TRAIL_SAMPLES = 128
19
+ /** vec4 slot where the trails begin — after the subjects and the anchors. */
20
+ export const EFFECT_TRAIL_BASE = EFFECT_SUBJECTS * 3 + EFFECT_ANCHORS * EFFECT_SUBJECTS * 3
@@ -0,0 +1,280 @@
1
+ // Positional lights, as data — the sibling of the cast, audio and score
2
+ // interfaces, and shaped like them: one shared buffer, read through accessors,
3
+ // never touched directly.
4
+ //
5
+ // WHAT THIS IS NOT. The sun is still the ONE key light and it still owns the
6
+ // toon ramp. These are an ADDITIVE layer on top of whatever the material's
7
+ // graph decided, and they deliberately do not re-ramp: two ramped terminators
8
+ // crossing a cheek read as plastic, which is the failure every stylised
9
+ // renderer that bolted a second key light onto a toon shader has shipped. A
10
+ // light here brightens; it does not restate the shading.
11
+ //
12
+ // So there are no per-light shadows, no area lights and no clustering. At this
13
+ // count a flat loop in the fragment shader is cheaper than anything that would
14
+ // avoid it, and the cap is what keeps that true.
15
+ //
16
+ // LAYOUT. A 4-float header (count, then padding that keeps the records
17
+ // vec4-aligned), then MAX_LIGHTS records of 8 floats:
18
+ //
19
+ // [0..2] position, world space [3] radius
20
+ // [4..6] colour PREMULTIPLIED by intensity [7] type
21
+ //
22
+ // Colour carries intensity because nothing reads them apart: every use is the
23
+ // product, and storing two numbers that are only ever multiplied is two numbers
24
+ // that can disagree. `type` is reserved — every light is a point light today
25
+ // and the loop does not branch on it, so it is honest padding rather than a
26
+ // switch with one case.
27
+
28
+ import { audioApi } from "./audio-api"
29
+ import { midiApi } from "./midi-api"
30
+ import { lyricsApi } from "./lyrics-api"
31
+ import { clockApi, trailSlotsApi, viewportApi } from "./passes/hosted-api"
32
+
33
+ /** Floats before the first record. One is the count; the rest keep the records
34
+ * vec4-aligned, which is what lets a future pass read them as vec4s. */
35
+ export const LIGHT_HEADER = 4
36
+ /** Floats per light — see the layout above. */
37
+ export const LIGHT_STRIDE = 8
38
+ /**
39
+ * The cap, and it is a real one: the loop below runs per fragment, so this is
40
+ * the number that decides whether lights are free or a cost. Sixteen is the
41
+ * bounded middle tier the design settled on — enough for a stage rig, far below
42
+ * the point where clustering would start to pay for itself.
43
+ */
44
+ export const MAX_LIGHTS = 16
45
+ /** Floats in the whole buffer. */
46
+ export const LIGHTS_FLOATS = LIGHT_HEADER + MAX_LIGHTS * LIGHT_STRIDE
47
+
48
+ /**
49
+ * `// @lights 3` — how many lights this effect emits.
50
+ *
51
+ * Declared, like every other mount: what the file says is what gets allocated,
52
+ * so an effect that emits none costs no slots and nobody pays for a cap they
53
+ * did not ask for. Clamped rather than rejected, the same choice `@particles`
54
+ * makes — an author asking for a hundred gets the most the engine will give and
55
+ * a scene that still runs.
56
+ */
57
+ export function parseLightCount(wgsl: string, max: number): number {
58
+ const m = /^\s*\/\/\s*@lights\s+(\d+)\s*$/m.exec(wgsl)
59
+ if (!m) return 0
60
+ return Math.max(1, Math.min(max, parseInt(m[1], 10)))
61
+ }
62
+
63
+ /**
64
+ * The RzLight struct, declared in EVERY module a user's source is spliced into.
65
+ *
66
+ * One effect file goes into every module it has a mount in, so a foreground
67
+ * effect that also emits lights compiles its lightEmit inside the FIELD shader
68
+ * too — where nothing calls it, but it still has to resolve. Leaving the struct
69
+ * out of those modules is a compile error on a function the author was right to
70
+ * write, which is the same trap the grid's step-only half documents.
71
+ */
72
+ export const RZ_LIGHT_STRUCT_WGSL = /* wgsl */ `
73
+ /** What an effect returns for one of its lights. */
74
+ struct RzLight {
75
+ pos: vec3f,
76
+ color: vec3f,
77
+ intensity: f32,
78
+ radius: f32,
79
+ }
80
+ `
81
+
82
+ /**
83
+ * The world's light at a surface facing n — the flat colour, or the installed
84
+ * HDRI's irradiance (sh[0].w = 1), evaluated from folded SH coefficients (see
85
+ * ibl.ts for the folding; the shader is a plain polynomial in the normal).
86
+ *
87
+ * One string included by every module that declares LightUniforms with the sh
88
+ * block — the hosted-api lesson: a helper defined in some modules and not
89
+ * others is a compile error waiting for the first file that crosses them.
90
+ */
91
+ export const WORLD_AMBIENT_WGSL = /* wgsl */ `
92
+ fn rzWorldAmbient(n: vec3f) -> vec3f {
93
+ if (light.sh[0].w < 0.5) { return light.ambientColor.xyz; }
94
+ let x = n.x;
95
+ let y = n.y;
96
+ let z = n.z;
97
+ let c = light.sh[0].xyz
98
+ + light.sh[1].xyz * y + light.sh[2].xyz * z + light.sh[3].xyz * x
99
+ + light.sh[4].xyz * (x * y) + light.sh[5].xyz * (y * z)
100
+ + light.sh[6].xyz * (3.0 * z * z - 1.0) + light.sh[7].xyz * (x * z)
101
+ + light.sh[8].xyz * (x * x - y * y);
102
+ return max(c, vec3f(0.0));
103
+ }
104
+ `
105
+
106
+ /** Does this source define the emit mount? */
107
+ export function hasLightEmit(wgsl: string): boolean {
108
+ return /\bfn\s+lightEmit\s*\(/.test(wgsl)
109
+ }
110
+
111
+ /**
112
+ * The compute module that runs an effect's lightEmit once per light per frame.
113
+ *
114
+ * fn lightEmit(i: u32, time: f32) -> RzLight
115
+ *
116
+ * A COMPUTE stage rather than a CPU callback, and that is the whole point:
117
+ * Fireworks knows where its bursts are as a closed form in WGSL, and mirroring
118
+ * that on the CPU to place a light would be two derivations of one trajectory
119
+ * that drift apart. Emitting in the shader means the light is wherever the
120
+ * effect says it is, on the scene clock — which is also what makes it survive
121
+ * an offline export frame-stepped at a different rate.
122
+ *
123
+ * The author writes local index 0..n-1 and never learns the global one, the
124
+ * same aliasing the anchor table uses — so installing another effect ahead of
125
+ * this one moves its lights without touching its source.
126
+ *
127
+ * The base arrives in the UNIFORM rather than baked into the text. Baking it
128
+ * would mean recompiling every emitting effect the moment a scene gained or
129
+ * lost a document light, because that is what shifts the slots underneath
130
+ * them — a shader rebuild triggered by moving a lamp.
131
+ *
132
+ * The scene API arrives as a STRING rather than being imported, so this module
133
+ * depends on nothing that depends on it. That API is what lets a lamp aim at
134
+ * someone: Stage Lights points its beams at rzSubject().root, and a light that
135
+ * did not know where she was could only sit where the fixture hangs. It also
136
+ * brings RzLight, which is why this builder does not declare it again.
137
+ */
138
+ export function buildLightEmitShader(
139
+ wgsl: string,
140
+ sceneApi: string,
141
+ cast: { trailCount: number },
142
+ ): string {
143
+ return /* wgsl */ `
144
+ // read_write HERE and read-only in the material shaders. Different passes, so
145
+ // the two never coexist: this compute runs before the scene pass that reads it.
146
+ @group(0) @binding(0) var<storage, read_write> _rzLightsOut: array<f32>;
147
+ // (time, base slot, count, _) — see buildLightEmitShader on why the base is
148
+ // here and not in the text.
149
+ @group(0) @binding(1) var<uniform> _rzLightU: vec4f;
150
+ // The camera block and the cast — the two buffers the scene API reads. Same
151
+ // contents the field and grid modules bind, so an effect's lightEmit sees the
152
+ // scene exactly as its drawing half does.
153
+ @group(0) @binding(2) var<uniform> viewU: array<vec4<f32>, 15>;
154
+ @group(0) @binding(3) var<storage, read> _rzCast: array<vec4f>;
155
+ ${sceneApi}
156
+ // Audio and score at 4 and 5, the same bindings the particle and trail modules
157
+ // put them on. A lamp that pulses on the beat or lights on a note is the whole
158
+ // point of a light an effect owns rather than one the document places, so this
159
+ // is not compile-safety padding — it is the mount's reason to exist.
160
+ ${audioApi(0, 4)}
161
+ ${midiApi(0, 5)}
162
+ ${lyricsApi(0, 6)}
163
+ // The rest of the hosted API. Every one of these is here because the AUTHOR'S
164
+ // WHOLE FILE lands below, not because lightEmit needs it: a trail effect that
165
+ // grows a lamp at its tip compiles its ribbon code in this module too. The math
166
+ // helpers and the Particle struct are NOT repeated — they arrive with the scene
167
+ // API above, and a second copy is a redefinition error in engine code.
168
+ ${clockApi("_rzLightU.x", "0.0")}
169
+ // Canvas height — the same number the drawing modules read out of their camera
170
+ // struct, both written from canvas.height, so this name means ONE value in
171
+ // every module. Verified against the writers, not assumed: cameraMatrixData[35]
172
+ // and viewU[6].w have the same source.
173
+ ${viewportApi("viewU[6].w")}
174
+ ${trailSlotsApi(cast.trailCount)}
175
+ ${wgsl}
176
+
177
+ @compute @workgroup_size(64)
178
+ fn lightEmitMain(@builtin(global_invocation_id) gid: vec3u) {
179
+ let i = gid.x;
180
+ // The dispatch is sized to the count, but a workgroup is 64 wide and the
181
+ // count rarely is — the tail must not write into the next effect's slots.
182
+ if (i >= u32(_rzLightU.z)) { return; }
183
+ // Time is a PARAMETER, not an rzTime() call: this same source compiles
184
+ // inside the field, particle, trail and grid modules, and those already
185
+ // define rzTime differently or not at all. A parameter needs nothing from
186
+ // the module it lands in, which is why the field mounts take theirs too.
187
+ let l = lightEmit(i, _rzLightU.x);
188
+ // SANITIZED at the one write site, because lightEmit is HOSTED USER CODE and
189
+ // this buffer feeds every fragment of every material: one NaN position would
190
+ // poison the whole frame, and WGSL leaves max(NaN, 0) indeterminate, so it
191
+ // would not even fail the same way on every GPU. A light that fails the
192
+ // check writes zeros — radius 0 is off. Colour is clamped at zero on top:
193
+ // this layer is ADDITIVE, and a negative channel would darken what it lands
194
+ // on and can push HDR negative into bloom.
195
+ let finite = l.pos.x == l.pos.x && l.pos.y == l.pos.y && l.pos.z == l.pos.z &&
196
+ l.radius == l.radius && l.intensity == l.intensity &&
197
+ l.color.x == l.color.x && l.color.y == l.color.y && l.color.z == l.color.z;
198
+ let c = select(vec3f(0.0), max(l.color * l.intensity, vec3f(0.0)), finite);
199
+ let b = ${LIGHT_HEADER}u + (u32(_rzLightU.y) + i) * ${LIGHT_STRIDE}u;
200
+ _rzLightsOut[b] = select(0.0, l.pos.x, finite);
201
+ _rzLightsOut[b + 1u] = select(0.0, l.pos.y, finite);
202
+ _rzLightsOut[b + 2u] = select(0.0, l.pos.z, finite);
203
+ _rzLightsOut[b + 3u] = select(0.0, max(l.radius, 0.0), finite);
204
+ // Colour carries intensity, exactly as the CPU writer stores it — one product,
205
+ // one place, so the two producers cannot disagree about what a slot means.
206
+ _rzLightsOut[b + 4u] = c.x;
207
+ _rzLightsOut[b + 5u] = c.y;
208
+ _rzLightsOut[b + 6u] = c.z;
209
+ }
210
+ `
211
+ }
212
+
213
+ /** The rz*Light accessors, with the buffer declared at the given binding. */
214
+ export function lightsApi(group: number, binding: number): string {
215
+ return /* wgsl */ `
216
+ @group(${group}) @binding(${binding}) var<storage, read> _rzLights: array<f32>;
217
+
218
+ const RZ_MAX_LIGHTS: u32 = ${MAX_LIGHTS}u;
219
+
220
+ /** How many positional lights the scene has. Zero is the ordinary case. */
221
+ fn rzLightCount() -> u32 { return min(u32(_rzLights[0]), RZ_MAX_LIGHTS); }
222
+
223
+ /** Light i's world position. */
224
+ fn rzLightPos(i: u32) -> vec3f {
225
+ let b = ${LIGHT_HEADER}u + i * ${LIGHT_STRIDE}u;
226
+ return vec3f(_rzLights[b], _rzLights[b + 1u], _rzLights[b + 2u]);
227
+ }
228
+
229
+ /** How far light i reaches. Its falloff is zero AT this distance, not merely
230
+ * small, so the light has a bound a cull can be derived from later. */
231
+ fn rzLightRadius(i: u32) -> f32 { return _rzLights[${LIGHT_HEADER}u + i * ${LIGHT_STRIDE}u + 3u]; }
232
+
233
+ /** Light i's colour, already multiplied by its intensity. */
234
+ fn rzLightColor(i: u32) -> vec3f {
235
+ let b = ${LIGHT_HEADER}u + i * ${LIGHT_STRIDE}u + 4u;
236
+ return vec3f(_rzLights[b], _rzLights[b + 1u], _rzLights[b + 2u]);
237
+ }
238
+
239
+ /**
240
+ * Every positional light's contribution at a surface point, as light — not as a
241
+ * finished colour. Multiply by whatever the surface's albedo is.
242
+ *
243
+ * WITH NO LIGHTS THIS RETURNS EXACTLY ZERO and the loop never runs, so a scene
244
+ * that declares none is arithmetically identical to one compiled before lights
245
+ * existed. That is the property the whole feature is gated on: adding this to
246
+ * every material must cost nothing until someone asks for a light.
247
+ *
248
+ * FALLOFF IS RELATIVE TO THE RADIUS, and deliberately not physical.
249
+ *
250
+ * The first version windowed a real inverse-square, and it was unusable: 1/d²
251
+ * is measured in world units, an MMD character is about 18 of them tall, so a
252
+ * lamp two metres off her shoulder divided by 37 and an intensity of 4 landed
253
+ * as 0.06 — invisible. Radius and intensity were fighting, and intensity had no
254
+ * scale a person could learn.
255
+ *
256
+ * So: intensity is the brightness AT the light, radius is where it reaches
257
+ * zero, and the curve between them is the same shape whatever the scene's
258
+ * scale. Both dials now mean what they say, which for a composer beats being
259
+ * right about photons. (1 - t²)² — smooth at both ends, exactly 0 at the
260
+ * radius, so the bound a cull could be derived from is still real.
261
+ */
262
+ fn rzLightsDiffuse(p: vec3f, n: vec3f) -> vec3f {
263
+ var acc = vec3f(0.0);
264
+ let count = rzLightCount();
265
+ for (var i = 0u; i < count; i = i + 1u) {
266
+ let d = rzLightPos(i) - p;
267
+ let dist = length(d);
268
+ // Facing the light, and nothing behind it. No wrap or half-lambert: this
269
+ // layer adds light, and a wrapped term would lift the shadow side, which is
270
+ // the ramp's business and not this one's.
271
+ let ndl = max(dot(n, d / max(dist, 1e-4)), 0.0);
272
+ if (ndl <= 0.0) { continue; }
273
+ let t = clamp(dist / max(rzLightRadius(i), 1e-4), 0.0, 1.0);
274
+ let falloff = 1.0 - t * t;
275
+ acc = acc + rzLightColor(i) * (ndl * falloff * falloff);
276
+ }
277
+ return acc;
278
+ }
279
+ `
280
+ }