reze-engine 0.65.0 → 0.66.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 (151) hide show
  1. package/dist/engine.d.ts +156 -42
  2. package/dist/engine.d.ts.map +1 -1
  3. package/dist/engine.js +876 -186
  4. package/dist/graph/compile.d.ts.map +1 -1
  5. package/dist/graph/compile.js +5 -0
  6. package/dist/graph/presets/body.d.ts.map +1 -1
  7. package/dist/graph/presets/body.js +26 -24
  8. package/dist/graph/presets/cloth_rough.js +8 -8
  9. package/dist/graph/presets/cloth_smooth.js +8 -8
  10. package/dist/graph/presets/default.js +4 -4
  11. package/dist/graph/presets/eye.js +10 -10
  12. package/dist/graph/presets/face.d.ts.map +1 -1
  13. package/dist/graph/presets/face.js +28 -26
  14. package/dist/graph/presets/hair.js +6 -6
  15. package/dist/graph/presets/metal.js +8 -8
  16. package/dist/graph/presets/stockings.d.ts.map +1 -1
  17. package/dist/graph/presets/stockings.js +4 -11
  18. package/dist/graph/presets/unlit.d.ts.map +1 -1
  19. package/dist/graph/presets/unlit.js +9 -13
  20. package/dist/graph/registry.d.ts.map +1 -1
  21. package/dist/graph/registry.js +128 -157
  22. package/dist/graph/render-class.d.ts +5 -2
  23. package/dist/graph/render-class.d.ts.map +1 -1
  24. package/dist/graph/slots.d.ts.map +1 -1
  25. package/dist/graph/slots.js +5 -1
  26. package/dist/model.d.ts +14 -1
  27. package/dist/model.d.ts.map +1 -1
  28. package/dist/model.js +27 -1
  29. package/dist/pmx-loader.d.ts +4 -0
  30. package/dist/pmx-loader.d.ts.map +1 -1
  31. package/dist/pmx-loader.js +11 -2
  32. package/dist/shaders/anchor-table.d.ts +11 -0
  33. package/dist/shaders/anchor-table.d.ts.map +1 -1
  34. package/dist/shaders/anchor-table.js +9 -3
  35. package/dist/shaders/directives.d.ts +42 -5
  36. package/dist/shaders/directives.d.ts.map +1 -1
  37. package/dist/shaders/directives.js +51 -4
  38. package/dist/shaders/lights.d.ts +11 -7
  39. package/dist/shaders/lights.d.ts.map +1 -1
  40. package/dist/shaders/lights.js +315 -271
  41. package/dist/shaders/materials/common.d.ts.map +1 -1
  42. package/dist/shaders/materials/common.js +26 -3
  43. package/dist/shaders/materials/nodes.d.ts +1 -1
  44. package/dist/shaders/materials/nodes.d.ts.map +1 -1
  45. package/dist/shaders/materials/nodes.js +191 -271
  46. package/dist/shaders/passes/composite.d.ts +1 -1
  47. package/dist/shaders/passes/composite.d.ts.map +1 -1
  48. package/dist/shaders/passes/composite.js +3 -5
  49. package/dist/shaders/passes/ground.js +1 -1
  50. package/dist/shaders/passes/particles.d.ts.map +1 -1
  51. package/dist/shaders/passes/particles.js +9 -3
  52. package/dist/shaders/passes/probe.d.ts +45 -0
  53. package/dist/shaders/passes/probe.d.ts.map +1 -0
  54. package/dist/shaders/passes/probe.js +230 -0
  55. package/dist/shaders/passes/scene-contract.d.ts +13 -0
  56. package/dist/shaders/passes/scene-contract.d.ts.map +1 -1
  57. package/dist/shaders/passes/scene-contract.js +4 -0
  58. package/dist/shaders/scene-light-api.d.ts.map +1 -1
  59. package/dist/shaders/scene-light-api.js +1 -0
  60. package/dist/unity/globals.d.ts +7 -0
  61. package/dist/unity/globals.d.ts.map +1 -1
  62. package/dist/unity/globals.js +4 -3
  63. package/dist/unity/host.d.ts +3 -0
  64. package/dist/unity/host.d.ts.map +1 -1
  65. package/dist/unity/host.js +41 -7
  66. package/dist/unity/looks.d.ts +42 -0
  67. package/dist/unity/looks.d.ts.map +1 -1
  68. package/dist/unity/looks.js +131 -4
  69. package/dist/unity/stage.d.ts +17 -0
  70. package/dist/unity/stage.d.ts.map +1 -1
  71. package/dist/unity/stage.js +22 -5
  72. package/package.json +1 -1
  73. package/src/engine.ts +949 -215
  74. package/src/graph/compile.ts +4 -0
  75. package/src/graph/presets/body.ts +26 -24
  76. package/src/graph/presets/cloth_rough.ts +8 -8
  77. package/src/graph/presets/cloth_smooth.ts +8 -8
  78. package/src/graph/presets/default.ts +4 -4
  79. package/src/graph/presets/eye.ts +10 -10
  80. package/src/graph/presets/face.ts +28 -26
  81. package/src/graph/presets/hair.ts +6 -6
  82. package/src/graph/presets/metal.ts +8 -8
  83. package/src/graph/presets/stockings.ts +4 -11
  84. package/src/graph/presets/unlit.ts +9 -13
  85. package/src/graph/registry.ts +133 -158
  86. package/src/graph/render-class.ts +5 -2
  87. package/src/graph/slots.ts +5 -1
  88. package/src/model.ts +33 -1
  89. package/src/pmx-loader.ts +11 -2
  90. package/src/shaders/anchor-table.ts +15 -3
  91. package/src/shaders/directives.ts +88 -6
  92. package/src/shaders/lights.ts +464 -415
  93. package/src/shaders/materials/common.ts +26 -3
  94. package/src/shaders/materials/nodes.ts +191 -271
  95. package/src/shaders/passes/composite.ts +3 -5
  96. package/src/shaders/passes/ground.ts +1 -1
  97. package/src/shaders/passes/particles.ts +9 -3
  98. package/src/shaders/passes/probe.ts +240 -0
  99. package/src/shaders/passes/scene-contract.ts +17 -0
  100. package/src/shaders/scene-light-api.ts +1 -0
  101. package/src/unity/globals.ts +7 -3
  102. package/src/unity/host.ts +36 -6
  103. package/src/unity/looks.ts +161 -7
  104. package/src/unity/stage.ts +28 -5
  105. package/dist/physics-debug.d.ts +0 -30
  106. package/dist/physics-debug.d.ts.map +0 -1
  107. package/dist/physics-debug.js +0 -526
  108. package/dist/shaders/agx-lut.d.ts +0 -11
  109. package/dist/shaders/agx-lut.d.ts.map +0 -1
  110. package/dist/shaders/agx-lut.js +0 -29
  111. package/dist/shaders/dfg_lut.d.ts +0 -3
  112. package/dist/shaders/dfg_lut.d.ts.map +0 -1
  113. package/dist/shaders/dfg_lut.js +0 -129
  114. package/dist/shaders/ltc_mag_lut.d.ts +0 -3
  115. package/dist/shaders/ltc_mag_lut.d.ts.map +0 -1
  116. package/dist/shaders/ltc_mag_lut.js +0 -1033
  117. package/dist/shaders/materials/body.d.ts +0 -2
  118. package/dist/shaders/materials/body.d.ts.map +0 -1
  119. package/dist/shaders/materials/body.js +0 -95
  120. package/dist/shaders/materials/cloth_rough.d.ts +0 -2
  121. package/dist/shaders/materials/cloth_rough.d.ts.map +0 -1
  122. package/dist/shaders/materials/cloth_rough.js +0 -69
  123. package/dist/shaders/materials/cloth_smooth.d.ts +0 -2
  124. package/dist/shaders/materials/cloth_smooth.d.ts.map +0 -1
  125. package/dist/shaders/materials/cloth_smooth.js +0 -61
  126. package/dist/shaders/materials/default.d.ts +0 -2
  127. package/dist/shaders/materials/default.d.ts.map +0 -1
  128. package/dist/shaders/materials/default.js +0 -43
  129. package/dist/shaders/materials/eye.d.ts +0 -2
  130. package/dist/shaders/materials/eye.d.ts.map +0 -1
  131. package/dist/shaders/materials/eye.js +0 -60
  132. package/dist/shaders/materials/face.d.ts +0 -2
  133. package/dist/shaders/materials/face.d.ts.map +0 -1
  134. package/dist/shaders/materials/face.js +0 -95
  135. package/dist/shaders/materials/hair.d.ts +0 -2
  136. package/dist/shaders/materials/hair.d.ts.map +0 -1
  137. package/dist/shaders/materials/hair.js +0 -90
  138. package/dist/shaders/materials/metal.d.ts +0 -2
  139. package/dist/shaders/materials/metal.d.ts.map +0 -1
  140. package/dist/shaders/materials/metal.js +0 -77
  141. package/dist/shaders/materials/mmd_classic.d.ts +0 -2
  142. package/dist/shaders/materials/mmd_classic.d.ts.map +0 -1
  143. package/dist/shaders/materials/mmd_classic.js +0 -66
  144. package/dist/shaders/materials/stockings.d.ts +0 -2
  145. package/dist/shaders/materials/stockings.d.ts.map +0 -1
  146. package/dist/shaders/materials/stockings.js +0 -122
  147. package/dist/shaders/passes/physics-debug.d.ts +0 -2
  148. package/dist/shaders/passes/physics-debug.d.ts.map +0 -1
  149. package/dist/shaders/passes/physics-debug.js +0 -69
  150. package/src/shaders/dfg_lut.ts +0 -131
  151. package/src/shaders/ltc_mag_lut.ts +0 -1035
@@ -1,415 +1,464 @@
1
- import { SCENE_TAP_STUB } from "./scene-tap"
2
- import { subjectMaskApi } from "./cast-api"
3
- // Positional lights, as data — the sibling of the cast, audio and score
4
- // interfaces, and shaped like them: one shared buffer, read through accessors,
5
- // never touched directly.
6
- //
7
- // WHAT THIS IS NOT. The sun is still the ONE key light and it still owns the
8
- // toon ramp. These are an ADDITIVE layer on top of whatever the material's
9
- // graph decided, and they deliberately do not re-ramp: two ramped terminators
10
- // crossing a cheek read as plastic, which is the failure every stylised
11
- // renderer that bolted a second key light onto a toon shader has shipped. A
12
- // light here brightens; it does not restate the shading.
13
- //
14
- // So there are no per-light shadows and no area lights. A fragment walks the
15
- // DOCUMENT's lamps through a world-space grid (see light-grid.ts): its cell
16
- // names the lamps that can reach it, so a stage rig of fifty costs each pixel
17
- // the handful standing near it. The lamps an EFFECT emits are placed by a
18
- // compute pass every frame, which the grid never sees, and are walked in full.
19
- //
20
- // LAYOUT, in 32-bit words, read by the shader as vec4u. A 16-word header:
21
- //
22
- // [0] light count (f32) [1] how many are the document's (f32)
23
- // [4..6] grid origin (f32) [7] 1 / cell edge (f32)
24
- // [8..10] grid dims (u32) [12..15] the OUTSIDE mask (u32 bits)
25
- //
26
- // then MAX_LIGHTS records of 16 floats — four vec4s:
27
- //
28
- // [0..2] position, world space [3] radius
29
- // [4..6] colour PREMULTIPLIED by intensity [7] type
30
- // [8..10] aim, unit, pointing away from the light [11] cos of the outer angle
31
- // [12] cos of the inner angle
32
- // [13] the rendering layers it does NOT reach (u32 bits) [14..15] spare
33
- //
34
- // then the grid: LIGHT_MASK_WORDS words of lamp bits per cell.
35
- //
36
- // Colour carries intensity because nothing reads them apart: every use is the
37
- // product, and storing two numbers that are only ever multiplied is two numbers
38
- // that can disagree.
39
- //
40
- // A POINT LIGHT IS A SPOT WITH NO CONE, and that is how the loop stays
41
- // branchless: it stores aim (0,0,0) and both cosines at -1, which makes the
42
- // cone term below saturate to exactly 1. `type` says which a light is for
43
- // anyone reading the buffer; the shading never asks.
44
- //
45
- // LAYERS, as the game's pipeline (URP's rendering layers) gates its lights: a
46
- // draw carries layer bits, and a light reaches it only where its own mask
47
- // shares one. Stored INVERTED — the layers a light does not reach — so the
48
- // zero every writer that predates layers leaves in [13] reaches everything,
49
- // which is what an effect's lamp and a plain document light mean.
50
-
51
- import { castDistanceStub } from "./passes/cast-distance"
52
- import { audioApi } from "./audio-api"
53
- import { midiApi } from "./midi-api"
54
- import { lyricsApi } from "./lyrics-api"
55
- import { clockApi, trailSlotsApi, viewportApi } from "./passes/hosted-api"
56
- import { idApi } from "./id-api"
57
- import { sceneLightApi, worldAmbientWgsl } from "./scene-light-api"
58
- import { pointsApi } from "./points-api"
59
- import { textureApi } from "./texture-api"
60
-
61
- /** Words before the first record: the counts, the grid's placement and the
62
- * outside mask, four vec4s — see the layout above. */
63
- export const LIGHT_HEADER = 16
64
- /** Floats per light — see the layout above. */
65
- export const LIGHT_STRIDE = 16
66
- /**
67
- * The cap. The loop below runs per fragment over the lights a scene HAS, so
68
- * this bounds the worst case rather than the ordinary one, and the buffer is
69
- * storage rather than uniform — 128 records is 8 KiB, which costs nothing to
70
- * declare and is paid for only by the scenes that fill it.
71
- *
72
- * Sized against the work, not against a round number: a Unity stage arrives
73
- * with a lamp per fixture. One rip of a resort brought thirty-three, a lit
74
- * interior brings a hundred or more, and the ceilings of sixteen and then
75
- * forty-eight each cut a real scene in half. Past this the extras are dropped.
76
- *
77
- * A fragment pays for the document lamps its grid cell names, so the cost
78
- * follows how many reach a place rather than how many the scene holds. It is
79
- * also why the cap is 128: one vec4u of bits per cell.
80
- */
81
- export const MAX_LIGHTS = 128
82
- /** Words of lamp bits per grid cell — one bit per lamp the cap allows. */
83
- export const LIGHT_MASK_WORDS = MAX_LIGHTS / 32
84
- /** The grid's cell budget. 32k cells over a stage a couple of hundred units
85
- * across is a cell of about four — finer than the lamps it separates. */
86
- export const LIGHT_GRID_CELLS = 32768
87
- /** Where the grid starts, in words. */
88
- export const LIGHT_GRID_BASE = LIGHT_HEADER + MAX_LIGHTS * LIGHT_STRIDE
89
- /** Words in the whole buffer: header, records, grid. */
90
- export const LIGHTS_FLOATS = LIGHT_GRID_BASE + LIGHT_GRID_CELLS * LIGHT_MASK_WORDS
91
-
92
- /**
93
- * The RzLight struct, declared in EVERY module a user's source is spliced into.
94
- *
95
- * One effect file goes into every module it has a mount in, so a foreground
96
- * effect that also emits lights compiles its lightEmit inside the FIELD shader
97
- * too — where nothing calls it, but it still has to resolve. Leaving the struct
98
- * out of those modules is a compile error on a function the author was right to
99
- * write, which is the same trap the grid's step-only half documents.
100
- */
101
- export const RZ_LIGHT_STRUCT_WGSL = /* wgsl */ `
102
- /** What an effect returns for one of its lights. */
103
- struct RzLight {
104
- pos: vec3f,
105
- color: vec3f,
106
- intensity: f32,
107
- radius: f32,
108
- }
109
- `
110
-
111
- /** rzWorldAmbient against the materials' and the ground's `light` uniform. */
112
- export const WORLD_AMBIENT_WGSL = worldAmbientWgsl("light")
113
-
114
- /** Does this source define the emit mount? */
115
- export function hasLightEmit(wgsl: string): boolean {
116
- return /\bfn\s+lightEmit\s*\(/.test(wgsl)
117
- }
118
-
119
- /**
120
- * The compute module that runs an effect's lightEmit once per light per frame.
121
- *
122
- * fn lightEmit(i: u32, time: f32) -> RzLight
123
- *
124
- * A COMPUTE stage rather than a CPU callback, and that is the whole point:
125
- * Fireworks knows where its bursts are as a closed form in WGSL, and mirroring
126
- * that on the CPU to place a light would be two derivations of one trajectory
127
- * that drift apart. Emitting in the shader means the light is wherever the
128
- * effect says it is, on the scene clock — which is also what makes it survive
129
- * an offline export frame-stepped at a different rate.
130
- *
131
- * The author writes local index 0..n-1 and never learns the global one, the
132
- * same aliasing the anchor table uses — so installing another effect ahead of
133
- * this one moves its lights without touching its source.
134
- *
135
- * The base arrives in the UNIFORM rather than baked into the text. Baking it
136
- * would mean recompiling every emitting effect the moment a scene gained or
137
- * lost a document light, because that is what shifts the slots underneath
138
- * them — a shader rebuild triggered by moving a lamp.
139
- *
140
- * The scene API arrives as a STRING rather than being imported, so this module
141
- * depends on nothing that depends on it. That API is what lets a lamp aim at
142
- * someone: Stage Lights points its beams at rzSubject().root, and a light that
143
- * did not know where she was could only sit where the fixture hangs. It also
144
- * brings RzLight, which is why this builder does not declare it again.
145
- */
146
- export function buildLightEmitShader(
147
- wgsl: string,
148
- sceneApi: string,
149
- cast: { trailCount: number },
150
- paramsDecl = "",
151
- ): string {
152
- return /* wgsl */ `
153
- // read_write HERE and read-only in the material shaders. Different passes, so
154
- // the two never coexist: this compute runs before the scene pass that reads it.
155
- @group(0) @binding(0) var<storage, read_write> _rzLightsOut: array<f32>;
156
- // [0] (time, base slot, count, weight) — see buildLightEmitShader on why the
157
- // base is here and not in the text — and [1].x the subject mask, which is the
158
- // only thing the second vec4 is for: an emitter reads the cast through the same
159
- // accessors its drawing half does, so a rig aimed at one dancer must see one.
160
- @group(0) @binding(1) var<uniform> _rzLightU: array<vec4f, 2>;
161
- // The camera block and the cast — the two buffers the scene API reads. Same
162
- // contents the field and grid modules bind, so an effect's lightEmit sees the
163
- // scene exactly as its drawing half does.
164
- @group(0) @binding(2) var<uniform> viewU: array<vec4<f32>, 15>;
165
- @group(0) @binding(3) var<storage, read> _rzCast: array<vec4f>;
166
- ${sceneApi}
167
- ${subjectMaskApi("u32(_rzLightU[1].x)")}
168
- ${SCENE_TAP_STUB}
169
- // Audio and score at 4 and 5, the same bindings the particle and trail modules
170
- // put them on. A lamp that pulses on the beat or lights on a note is the whole
171
- // point of a light an effect owns rather than one the document places, so this
172
- // is not compile-safety padding — it is the mount's reason to exist.
173
- ${audioApi(0, 4)}
174
- ${midiApi(0, 5)}
175
- ${lyricsApi(0, 6)}
176
- // The rest of the hosted API. Every one of these is here because the AUTHOR'S
177
- // WHOLE FILE lands below, not because lightEmit needs it: a trail effect that
178
- // grows a lamp at its tip compiles its ribbon code in this module too. The math
179
- // helpers and the Particle struct are NOT repeated — they arrive with the scene
180
- // API above, and a second copy is a redefinition error in engine code.
181
- ${clockApi("_rzLightU[0].x", "0.0")}
182
- // Canvas height — the same number the drawing modules read out of their camera
183
- // struct, both written from canvas.height, so this name means ONE value in
184
- // every module. Verified against the writers, not assumed: cameraMatrixData[35]
185
- // and viewU[6].w have the same source.
186
- ${viewportApi("viewU[6].w")}
187
- ${trailSlotsApi(cast.trailCount)}
188
- // The id accessors, stubbed: this module cannot read an attachment the
189
- // scene pass writes. See id-api.ts — the author's whole file compiles here.
190
- ${idApi(false, 0, 0) + castDistanceStub() + sceneLightApi(false, 0, 0) + pointsApi(false) + textureApi(0)}
191
- // The dials the author declared, if any. A lamp is exactly the thing someone
192
- // retunes — its colour and its reach — so an emitter reads params like every
193
- // other mount rather than being the one place a #param resolves to nothing.
194
- ${paramsDecl}
195
- ${wgsl}
196
-
197
- @compute @workgroup_size(64)
198
- fn lightEmitMain(@builtin(global_invocation_id) gid: vec3u) {
199
- let i = gid.x;
200
- // The dispatch is sized to the count, but a workgroup is 64 wide and the
201
- // count rarely is — the tail must not write into the next effect's slots.
202
- if (i >= u32(_rzLightU[0].z)) { return; }
203
- // Time is a PARAMETER, not an rzTime() call: this same source compiles
204
- // inside the field, particle, trail and grid modules, and those already
205
- // define rzTime differently or not at all. A parameter needs nothing from
206
- // the module it lands in, which is why the field mounts take theirs too.
207
- let l = lightEmit(i, _rzLightU[0].x);
208
- // SANITIZED at the one write site, because lightEmit is HOSTED USER CODE and
209
- // this buffer feeds every fragment of every material: one NaN position would
210
- // poison the whole frame, and WGSL leaves max(NaN, 0) indeterminate, so it
211
- // would not even fail the same way on every GPU. A light that fails the
212
- // check writes zeros — radius 0 is off. Colour is clamped at zero on top:
213
- // this layer is ADDITIVE, and a negative channel would darken what it lands
214
- // on and can push HDR negative into bloom.
215
- let finite = l.pos.x == l.pos.x && l.pos.y == l.pos.y && l.pos.z == l.pos.z &&
216
- l.radius == l.radius && l.intensity == l.intensity &&
217
- l.color.x == l.color.x && l.color.y == l.color.y && l.color.z == l.color.z;
218
- // Weight rides along with the sanitisation, which is already the one place
219
- // this buffer is written: a lamp at half weight is half as bright, and one
220
- // at zero never reaches here because the dispatch is skipped.
221
- let c = select(vec3f(0.0), max(l.color * l.intensity, vec3f(0.0)), finite) * _rzLightU[0].w;
222
- let b = ${LIGHT_HEADER}u + (u32(_rzLightU[0].y) + i) * ${LIGHT_STRIDE}u;
223
- _rzLightsOut[b] = select(0.0, l.pos.x, finite);
224
- _rzLightsOut[b + 1u] = select(0.0, l.pos.y, finite);
225
- _rzLightsOut[b + 2u] = select(0.0, l.pos.z, finite);
226
- // Radius goes to zero when the effect is OFF, and is left alone at every
227
- // weight above it. Scaling it with the fade would shrink a dimming lamp's
228
- // reach, which is a different thing than dimming it; zeroing it at nothing
229
- // is what lets a material's distance cull drop the slot entirely.
230
- _rzLightsOut[b + 3u] = select(0.0, max(l.radius, 0.0), finite && _rzLightU[0].w > 0.0);
231
- // Colour carries intensity, exactly as the CPU writer stores it — one product,
232
- // one place, so the two producers cannot disagree about what a slot means.
233
- _rzLightsOut[b + 4u] = c.x;
234
- _rzLightsOut[b + 5u] = c.y;
235
- _rzLightsOut[b + 6u] = c.z;
236
- // An effect emits POINT lights: RzLight carries no aim, and widening it would
237
- // fail to compile every emitter already written against it. The rest of the
238
- // record is written anyway — these slots are reused frame to frame, and a spot
239
- // the document placed here last frame would otherwise keep its cone.
240
- _rzLightsOut[b + 7u] = 0.0;
241
- _rzLightsOut[b + 8u] = 0.0;
242
- _rzLightsOut[b + 9u] = 0.0;
243
- _rzLightsOut[b + 10u] = 0.0;
244
- _rzLightsOut[b + 11u] = -1.0;
245
- _rzLightsOut[b + 12u] = -1.0;
246
- _rzLightsOut[b + 13u] = 0.0;
247
- _rzLightsOut[b + 14u] = 0.0;
248
- _rzLightsOut[b + 15u] = 0.0;
249
- }
250
- `
251
- }
252
-
253
- /** The rz*Light accessors, with the buffer declared at the given binding.
254
- * `layers` is the WGSL expression for the drawing's rendering-layer bits. */
255
- export function lightsApi(group: number, binding: number, layers: string): string {
256
- const R = LIGHT_HEADER / 4
257
- const S = LIGHT_STRIDE / 4
258
- return /* wgsl */ `
259
- // vec4u, so a record is four loads and a cell's lamp bits are one. Floats come
260
- // out through bitcast; the bits must NOT pass through f32 on the way, where a
261
- // mask that happens to spell a NaN is not guaranteed to survive a load.
262
- @group(${group}) @binding(${binding}) var<storage, read> _rzLights: array<vec4u>;
263
-
264
- const RZ_MAX_LIGHTS: u32 = ${MAX_LIGHTS}u;
265
- // A lamp's bulb, in world units: the inverse square is held flat inside it,
266
- // so the spike beside the lamp is finite. Aether Gazer's lamps are
267
- // 0.1 m across (their shapeRadius, capping 1/d² at 1/0.1), which at MMD scale
268
- // is 2.5 units.
269
- const RZ_LAMP_NEAR: f32 = 2.5;
270
-
271
- /** How many positional lights the scene has. Zero is the ordinary case. */
272
- fn rzLightCount() -> u32 { return min(u32(bitcast<f32>(_rzLights[0].x)), RZ_MAX_LIGHTS); }
273
-
274
- /** How many of them the document placed — the ones the grid indexes. */
275
- fn _rzLightDocCount() -> u32 { return min(u32(bitcast<f32>(_rzLights[0].y)), rzLightCount()); }
276
-
277
- /** One vec4 of light i's record. */
278
- fn _rzLightVec(i: u32, k: u32) -> vec4f { return bitcast<vec4f>(_rzLights[${R}u + i * ${S}u + k]); }
279
-
280
- /** Light i's world position. */
281
- fn rzLightPos(i: u32) -> vec3f { return _rzLightVec(i, 0u).xyz; }
282
-
283
- /** How far light i reaches. Its falloff is zero AT this distance, not merely
284
- * small, which is the bound the grid is built from. */
285
- fn rzLightRadius(i: u32) -> f32 { return _rzLightVec(i, 0u).w; }
286
-
287
- /** Light i's colour, already multiplied by its intensity. */
288
- fn rzLightColor(i: u32) -> vec3f { return _rzLightVec(i, 1u).xyz; }
289
-
290
- /** Where light i points, away from itself. The zero vector for a point light. */
291
- fn rzLightAim(i: u32) -> vec3f { return _rzLightVec(i, 2u).xyz; }
292
-
293
- /** The cosines a spot fades between: x its outer edge, y its inner one. A point
294
- * light stores (-1, -1), which saturates the cone term to 1. */
295
- fn rzLightCone(i: u32) -> vec2f { return vec2f(_rzLightVec(i, 2u).w, _rzLightVec(i, 3u).x); }
296
-
297
- /** Does light i reach this drawing's rendering layers? Read as bits, never as
298
- * a float — see the layout. */
299
- fn _rzLightReaches(i: u32) -> bool { return ((${layers}) & ~_rzLights[${R}u + i * ${S}u + 3u].y) != 0u; }
300
-
301
- /** The document lamps that can reach p: its grid cell's bits, or the outside
302
- * mask beyond the grid. Written so a NaN position fails the inside test. */
303
- fn _rzLightCellMask(p: vec3f) -> vec4u {
304
- let g = bitcast<vec4f>(_rzLights[1]);
305
- let dims = _rzLights[2].xyz;
306
- let c = floor((p - g.xyz) * g.w);
307
- if (!(all(c >= vec3f(0.0)) && all(c < vec3f(dims)))) { return _rzLights[3]; }
308
- let ci = vec3u(c);
309
- return _rzLights[${LIGHT_GRID_BASE / 4}u + (ci.z * dims.y + ci.y) * dims.x + ci.x];
310
- }
311
-
312
- /**
313
- * One light's contribution at a surface point.
314
- *
315
- * A LIGHT FALLS OFF AS THE INVERSE SQUARE, the curve Unity, Unreal, Blender and
316
- * glTF all light with: intensity / max(d², RZ_LAMP_NEAR²), so its intensity is
317
- * the brightness one unit away, windowed by (1 − (d/R)⁴)² so it is exactly zero
318
- * at its radius and the bound the grid is built from is real.
319
- *
320
- * THE UNITS ARE BLENDER'S. Intensity is radiant intensity, a point light's
321
- * power over 4π, and a Lambertian surface returns albedo × irradiance / π —
322
- * the π the sun term already carries. So a stage exported from Blender lights
323
- * here as it lit there, and a lamp's intensity is what Blender's exporter
324
- * writes in candela over 683.
325
- */
326
- fn _rzLightOne(i: u32, p: vec3f, n: vec3f) -> vec3f {
327
- if (!_rzLightReaches(i)) { return vec3f(0.0); }
328
- let pr = _rzLightVec(i, 0u);
329
- let d = pr.xyz - p;
330
- let dist = length(d);
331
- // Out of reach before anything else is computed. The grid is conservative —
332
- // a cell's bit means the lamp CAN reach part of it — so this still runs.
333
- if (dist >= pr.w) { return vec3f(0.0); }
334
- let toLight = d / max(dist, 1e-4);
335
- // Facing the light, and nothing behind it. No wrap or half-lambert: this
336
- // layer adds light, and a wrapped term would lift the shadow side, which is
337
- // the ramp's business and not this one's.
338
- let ndl = max(dot(n, toLight), 0.0);
339
- if (ndl <= 0.0) { return vec3f(0.0); }
340
- let t = clamp(dist / max(pr.w, 1e-4), 0.0, 1.0);
341
- let t2 = t * t;
342
- let window = 1.0 - t2 * t2;
343
- let falloff = window * window / max(dist * dist, RZ_LAMP_NEAR * RZ_LAMP_NEAR);
344
- // How far inside the cone this point sits: 1 within the inner angle, 0 past
345
- // the outer one, squared for the same soft edge the falloff has. A point
346
- // light's (-1, -1) divides by the floor and clamps to 1, so it pays one
347
- // dot product and no branch.
348
- let cone = rzLightCone(i);
349
- let aim = clamp((dot(-toLight, rzLightAim(i)) - cone.x) / max(cone.y - cone.x, 1e-4), 0.0, 1.0);
350
- return rzLightColor(i) * (ndl * falloff * aim * aim);
351
- }
352
-
353
- /** The lamps named by one word of a cell's bits, lowest first. */
354
- fn _rzLightWord(bits0: u32, base: u32, p: vec3f, n: vec3f) -> vec3f {
355
- var acc = vec3f(0.0);
356
- var bits = bits0;
357
- loop {
358
- if (bits == 0u) { break; }
359
- let i = base + firstTrailingBit(bits);
360
- bits = bits & (bits - 1u);
361
- acc = acc + _rzLightOne(i, p, n);
362
- }
363
- return acc;
364
- }
365
-
366
- /**
367
- * Every positional light's contribution at a surface point, as light — not as a
368
- * finished colour. Multiply by whatever the surface's albedo is.
369
- *
370
- * WITH NO LIGHTS THIS RETURNS EXACTLY ZERO and neither walk runs, so a scene
371
- * that declares none is arithmetically identical to one compiled before lights
372
- * existed. That is the property the whole feature is gated on: adding this to
373
- * every material must cost nothing until someone asks for a light.
374
- */
375
- fn rzLightsDiffuse(p: vec3f, n: vec3f) -> vec3f {
376
- return _rzLightsIrradiance(p, n) * (1.0 / 3.141592653589793);
377
- }
378
-
379
- // ONE WALK PER FRAGMENT. A principled closure walks the lamps for its specular
380
- // anyway, and every lamp's reach, falloff and cone are the same numbers the
381
- // diffuse layer needs — so that walk computes both, against the normal the
382
- // material prelude recorded here, and leaves the diffuse for the epilogue.
383
- // Walking the grid twice was ~60% of a lit stage's frame.
384
- var<private> _rzLampN: vec3f;
385
- var<private> _rzLampDiffuse: vec3f;
386
- var<private> _rzLampDiffuseSet: bool = false;
387
- // Set by a closure that shaded the lamps' diffuse itself (a Unity-mode
388
- // principled, which tints it by its own base) — the epilogue then adds none.
389
- var<private> _rzLampDiffuseTaken: bool = false;
390
-
391
- /** rzLightsDiffuse(p, _rzLampN), taken from a principled walk when one ran. */
392
- fn rzLightsDiffuseOnce(p: vec3f, n: vec3f) -> vec3f {
393
- if (_rzLampDiffuseTaken) { return vec3f(0.0); }
394
- if (_rzLampDiffuseSet) { return _rzLampDiffuse; }
395
- return rzLightsDiffuse(p, n);
396
- }
397
-
398
- fn _rzLightsIrradiance(p: vec3f, n: vec3f) -> vec3f {
399
- var acc = vec3f(0.0);
400
- let count = rzLightCount();
401
- let docs = _rzLightDocCount();
402
- // The document's lamps: only those the grid says can reach this cell.
403
- if (docs > 0u) {
404
- let m = _rzLightCellMask(p);
405
- acc = acc + _rzLightWord(m.x, 0u, p, n) + _rzLightWord(m.y, 32u, p, n) +
406
- _rzLightWord(m.z, 64u, p, n) + _rzLightWord(m.w, 96u, p, n);
407
- }
408
- // The effects' lamps, which move every frame on the GPU: all of them.
409
- for (var i = docs; i < count; i = i + 1u) {
410
- acc = acc + _rzLightOne(i, p, n);
411
- }
412
- return acc;
413
- }
414
- `
415
- }
1
+ import { SCENE_TAP_STUB } from "./scene-tap"
2
+ import { subjectMaskApi } from "./cast-api"
3
+ // Positional lights, as data — the sibling of the cast, audio and score
4
+ // interfaces, and shaped like them: one shared buffer, read through accessors,
5
+ // never touched directly.
6
+ //
7
+ // WHAT THIS IS NOT. The sun is still the ONE key light and it still owns the
8
+ // toon ramp. These are an ADDITIVE layer on top of whatever the material's
9
+ // graph decided, and they deliberately do not re-ramp: two ramped terminators
10
+ // crossing a cheek read as plastic, which is the failure every stylised
11
+ // renderer that bolted a second key light onto a toon shader has shipped. A
12
+ // light here brightens; it does not restate the shading.
13
+ //
14
+ // So there are no per-light shadows and no area lights. A fragment walks the
15
+ // DOCUMENT's lamps through a world-space grid (see light-grid.ts): its cell
16
+ // names the lamps that can reach it, so a stage rig of fifty costs each pixel
17
+ // the handful standing near it. The lamps an EFFECT emits are placed by a
18
+ // compute pass every frame, which the grid never sees, and are walked in full.
19
+ //
20
+ // LAYOUT, in 32-bit words, read by the shader as vec4u. A 20-word header:
21
+ //
22
+ // [0] light count (f32) [1] how many are the document's (f32)
23
+ // [4..6] grid origin (f32) [7] 1 / cell edge (f32)
24
+ // [8..10] grid dims (u32) [12..19] the OUTSIDE mask (u32 bits)
25
+ //
26
+ // then MAX_LIGHTS records of 16 floats — four vec4s:
27
+ //
28
+ // [0..2] position, world space [3] radius
29
+ // [4..6] colour PREMULTIPLIED by intensity [7] type
30
+ // [8..10] aim, unit, pointing away from the light [11] cos of the outer angle
31
+ // [12] cos of the inner angle
32
+ // [13] the rendering layers it does NOT reach (u32 bits)
33
+ // [14] its bulb: the distance inside which the inverse square is held flat
34
+ // (0 = RZ_LAMP_NEAR) [15] its cookie: layer + 1, 0 none
35
+ //
36
+ // then the grid: LIGHT_MASK_WORDS words of lamp bits per cell.
37
+ //
38
+ // Colour carries intensity because nothing reads them apart: every use is the
39
+ // product, and storing two numbers that are only ever multiplied is two numbers
40
+ // that can disagree.
41
+ //
42
+ // A POINT LIGHT IS A SPOT WITH NO CONE, and that is how the loop stays
43
+ // branchless: it stores aim (0,0,0) and both cosines at -1, which makes the
44
+ // cone term below saturate to exactly 1. `type` says which a light is for
45
+ // anyone reading the buffer; the shading never asks.
46
+ //
47
+ // LAYERS, as the game's pipeline (URP's rendering layers) gates its lights: a
48
+ // draw carries layer bits, and a light reaches it only where its own mask
49
+ // shares one. Stored INVERTED — the layers a light does not reach — so the
50
+ // zero every writer that predates layers leaves in [13] reaches everything,
51
+ // which is what an effect's lamp and a plain document light mean.
52
+
53
+ import { castDistanceStub } from "./passes/cast-distance"
54
+ import { audioApi } from "./audio-api"
55
+ import { midiApi } from "./midi-api"
56
+ import { lyricsApi } from "./lyrics-api"
57
+ import { clockApi, trailSlotsApi, viewportApi } from "./passes/hosted-api"
58
+ import { idApi } from "./id-api"
59
+ import { sceneLightApi, worldAmbientWgsl } from "./scene-light-api"
60
+ import { pointsApi } from "./points-api"
61
+ import { textureApi } from "./texture-api"
62
+
63
+ /** Words before the first record: the counts, the grid's placement and the
64
+ * outside mask, five vec4s — see the layout above. */
65
+ export const LIGHT_HEADER = 20
66
+ /** Floats per light — see the layout above. */
67
+ export const LIGHT_STRIDE = 16
68
+ /**
69
+ * The cap. The loop below runs per fragment over the lights a scene HAS, so
70
+ * this bounds the worst case rather than the ordinary one, and the buffer is
71
+ * storage rather than uniform — 128 records is 8 KiB, which costs nothing to
72
+ * declare and is paid for only by the scenes that fill it.
73
+ *
74
+ * Sized against the work, not against a round number: a Unity stage arrives
75
+ * with a lamp per fixture. One rip of a resort brought thirty-three, a lit
76
+ * interior brings a hundred or more, and the ceilings of sixteen and then
77
+ * forty-eight each cut a real scene in half. Past this the extras are dropped.
78
+ *
79
+ * A fragment pays for the document lamps its grid cell names, so the cost
80
+ * follows how many reach a place rather than how many the scene holds. 256,
81
+ * two vec4u of bits per cell, is URP's own Forward+ budget for visible lights
82
+ * — and holds a lit interior's whole rig (X317's 153).
83
+ */
84
+ export const MAX_LIGHTS = 256
85
+ /** Words of lamp bits per grid cell — one bit per lamp the cap allows. */
86
+ export const LIGHT_MASK_WORDS = MAX_LIGHTS / 32
87
+ /** The grid's cell budget. 32k cells over a stage a couple of hundred units
88
+ * across is a cell of about four — finer than the lamps it separates. */
89
+ export const LIGHT_GRID_CELLS = 32768
90
+ /** Where the grid starts, in words. */
91
+ export const LIGHT_GRID_BASE = LIGHT_HEADER + MAX_LIGHTS * LIGHT_STRIDE
92
+ /** Words in the whole buffer: header, records, grid. */
93
+ export const LIGHTS_FLOATS = LIGHT_GRID_BASE + LIGHT_GRID_CELLS * LIGHT_MASK_WORDS
94
+
95
+ /**
96
+ * The RzLight struct, declared in EVERY module a user's source is spliced into.
97
+ *
98
+ * One effect file goes into every module it has a mount in, so a foreground
99
+ * effect that also emits lights compiles its lightEmit inside the FIELD shader
100
+ * too — where nothing calls it, but it still has to resolve. Leaving the struct
101
+ * out of those modules is a compile error on a function the author was right to
102
+ * write, which is the same trap the grid's step-only half documents.
103
+ */
104
+ export const RZ_LIGHT_STRUCT_WGSL = /* wgsl */ `
105
+ /** What an effect returns for one of its lights. */
106
+ struct RzLight {
107
+ pos: vec3f,
108
+ color: vec3f,
109
+ intensity: f32,
110
+ radius: f32,
111
+ /** Its bulb: the distance inside which it stops getting brighter. 0 is the
112
+ * engine's default; a glow worn on a hand wants one the size of the glow. */
113
+ near: f32,
114
+ }
115
+ `
116
+
117
+ /** rzWorldAmbient against the materials' and the ground's `light` uniform. */
118
+ export const WORLD_AMBIENT_WGSL = worldAmbientWgsl("light")
119
+
120
+ /** Does this source define the emit mount? */
121
+ export function hasLightEmit(wgsl: string): boolean {
122
+ return /\bfn\s+lightEmit\s*\(/.test(wgsl)
123
+ }
124
+
125
+ /**
126
+ * The compute module that runs an effect's lightEmit once per light per frame.
127
+ *
128
+ * fn lightEmit(i: u32, time: f32) -> RzLight
129
+ *
130
+ * A COMPUTE stage rather than a CPU callback, and that is the whole point:
131
+ * Fireworks knows where its bursts are as a closed form in WGSL, and mirroring
132
+ * that on the CPU to place a light would be two derivations of one trajectory
133
+ * that drift apart. Emitting in the shader means the light is wherever the
134
+ * effect says it is, on the scene clock — which is also what makes it survive
135
+ * an offline export frame-stepped at a different rate.
136
+ *
137
+ * The author writes local index 0..n-1 and never learns the global one, the
138
+ * same aliasing the anchor table uses — so installing another effect ahead of
139
+ * this one moves its lights without touching its source.
140
+ *
141
+ * The base arrives in the UNIFORM rather than baked into the text. Baking it
142
+ * would mean recompiling every emitting effect the moment a scene gained or
143
+ * lost a document light, because that is what shifts the slots underneath
144
+ * them — a shader rebuild triggered by moving a lamp.
145
+ *
146
+ * The scene API arrives as a STRING rather than being imported, so this module
147
+ * depends on nothing that depends on it. That API is what lets a lamp aim at
148
+ * someone: Stage Lights points its beams at rzSubject().root, and a light that
149
+ * did not know where she was could only sit where the fixture hangs. It also
150
+ * brings RzLight, which is why this builder does not declare it again.
151
+ */
152
+ export function buildLightEmitShader(
153
+ wgsl: string,
154
+ sceneApi: string,
155
+ cast: { trailCount: number },
156
+ paramsDecl = "",
157
+ ): string {
158
+ return /* wgsl */ `
159
+ // read_write HERE and read-only in the material shaders. Different passes, so
160
+ // the two never coexist: this compute runs before the scene pass that reads it.
161
+ @group(0) @binding(0) var<storage, read_write> _rzLightsOut: array<f32>;
162
+ // [0] (time, base slot, count, weight) — see buildLightEmitShader on why the
163
+ // base is here and not in the text — and [1].x the subject mask, which is the
164
+ // only thing the second vec4 is for: an emitter reads the cast through the same
165
+ // accessors its drawing half does, so a rig aimed at one dancer must see one.
166
+ @group(0) @binding(1) var<uniform> _rzLightU: array<vec4f, 2>;
167
+ // The camera block and the cast — the two buffers the scene API reads. Same
168
+ // contents the field and grid modules bind, so an effect's lightEmit sees the
169
+ // scene exactly as its drawing half does.
170
+ @group(0) @binding(2) var<uniform> viewU: array<vec4<f32>, 15>;
171
+ @group(0) @binding(3) var<storage, read> _rzCast: array<vec4f>;
172
+ ${sceneApi}
173
+ ${subjectMaskApi("u32(_rzLightU[1].x)")}
174
+ ${SCENE_TAP_STUB}
175
+ // Audio and score at 4 and 5, the same bindings the particle and trail modules
176
+ // put them on. A lamp that pulses on the beat or lights on a note is the whole
177
+ // point of a light an effect owns rather than one the document places, so this
178
+ // is not compile-safety padding — it is the mount's reason to exist.
179
+ ${audioApi(0, 4)}
180
+ ${midiApi(0, 5)}
181
+ ${lyricsApi(0, 6)}
182
+ // The rest of the hosted API. Every one of these is here because the AUTHOR'S
183
+ // WHOLE FILE lands below, not because lightEmit needs it: a trail effect that
184
+ // grows a lamp at its tip compiles its ribbon code in this module too. The math
185
+ // helpers and the Particle struct are NOT repeated — they arrive with the scene
186
+ // API above, and a second copy is a redefinition error in engine code.
187
+ ${clockApi("_rzLightU[0].x", "0.0")}
188
+ // Canvas height — the same number the drawing modules read out of their camera
189
+ // struct, both written from canvas.height, so this name means ONE value in
190
+ // every module. Verified against the writers, not assumed: cameraMatrixData[35]
191
+ // and viewU[6].w have the same source.
192
+ ${viewportApi("viewU[6].w")}
193
+ ${trailSlotsApi(cast.trailCount)}
194
+ // The id accessors, stubbed: this module cannot read an attachment the
195
+ // scene pass writes. See id-api.ts — the author's whole file compiles here.
196
+ ${idApi(false, 0, 0) + castDistanceStub() + sceneLightApi(false, 0, 0) + pointsApi(false) + textureApi(0)}
197
+ // The dials the author declared, if any. A lamp is exactly the thing someone
198
+ // retunes — its colour and its reach — so an emitter reads params like every
199
+ // other mount rather than being the one place a #param resolves to nothing.
200
+ ${paramsDecl}
201
+ ${wgsl}
202
+
203
+ @compute @workgroup_size(64)
204
+ fn lightEmitMain(@builtin(global_invocation_id) gid: vec3u) {
205
+ let i = gid.x;
206
+ // The dispatch is sized to the count, but a workgroup is 64 wide and the
207
+ // count rarely is — the tail must not write into the next effect's slots.
208
+ if (i >= u32(_rzLightU[0].z)) { return; }
209
+ // Time is a PARAMETER, not an rzTime() call: this same source compiles
210
+ // inside the field, particle, trail and grid modules, and those already
211
+ // define rzTime differently or not at all. A parameter needs nothing from
212
+ // the module it lands in, which is why the field mounts take theirs too.
213
+ let l = lightEmit(i, _rzLightU[0].x);
214
+ // SANITIZED at the one write site, because lightEmit is HOSTED USER CODE and
215
+ // this buffer feeds every fragment of every material: one NaN position would
216
+ // poison the whole frame, and WGSL leaves max(NaN, 0) indeterminate, so it
217
+ // would not even fail the same way on every GPU. A light that fails the
218
+ // check writes zeros — radius 0 is off. Colour is clamped at zero on top:
219
+ // this layer is ADDITIVE, and a negative channel would darken what it lands
220
+ // on and can push HDR negative into bloom.
221
+ let finite = l.pos.x == l.pos.x && l.pos.y == l.pos.y && l.pos.z == l.pos.z &&
222
+ l.radius == l.radius && l.intensity == l.intensity &&
223
+ l.color.x == l.color.x && l.color.y == l.color.y && l.color.z == l.color.z;
224
+ // Weight rides along with the sanitisation, which is already the one place
225
+ // this buffer is written: a lamp at half weight is half as bright, and one
226
+ // at zero never reaches here because the dispatch is skipped.
227
+ let c = select(vec3f(0.0), max(l.color * l.intensity, vec3f(0.0)), finite) * _rzLightU[0].w;
228
+ let b = ${LIGHT_HEADER}u + (u32(_rzLightU[0].y) + i) * ${LIGHT_STRIDE}u;
229
+ _rzLightsOut[b] = select(0.0, l.pos.x, finite);
230
+ _rzLightsOut[b + 1u] = select(0.0, l.pos.y, finite);
231
+ _rzLightsOut[b + 2u] = select(0.0, l.pos.z, finite);
232
+ // Radius goes to zero when the effect is OFF, and is left alone at every
233
+ // weight above it. Scaling it with the fade would shrink a dimming lamp's
234
+ // reach, which is a different thing than dimming it; zeroing it at nothing
235
+ // is what lets a material's distance cull drop the slot entirely.
236
+ _rzLightsOut[b + 3u] = select(0.0, max(l.radius, 0.0), finite && _rzLightU[0].w > 0.0);
237
+ // Colour carries intensity, exactly as the CPU writer stores it — one product,
238
+ // one place, so the two producers cannot disagree about what a slot means.
239
+ _rzLightsOut[b + 4u] = c.x;
240
+ _rzLightsOut[b + 5u] = c.y;
241
+ _rzLightsOut[b + 6u] = c.z;
242
+ // An effect emits POINT lights: RzLight carries no aim, and widening it would
243
+ // fail to compile every emitter already written against it. The rest of the
244
+ // record is written anyway — these slots are reused frame to frame, and a spot
245
+ // the document placed here last frame would otherwise keep its cone.
246
+ _rzLightsOut[b + 7u] = 0.0;
247
+ _rzLightsOut[b + 8u] = 0.0;
248
+ _rzLightsOut[b + 9u] = 0.0;
249
+ _rzLightsOut[b + 10u] = 0.0;
250
+ _rzLightsOut[b + 11u] = -1.0;
251
+ _rzLightsOut[b + 12u] = -1.0;
252
+ _rzLightsOut[b + 13u] = 0.0;
253
+ _rzLightsOut[b + 14u] = select(0.0, max(l.near, 0.0), finite && l.near == l.near);
254
+ _rzLightsOut[b + 15u] = 0.0;
255
+ }
256
+ `
257
+ }
258
+
259
+ /** The rz*Light accessors, with the buffer declared at the given binding.
260
+ * `layers` is the WGSL expression for the drawing's rendering-layer bits. */
261
+ export function lightsApi(
262
+ group: number,
263
+ binding: number,
264
+ layers: string,
265
+ cookies: { binding: number; sampler: string },
266
+ ): string {
267
+ const R = LIGHT_HEADER / 4
268
+ const S = LIGHT_STRIDE / 4
269
+ return /* wgsl */ `
270
+ // vec4u, so a record is four loads and a cell's lamp bits are one. Floats come
271
+ // out through bitcast; the bits must NOT pass through f32 on the way, where a
272
+ // mask that happens to spell a NaN is not guaranteed to survive a load.
273
+ @group(${group}) @binding(${binding}) var<storage, read> _rzLights: array<vec4u>;
274
+ // The spot lamps' cookies, one picture a layer (Engine.loadLightCookie).
275
+ @group(${group}) @binding(${cookies.binding}) var _rzCookies: texture_2d_array<f32>;
276
+
277
+ const RZ_MAX_LIGHTS: u32 = ${MAX_LIGHTS}u;
278
+ // The DEFAULT bulb, in world units, for a record that names none (word 14):
279
+ // the inverse square is held flat inside it, so the spike beside the lamp is
280
+ // finite. URP's own floor — distance² at least HALF_MIN, 6.1e-5 m² — which at
281
+ // MMD scale (8 units a metre) is a bulb of 0.0625. A lamp with a real size (a
282
+ // game rig's shapeRadius) says so in word 14.
283
+ const RZ_LAMP_NEAR: f32 = 0.0625;
284
+
285
+ /** How many positional lights the scene has. Zero is the ordinary case. */
286
+ fn rzLightCount() -> u32 { return min(u32(bitcast<f32>(_rzLights[0].x)), RZ_MAX_LIGHTS); }
287
+
288
+ /** How many of them the document placed — the ones the grid indexes. */
289
+ fn _rzLightDocCount() -> u32 { return min(u32(bitcast<f32>(_rzLights[0].y)), rzLightCount()); }
290
+
291
+ /** One vec4 of light i's record. */
292
+ fn _rzLightVec(i: u32, k: u32) -> vec4f { return bitcast<vec4f>(_rzLights[${R}u + i * ${S}u + k]); }
293
+
294
+ /** Light i's world position. */
295
+ fn rzLightPos(i: u32) -> vec3f { return _rzLightVec(i, 0u).xyz; }
296
+
297
+ /** How far light i reaches. Its falloff is zero AT this distance, not merely
298
+ * small, which is the bound the grid is built from. */
299
+ fn rzLightRadius(i: u32) -> f32 { return _rzLightVec(i, 0u).w; }
300
+
301
+ /** Light i's colour, already multiplied by its intensity. */
302
+ fn rzLightColor(i: u32) -> vec3f { return _rzLightVec(i, 1u).xyz; }
303
+
304
+ /** Where light i points, away from itself. The zero vector for a point light. */
305
+ fn rzLightAim(i: u32) -> vec3f { return _rzLightVec(i, 2u).xyz; }
306
+
307
+ /** A spot's cookie, as URP's: its picture projected along the cone, so the
308
+ * light a point takes is the picture's colour there. The picture's square
309
+ * spans the cone's outer angle, up on the picture toward world up (or +z for
310
+ * a lamp aimed straight up or down). 1 for a lamp without one, and for a
311
+ * point lamp, whose cookie would need a cube. */
312
+ fn rzLightCookie(i: u32, toLight: vec3f) -> vec3f {
313
+ let k = _rzLightVec(i, 3u).w;
314
+ let a = rzLightAim(i);
315
+ if (k < 0.5 || dot(a, a) < 0.5) { return vec3f(1.0); }
316
+ let up0 = select(vec3f(0.0, 1.0, 0.0), vec3f(0.0, 0.0, 1.0), abs(a.y) > 0.99);
317
+ let right = normalize(cross(up0, a));
318
+ let up = cross(a, right);
319
+ let d = -toLight;
320
+ let c = rzLightCone(i).x;
321
+ let spread = sqrt(max(1.0 - c * c, 0.0)) / max(c, 1e-4);
322
+ let z = max(dot(d, a), 1e-4);
323
+ let uv = vec2f(dot(d, right), -dot(d, up)) / (z * spread) * 0.5 + 0.5;
324
+ return textureSampleLevel(_rzCookies, ${cookies.sampler}, uv, i32(k) - 1, 0.0).rgb;
325
+ }
326
+
327
+ /** Light i's bulb: inside this distance the inverse square is held flat. A
328
+ * record that names none (0, every writer that predates the word) takes
329
+ * RZ_LAMP_NEAR. A game lamp's is its shapeRadius: Unity caps 1/d² at
330
+ * 1/shapeRadius, so the flat core is sqrt(shapeRadius) - and a character rig
331
+ * tuned with a bulb wider than its reach is a lamp of constant strength inside
332
+ * a soft sphere, which a single global bulb cannot spell. */
333
+ fn rzLightNear(i: u32) -> f32 { let r = _rzLightVec(i, 3u).z; return select(RZ_LAMP_NEAR, r, r > 0.0); }
334
+
335
+ /** The cosines a spot fades between: x its outer edge, y its inner one. A point
336
+ * light stores (-1, -1), which saturates the cone term to 1. */
337
+ fn rzLightCone(i: u32) -> vec2f { return vec2f(_rzLightVec(i, 2u).w, _rzLightVec(i, 3u).x); }
338
+
339
+ /** Does light i reach this drawing's rendering layers? Read as bits, never as
340
+ * a float — see the layout. */
341
+ fn _rzLightReaches(i: u32) -> bool { return ((${layers}) & ~_rzLights[${R}u + i * ${S}u + 3u].y) != 0u; }
342
+
343
+ /** The document lamps that can reach p — 256 bits, lamps 0..127 in .lo and
344
+ * 128..255 in .hi: its grid cell's, or the outside mask beyond the grid.
345
+ * Written so a NaN position fails the inside test. */
346
+ struct _RzLampMask { lo: vec4u, hi: vec4u };
347
+ fn _rzLightCellMask(p: vec3f) -> _RzLampMask {
348
+ let g = bitcast<vec4f>(_rzLights[1]);
349
+ let dims = _rzLights[2].xyz;
350
+ let c = floor((p - g.xyz) * g.w);
351
+ if (!(all(c >= vec3f(0.0)) && all(c < vec3f(dims)))) { return _RzLampMask(_rzLights[3], _rzLights[4]); }
352
+ let ci = vec3u(c);
353
+ let at = ${LIGHT_GRID_BASE / 4}u + ((ci.z * dims.y + ci.y) * dims.x + ci.x) * 2u;
354
+ return _RzLampMask(_rzLights[at], _rzLights[at + 1u]);
355
+ }
356
+
357
+ /**
358
+ * One light's contribution at a surface point.
359
+ *
360
+ * A LIGHT FALLS OFF AS THE INVERSE SQUARE, the curve Unity, Unreal, Blender and
361
+ * glTF all light with: intensity / max(d², bulb²) (rzLightNear), so its intensity is
362
+ * the brightness one unit away, windowed by (1 − (d/R)⁴)² so it is exactly zero
363
+ * at its radius and the bound the grid is built from is real.
364
+ *
365
+ * LIGHT UNITS ARE UNITY'S, for every light in this engine — the sun, the
366
+ * directional slots and these lamps: a surface returns albedo × light × N·L
367
+ * with no 1/π (URP folds it into its intensities), and a highlight is the
368
+ * lobe as URP writes it. So a white lamp of intensity 1 one unit away lights
369
+ * albedo 1:1 head on, as a Unity point light does. A Blender light carries
370
+ * divided by π (the app's GLB loader does it).
371
+ */
372
+ fn _rzLightOne(i: u32, p: vec3f, n: vec3f) -> vec3f {
373
+ if (!_rzLightReaches(i)) { return vec3f(0.0); }
374
+ let pr = _rzLightVec(i, 0u);
375
+ let d = pr.xyz - p;
376
+ let dist = length(d);
377
+ // Out of reach before anything else is computed. The grid is conservative —
378
+ // a cell's bit means the lamp CAN reach part of it — so this still runs.
379
+ if (dist >= pr.w) { return vec3f(0.0); }
380
+ let toLight = d / max(dist, 1e-4);
381
+ // Facing the light, and nothing behind it. No wrap or half-lambert: this
382
+ // layer adds light, and a wrapped term would lift the shadow side, which is
383
+ // the ramp's business and not this one's.
384
+ let ndl = max(dot(n, toLight), 0.0);
385
+ if (ndl <= 0.0) { return vec3f(0.0); }
386
+ let t = clamp(dist / max(pr.w, 1e-4), 0.0, 1.0);
387
+ let t2 = t * t;
388
+ let window = 1.0 - t2 * t2;
389
+ let bulb = rzLightNear(i);
390
+ let falloff = window * window / max(dist * dist, bulb * bulb);
391
+ // How far inside the cone this point sits: 1 within the inner angle, 0 past
392
+ // the outer one, squared for the same soft edge the falloff has. A point
393
+ // light's (-1, -1) divides by the floor and clamps to 1, so it pays one
394
+ // dot product and no branch.
395
+ let cone = rzLightCone(i);
396
+ let aim = clamp((dot(-toLight, rzLightAim(i)) - cone.x) / max(cone.y - cone.x, 1e-4), 0.0, 1.0);
397
+ return rzLightColor(i) * rzLightCookie(i, toLight) * (ndl * falloff * aim * aim);
398
+ }
399
+
400
+ /** The lamps named by one word of a cell's bits, lowest first. */
401
+ fn _rzLightWord(bits0: u32, base: u32, p: vec3f, n: vec3f) -> vec3f {
402
+ var acc = vec3f(0.0);
403
+ var bits = bits0;
404
+ loop {
405
+ if (bits == 0u) { break; }
406
+ let i = base + firstTrailingBit(bits);
407
+ bits = bits & (bits - 1u);
408
+ acc = acc + _rzLightOne(i, p, n);
409
+ }
410
+ return acc;
411
+ }
412
+
413
+ /**
414
+ * Every positional light's contribution at a surface point, as light — not as a
415
+ * finished colour. Multiply by whatever the surface's albedo is.
416
+ *
417
+ * WITH NO LIGHTS THIS RETURNS EXACTLY ZERO and neither walk runs, so a scene
418
+ * that declares none is arithmetically identical to one compiled before lights
419
+ * existed. That is the property the whole feature is gated on: adding this to
420
+ * every material must cost nothing until someone asks for a light.
421
+ */
422
+ fn rzLightsDiffuse(p: vec3f, n: vec3f) -> vec3f {
423
+ return _rzLightsIrradiance(p, n);
424
+ }
425
+
426
+ // ONE WALK PER FRAGMENT. A principled closure walks the lamps for its specular
427
+ // anyway, and every lamp's reach, falloff and cone are the same numbers the
428
+ // diffuse layer needs — so that walk computes both, against the normal the
429
+ // material prelude recorded here, and leaves the diffuse for the epilogue.
430
+ // Walking the grid twice was ~60% of a lit stage's frame.
431
+ var<private> _rzLampN: vec3f;
432
+ var<private> _rzLampDiffuse: vec3f;
433
+ var<private> _rzLampDiffuseSet: bool = false;
434
+ // Set by a closure that shaded the lamps' diffuse itself (a Unity-mode
435
+ // principled, which tints it by its own base) — the epilogue then adds none.
436
+ var<private> _rzLampDiffuseTaken: bool = false;
437
+
438
+ /** rzLightsDiffuse(p, _rzLampN), taken from a principled walk when one ran. */
439
+ fn rzLightsDiffuseOnce(p: vec3f, n: vec3f) -> vec3f {
440
+ if (_rzLampDiffuseTaken) { return vec3f(0.0); }
441
+ if (_rzLampDiffuseSet) { return _rzLampDiffuse; }
442
+ return rzLightsDiffuse(p, n);
443
+ }
444
+
445
+ fn _rzLightsIrradiance(p: vec3f, n: vec3f) -> vec3f {
446
+ var acc = vec3f(0.0);
447
+ let count = rzLightCount();
448
+ let docs = _rzLightDocCount();
449
+ // The document's lamps: only those the grid says can reach this cell.
450
+ if (docs > 0u) {
451
+ let m = _rzLightCellMask(p);
452
+ acc = acc + _rzLightWord(m.lo.x, 0u, p, n) + _rzLightWord(m.lo.y, 32u, p, n) +
453
+ _rzLightWord(m.lo.z, 64u, p, n) + _rzLightWord(m.lo.w, 96u, p, n) +
454
+ _rzLightWord(m.hi.x, 128u, p, n) + _rzLightWord(m.hi.y, 160u, p, n) +
455
+ _rzLightWord(m.hi.z, 192u, p, n) + _rzLightWord(m.hi.w, 224u, p, n);
456
+ }
457
+ // The effects' lamps, which move every frame on the GPU: all of them.
458
+ for (var i = docs; i < count; i = i + 1u) {
459
+ acc = acc + _rzLightOne(i, p, n);
460
+ }
461
+ return acc;
462
+ }
463
+ `
464
+ }