reze-engine 0.43.0 → 0.50.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 (140) hide show
  1. package/README.md +53 -429
  2. package/dist/animation.d.ts +26 -0
  3. package/dist/animation.d.ts.map +1 -1
  4. package/dist/animation.js +42 -0
  5. package/dist/camera.d.ts +3 -0
  6. package/dist/camera.d.ts.map +1 -1
  7. package/dist/camera.js +33 -8
  8. package/dist/engine.d.ts +841 -53
  9. package/dist/engine.d.ts.map +1 -1
  10. package/dist/engine.js +3498 -451
  11. package/dist/graph/registry.d.ts +2 -2
  12. package/dist/graph/registry.d.ts.map +1 -1
  13. package/dist/graph/registry.js +1 -1
  14. package/dist/graph/slots.d.ts +0 -1
  15. package/dist/graph/slots.d.ts.map +1 -1
  16. package/dist/graph/slots.js +37 -9
  17. package/dist/hdr.d.ts +18 -0
  18. package/dist/hdr.d.ts.map +1 -0
  19. package/dist/hdr.js +162 -0
  20. package/dist/ibl.d.ts +19 -0
  21. package/dist/ibl.d.ts.map +1 -0
  22. package/dist/ibl.js +113 -0
  23. package/dist/ik-solver.d.ts +2 -1
  24. package/dist/ik-solver.d.ts.map +1 -1
  25. package/dist/index.d.ts +5 -1
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +10 -0
  28. package/dist/math.d.ts +20 -1
  29. package/dist/math.d.ts.map +1 -1
  30. package/dist/math.js +23 -16
  31. package/dist/midi-loader.d.ts +10 -0
  32. package/dist/midi-loader.d.ts.map +1 -0
  33. package/dist/midi-loader.js +247 -0
  34. package/dist/model.d.ts +19 -14
  35. package/dist/model.d.ts.map +1 -1
  36. package/dist/model.js +31 -2
  37. package/dist/param-track.d.ts +48 -0
  38. package/dist/param-track.d.ts.map +1 -0
  39. package/dist/param-track.js +80 -0
  40. package/dist/physics/types.d.ts.map +1 -1
  41. package/dist/physics/types.js +3 -0
  42. package/dist/reflection.d.ts +27 -0
  43. package/dist/reflection.d.ts.map +1 -0
  44. package/dist/reflection.js +93 -0
  45. package/dist/shaders/anchor-table.d.ts +56 -0
  46. package/dist/shaders/anchor-table.d.ts.map +1 -0
  47. package/dist/shaders/anchor-table.js +128 -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 +28 -22
  67. package/dist/shaders/passes/composite.d.ts.map +1 -1
  68. package/dist/shaders/passes/composite.js +165 -138
  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 +10 -2
  88. package/dist/shaders/passes/particles.d.ts.map +1 -1
  89. package/dist/shaders/passes/particles.js +37 -109
  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 +14 -2
  97. package/dist/shaders/passes/trails.d.ts.map +1 -1
  98. package/dist/shaders/passes/trails.js +55 -90
  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/animation.ts +41 -0
  109. package/src/camera.ts +31 -8
  110. package/src/engine.ts +4015 -556
  111. package/src/graph/registry.ts +2 -2
  112. package/src/graph/slots.ts +37 -9
  113. package/src/hdr.ts +156 -0
  114. package/src/ibl.ts +115 -0
  115. package/src/ik-solver.ts +1 -1
  116. package/src/index.ts +12 -0
  117. package/src/math.ts +23 -17
  118. package/src/midi-loader.ts +246 -0
  119. package/src/model.ts +34 -4
  120. package/src/param-track.ts +83 -0
  121. package/src/physics/types.ts +4 -1
  122. package/src/reflection.ts +94 -0
  123. package/src/shaders/anchor-table.ts +147 -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 +182 -139
  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 +54 -112
  137. package/src/shaders/passes/scene-contract.ts +266 -0
  138. package/src/shaders/passes/trails.ts +77 -93
  139. package/src/shadow-cascades.ts +97 -0
  140. package/src/vmd-loader.ts +2 -2
@@ -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
+ }
@@ -0,0 +1,202 @@
1
+ // Lyrics, as data — the fourth timing interface beside audio, the score and
2
+ // the lights, and shaped like them: one shared buffer, read through accessors,
3
+ // never touched directly.
4
+ //
5
+ // An effect gets the TIMING of the words — which line is live at the scene
6
+ // clock, how far through it is — and, in the field module, the words
7
+ // themselves: the host rasterises each line once (Canvas2D; CJK rules out
8
+ // glyph atlases) into a fixed atlas the effect samples through rzLyricText.
9
+ // The look — fill, outline, wipe, motion — is the effect author's, in WGSL.
10
+ //
11
+ // LAYOUT. A 4-float header (count, then padding that keeps the records
12
+ // vec4-aligned), then LYRIC_LINES_MAX records of 8 floats:
13
+ //
14
+ // [0] start s [1] end s [2] character count [3] reserved
15
+ // [4..7] atlas rect: u0, vTop, u1, vBottom
16
+ //
17
+ // FIXED SIZE, unlike the score: a song carries tens of lines, not thousands
18
+ // of notes, so capping at 256 costs 8 KB and buys the property that setLyrics
19
+ // is a buffer write — the buffer identity never changes, so nothing ever has
20
+ // to re-bind for lyrics arriving late. The atlas holds the same property by
21
+ // being allocated once at a fixed size (LYRIC_ATLAS_W × LYRIC_ATLAS_H).
22
+
23
+ export const LYRIC_LINES_MAX = 256
24
+ export const LYRIC_HEADER = 4
25
+ export const LYRIC_STRIDE = 8
26
+ export const LYRICS_FLOATS = LYRIC_HEADER + LYRIC_LINES_MAX * LYRIC_STRIDE
27
+
28
+ /** Bounds on the line atlas the host packs rasterised lines into. It is sized
29
+ * to the track that arrives rather than allocated at the maximum: a scene with
30
+ * no lyrics carries a 1×1 placeholder, and a song's atlas is as tall as its
31
+ * own lines need. 8192 is the smallest texture dimension WebGPU guarantees. */
32
+ export const LYRIC_ATLAS_MAX_W = 2048
33
+ export const LYRIC_ATLAS_MAX_H = 8192
34
+
35
+ export type LyricLine = {
36
+ /** Seconds on the scene clock. */
37
+ start: number
38
+ /** Seconds; a parser that has no better answer uses the next line's start. */
39
+ end: number
40
+ text: string
41
+ }
42
+
43
+ /** Where a rasterised line sits in the atlas: u0, vTop, u1, vBottom, in 0..1. */
44
+ export type LyricRect = [number, number, number, number]
45
+
46
+ /**
47
+ * Parse an .lrc file: `[mm:ss.xx]` tags (several per line share the text),
48
+ * an optional `[offset:±ms]` tag, blank-text tags kept as instrumental gaps'
49
+ * end markers. Lines come out sorted; each line's end is the next line's
50
+ * start, and the last line gets a ten-second hold. The offset follows the
51
+ * LRC convention: positive shows lines EARLIER — the knob to turn when the
52
+ * words feel late against this particular rip.
53
+ */
54
+ export function parseLRC(source: string): LyricLine[] {
55
+ let offset = 0
56
+ const stamped: { start: number; text: string }[] = []
57
+ for (const raw of source.split(/\r?\n/)) {
58
+ const off = /^\s*\[offset:\s*([+-]?\d+)\s*\]/i.exec(raw)
59
+ if (off) {
60
+ offset = parseInt(off[1], 10) / 1000
61
+ continue
62
+ }
63
+ const tags = [...raw.matchAll(/\[(\d+):(\d{1,2})(?:[.:](\d{1,3}))?\]/g)]
64
+ if (tags.length === 0) continue
65
+ const text = raw.slice(tags[tags.length - 1].index! + tags[tags.length - 1][0].length).trim()
66
+ for (const t of tags) {
67
+ const frac = t[3] ? parseInt(t[3], 10) / 10 ** t[3].length : 0
68
+ stamped.push({ start: Math.max(0, parseInt(t[1], 10) * 60 + parseInt(t[2], 10) + frac - offset), text })
69
+ }
70
+ }
71
+ stamped.sort((a, b) => a.start - b.start)
72
+ const lines: LyricLine[] = []
73
+ for (let i = 0; i < stamped.length; i++) {
74
+ // An empty-text stamp is an .lrc idiom for "the previous line ends here";
75
+ // it closes its predecessor and is not a line of its own.
76
+ if (stamped[i].text === "") continue
77
+ const next = stamped[i + 1]
78
+ lines.push({
79
+ start: stamped[i].start,
80
+ end: next ? next.start : stamped[i].start + 10,
81
+ text: stamped[i].text,
82
+ })
83
+ }
84
+ return lines
85
+ }
86
+
87
+ /** Fill the shared buffer's floats from parsed lines, clamped to the cap. */
88
+ export function packLyrics(lines: LyricLine[], rects?: LyricRect[]): Float32Array<ArrayBuffer> {
89
+ const out = new Float32Array(new ArrayBuffer(LYRICS_FLOATS * 4))
90
+ const n = Math.min(lines.length, LYRIC_LINES_MAX)
91
+ out[0] = n
92
+ for (let i = 0; i < n; i++) {
93
+ const b = LYRIC_HEADER + i * LYRIC_STRIDE
94
+ out[b] = lines[i].start
95
+ out[b + 1] = lines[i].end
96
+ out[b + 2] = lines[i].text.length
97
+ const r = rects?.[i]
98
+ if (r) {
99
+ out[b + 4] = r[0]
100
+ out[b + 5] = r[1]
101
+ out[b + 6] = r[2]
102
+ out[b + 7] = r[3]
103
+ }
104
+ }
105
+ return out
106
+ }
107
+
108
+ /** The rzLyric* timing accessors, with the buffer declared at the given binding. */
109
+ export function lyricsApi(group: number, binding: number): string {
110
+ return /* wgsl */ `
111
+ @group(${group}) @binding(${binding}) var<storage, read> _rzLyrics: array<f32>;
112
+
113
+ /** Lines in the lyric track; 0 when none is loaded, which every accessor
114
+ * below tolerates by answering zero rather than reading past the end. */
115
+ fn rzLyricCount() -> i32 { return i32(_rzLyrics[0]); }
116
+
117
+ fn rzLyricStart(i: i32) -> f32 {
118
+ if (i < 0 || i >= rzLyricCount()) { return 0.0; }
119
+ return _rzLyrics[${LYRIC_HEADER} + i * ${LYRIC_STRIDE}];
120
+ }
121
+
122
+ fn rzLyricEnd(i: i32) -> f32 {
123
+ if (i < 0 || i >= rzLyricCount()) { return 0.0; }
124
+ return _rzLyrics[${LYRIC_HEADER} + i * ${LYRIC_STRIDE} + 1];
125
+ }
126
+
127
+ /** Characters in line i — the number a per-character sweep divides by. */
128
+ fn rzLyricChars(i: i32) -> f32 {
129
+ if (i < 0 || i >= rzLyricCount()) { return 0.0; }
130
+ return _rzLyrics[${LYRIC_HEADER} + i * ${LYRIC_STRIDE} + 2];
131
+ }
132
+
133
+ /** Where line i sits in the lyric atlas: u0, vTop, u1, vBottom. Zero when the
134
+ * host never rasterised text — check with rzLyricHasText. */
135
+ fn rzLyricRect(i: i32) -> vec4f {
136
+ if (i < 0 || i >= rzLyricCount()) { return vec4f(0.0); }
137
+ let b = ${LYRIC_HEADER} + i * ${LYRIC_STRIDE};
138
+ return vec4f(_rzLyrics[b + 4], _rzLyrics[b + 5], _rzLyrics[b + 6], _rzLyrics[b + 7]);
139
+ }
140
+
141
+ fn rzLyricHasText(i: i32) -> bool {
142
+ let r = rzLyricRect(i);
143
+ return r.z > r.x;
144
+ }
145
+
146
+ /** The line live at time t, or -1 between lines and outside the track. */
147
+ fn rzLyricIndex(t: f32) -> i32 {
148
+ let n = rzLyricCount();
149
+ for (var i = 0; i < n; i = i + 1) {
150
+ if (t >= rzLyricStart(i) && t < rzLyricEnd(i)) { return i; }
151
+ }
152
+ return -1;
153
+ }
154
+
155
+ /** How far through line i the clock is, 0..1 — the karaoke sweep. */
156
+ fn rzLyricProgress(i: i32, t: f32) -> f32 {
157
+ let s = rzLyricStart(i);
158
+ let e = rzLyricEnd(i);
159
+ if (e <= s) { return 0.0; }
160
+ return clamp((t - s) / (e - s), 0.0, 1.0);
161
+ }
162
+ `
163
+ }
164
+
165
+ /**
166
+ * The text half — field module only, where the atlas is bound. uv is 0..1
167
+ * across LINE i's own box, y-up like everything else; the return is glyph
168
+ * coverage. textureSampleLevel, so it is legal after any branch.
169
+ */
170
+ export function lyricsTextApi(group: number, texBinding: number, samplerName: string): string {
171
+ return /* wgsl */ `
172
+ @group(${group}) @binding(${texBinding}) var _rzLyricTex: texture_2d<f32>;
173
+
174
+ fn rzLyricText(i: i32, uv: vec2f) -> f32 {
175
+ let r = rzLyricRect(i);
176
+ if (r.z <= r.x || uv.x < 0.0 || uv.x > 1.0 || uv.y < 0.0 || uv.y > 1.0) { return 0.0; }
177
+ let at = vec2f(mix(r.x, r.z, uv.x), mix(r.w, r.y, uv.y));
178
+ return textureSampleLevel(_rzLyricTex, ${samplerName}, at, 0.0).r;
179
+ }
180
+
181
+ /** Line i's width over its height as rasterised — size a box with it so the
182
+ * glyphs keep their proportions on any canvas. */
183
+ fn rzLyricAspect(i: i32) -> f32 {
184
+ let r = rzLyricRect(i);
185
+ let h = r.w - r.y;
186
+ if (h <= 0.0) { return 1.0; }
187
+ let dim = vec2f(textureDimensions(_rzLyricTex));
188
+ return ((r.z - r.x) * dim.x) / (h * dim.y);
189
+ }
190
+
191
+ /**
192
+ * Line i's box in ATLAS TEXELS. Divide by the size you draw it at to learn
193
+ * whether you are magnifying or minifying, which is what an edge-sharpening
194
+ * step needs to know — and the honest way to get it, since a derivative
195
+ * builtin is illegal after the branches an effect of this kind opens with.
196
+ */
197
+ fn rzLyricPixels(i: i32) -> vec2f {
198
+ let r = rzLyricRect(i);
199
+ return vec2f(r.z - r.x, r.w - r.y) * vec2f(textureDimensions(_rzLyricTex));
200
+ }
201
+ `
202
+ }