reze-engine 0.57.6 → 0.58.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 (37) hide show
  1. package/dist/engine.d.ts +31 -0
  2. package/dist/engine.d.ts.map +1 -1
  3. package/dist/engine.js +231 -82
  4. package/dist/shaders/directives.d.ts +18 -1
  5. package/dist/shaders/directives.d.ts.map +1 -1
  6. package/dist/shaders/directives.js +22 -3
  7. package/dist/shaders/lights.d.ts +2 -10
  8. package/dist/shaders/lights.d.ts.map +1 -1
  9. package/dist/shaders/lights.js +4 -24
  10. package/dist/shaders/materials/common.d.ts.map +1 -1
  11. package/dist/shaders/materials/common.js +3 -17
  12. package/dist/shaders/passes/composite.d.ts.map +1 -1
  13. package/dist/shaders/passes/composite.js +2 -0
  14. package/dist/shaders/passes/grid.d.ts.map +1 -1
  15. package/dist/shaders/passes/grid.js +2 -0
  16. package/dist/shaders/passes/particles.d.ts +20 -2
  17. package/dist/shaders/passes/particles.d.ts.map +1 -1
  18. package/dist/shaders/passes/particles.js +77 -5
  19. package/dist/shaders/passes/trails.d.ts +3 -0
  20. package/dist/shaders/passes/trails.d.ts.map +1 -1
  21. package/dist/shaders/passes/trails.js +7 -1
  22. package/dist/shaders/scene-light-api.d.ts +42 -0
  23. package/dist/shaders/scene-light-api.d.ts.map +1 -0
  24. package/dist/shaders/scene-light-api.js +144 -0
  25. package/dist/shaders/shadow-api.d.ts +29 -0
  26. package/dist/shaders/shadow-api.d.ts.map +1 -0
  27. package/dist/shaders/shadow-api.js +112 -0
  28. package/package.json +1 -1
  29. package/src/engine.ts +272 -96
  30. package/src/shaders/directives.ts +37 -4
  31. package/src/shaders/lights.ts +4 -24
  32. package/src/shaders/materials/common.ts +3 -17
  33. package/src/shaders/passes/composite.ts +2 -0
  34. package/src/shaders/passes/grid.ts +2 -0
  35. package/src/shaders/passes/particles.ts +93 -6
  36. package/src/shaders/passes/trails.ts +10 -1
  37. package/src/shaders/scene-light-api.ts +148 -0
@@ -38,6 +38,7 @@ import { midiApi } from "./midi-api"
38
38
  import { lyricsApi } from "./lyrics-api"
39
39
  import { clockApi, trailSlotsApi, viewportApi } from "./passes/hosted-api"
40
40
  import { idApi } from "./id-api"
41
+ import { sceneLightApi, worldAmbientWgsl } from "./scene-light-api"
41
42
 
42
43
  /** Floats before the first record. One is the count; the rest keep the records
43
44
  * vec4-aligned, which is what lets a future pass read them as vec4s. */
@@ -84,29 +85,8 @@ struct RzLight {
84
85
  }
85
86
  `
86
87
 
87
- /**
88
- * The world's light at a surface facing n — the flat colour, or the installed
89
- * HDRI's irradiance (sh[0].w = 1), evaluated from folded SH coefficients (see
90
- * ibl.ts for the folding; the shader is a plain polynomial in the normal).
91
- *
92
- * One string included by every module that declares LightUniforms with the sh
93
- * block — the hosted-api lesson: a helper defined in some modules and not
94
- * others is a compile error waiting for the first file that crosses them.
95
- */
96
- export const WORLD_AMBIENT_WGSL = /* wgsl */ `
97
- fn rzWorldAmbient(n: vec3f) -> vec3f {
98
- if (light.sh[0].w < 0.5) { return light.ambientColor.xyz; }
99
- let x = n.x;
100
- let y = n.y;
101
- let z = n.z;
102
- let c = light.sh[0].xyz
103
- + light.sh[1].xyz * y + light.sh[2].xyz * z + light.sh[3].xyz * x
104
- + light.sh[4].xyz * (x * y) + light.sh[5].xyz * (y * z)
105
- + light.sh[6].xyz * (3.0 * z * z - 1.0) + light.sh[7].xyz * (x * z)
106
- + light.sh[8].xyz * (x * x - y * y);
107
- return max(c, vec3f(0.0));
108
- }
109
- `
88
+ /** rzWorldAmbient against the materials' and the ground's `light` uniform. */
89
+ export const WORLD_AMBIENT_WGSL = worldAmbientWgsl("light")
110
90
 
111
91
  /** Does this source define the emit mount? */
112
92
  export function hasLightEmit(wgsl: string): boolean {
@@ -184,7 +164,7 @@ ${viewportApi("viewU[6].w")}
184
164
  ${trailSlotsApi(cast.trailCount)}
185
165
  // The id accessors, stubbed: this module cannot read an attachment the
186
166
  // scene pass writes. See id-api.ts — the author's whole file compiles here.
187
- ${idApi(false, 0, 0) + castDistanceStub()}
167
+ ${idApi(false, 0, 0) + castDistanceStub() + sceneLightApi(false, 0, 0)}
188
168
  // The dials the author declared, if any. A lamp is exactly the thing someone
189
169
  // retunes — its colour and its reach — so an emitter reads params like every
190
170
  // other mount rather than being the one place a #param resolves to nothing.
@@ -1,6 +1,7 @@
1
1
  import { sceneFsOutWgsl } from "../passes/scene-contract"
2
2
  import { lightsApi } from "../lights"
3
3
  import { SHADOW_CASCADES } from "../../shadow-cascades"
4
+ import { pcf9 } from "../scene-light-api"
4
5
  import { WORLD_AMBIENT_WGSL } from "../lights"
5
6
 
6
7
  // Shared WGSL blocks concatenated by every material shader.
@@ -175,8 +176,8 @@ fn safe_normal(nIn: vec3f) -> vec3f {
175
176
  `;
176
177
 
177
178
  // ─── Shadow sampler (3×3 PCF, cascade-selected) ─────────────────────
178
- // Normal-bias 0.08, depth-bias 0.001 NDC. Unrolled — Safari's Metal backend
179
- // doesn't unroll nested shadow loops reliably. Texel sizes are interpolated
179
+ // Normal-bias 0.08, depth-bias 0.001 NDC; the kernel is pcf9 in scene-light-api.ts,
180
+ // shared with effects. Texel sizes are interpolated
180
181
  // from SHADOW_CASCADES so they cannot go stale the way the hardcoded 1/2048
181
182
  // once did (PCF taps landed TWO texels apart after the map grew to 4096,
182
183
  // quantizing self-shadow edges into crawling gray squares).
@@ -188,21 +189,6 @@ fn safe_normal(nIn: vec3f) -> vec3f {
188
189
  // far plane the comparison failed against every stored depth, silently
189
190
  // shadowing any stage deeper than the box.
190
191
 
191
- const pcf9 = (map: string, ts: string) => /* wgsl */ `
192
- let suv = vec2f(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
193
- let cmpZ = ndc.z - 0.001;
194
- let ts = ${ts};
195
- let s00 = textureSampleCompareLevel(${map}, shadowSampler, suv + vec2f(-ts, -ts), cmpZ);
196
- let s10 = textureSampleCompareLevel(${map}, shadowSampler, suv + vec2f(0.0, -ts), cmpZ);
197
- let s20 = textureSampleCompareLevel(${map}, shadowSampler, suv + vec2f( ts, -ts), cmpZ);
198
- let s01 = textureSampleCompareLevel(${map}, shadowSampler, suv + vec2f(-ts, 0.0), cmpZ);
199
- let s11 = textureSampleCompareLevel(${map}, shadowSampler, suv, cmpZ);
200
- let s21 = textureSampleCompareLevel(${map}, shadowSampler, suv + vec2f( ts, 0.0), cmpZ);
201
- let s02 = textureSampleCompareLevel(${map}, shadowSampler, suv + vec2f(-ts, ts), cmpZ);
202
- let s12 = textureSampleCompareLevel(${map}, shadowSampler, suv + vec2f(0.0, ts), cmpZ);
203
- let s22 = textureSampleCompareLevel(${map}, shadowSampler, suv + vec2f( ts, ts), cmpZ);
204
- return (s00 + s10 + s20 + s01 + s11 + s21 + s02 + s12 + s22) * (1.0 / 9.0);
205
- `
206
192
 
207
193
  const SAMPLE_SHADOW_WGSL = /* wgsl */ `
208
194
 
@@ -6,6 +6,7 @@ import { clockApi, EFFECT_MATH_API, PARTICLE_STRUCT_WGSL, trailSlotsApi, viewpor
6
6
  import { EFFECT_ANCHORS, EFFECT_SUBJECTS, EFFECT_TRAIL_BASE, EFFECT_TRAIL_SAMPLES } from "../cast-layout"
7
7
  import { audioApi } from "../audio-api"
8
8
  import { idApi } from "../id-api"
9
+ import { sceneLightApi } from "../scene-light-api"
9
10
  import { castDistanceApi, CAST_FIELD_DIV } from "./cast-distance"
10
11
  import { lyricsApi, lyricsTextApi } from "../lyrics-api"
11
12
  import { midiApi } from "../midi-api"
@@ -722,6 +723,7 @@ export function buildFieldShader(effect: CompositeEffectSource): string {
722
723
  viewportApi("viewU[6].w") +
723
724
  trailSlotsApi(effect.trailCount) +
724
725
  idApi(effect.ids === true, 0, 23) +
726
+ sceneLightApi(false, 0, 0) +
725
727
  // Distance to the cast, in SCREEN pixels: the field is half-res, so a field
726
728
  // texel is CAST_FIELD_DIV of them and the accessor scales on the way out.
727
729
  // An author writes the width they mean and never learns how it is built.
@@ -8,6 +8,7 @@ import { SCENE_TAP_STUB } from "../scene-tap"
8
8
  import { type CastLayout } from "./particles"
9
9
  import { clockApi, trailSlotsApi, viewportApi } from "./hosted-api"
10
10
  import { idApi } from "../id-api"
11
+ import { sceneLightApi } from "../scene-light-api"
11
12
 
12
13
  // A persistent grid an effect can step and read: the one thing an effect could
13
14
  // not have before, which is MEMORY.
@@ -168,6 +169,7 @@ fn rzGrid(uv: vec2f) -> vec4f { return rzGridPrev(uv); }
168
169
  // Stubbed: this module cannot read an attachment the scene pass writes — see
169
170
  // id-api.ts. The author's whole file compiles here, so the names must exist.
170
171
  idApi(false, 0, 0) +
172
+ sceneLightApi(false, 0, 0) +
171
173
  // Inlined rather than imported from cast-distance, and NOT for tidiness.
172
174
  //
173
175
  // This module and composite.ts import each other — grid takes
@@ -1,4 +1,5 @@
1
1
  import { castDistanceStub } from "./cast-distance"
2
+ import { gridReadApi } from "./grid"
2
3
  import { RZ_LIGHT_STRUCT_WGSL } from "../lights"
3
4
  import { audioApi } from "../audio-api"
4
5
  import { lyricsApi } from "../lyrics-api"
@@ -9,6 +10,7 @@ import { EFFECT_SUBJECT_VEC4S } from "../cast-layout"
9
10
  import { clockApi, EFFECT_MATH_API, PARTICLE_STRUCT_WGSL, trailSlotsApi, viewportApi } from "./hosted-api"
10
11
  import { sceneIdFieldWgsl, sceneIdPadWgsl } from "./scene-contract"
11
12
  import { idApi } from "../id-api"
13
+ import { sceneLightApi } from "../scene-light-api"
12
14
  // GPU particles for user effects: a compute step and an instanced quad draw.
13
15
  //
14
16
  // Its own shader MODULE rather than more source spliced into composite.ts, for
@@ -75,7 +77,9 @@ fn _rzCastLive() -> i32 {
75
77
  }
76
78
 
77
79
  /** How the author's quads combine with the scene. */
78
- type ParticleBlend = "alpha" | "additive"
80
+ /** `cutout` shades exactly like `alpha`; the difference is entirely in the
81
+ * pipeline — depth write on, alpha-to-coverage on. See directives.ts. */
82
+ type ParticleBlend = "alpha" | "additive" | "cutout"
79
83
 
80
84
  type ParticleSource = {
81
85
  /** The author's WGSL verbatim. */
@@ -89,8 +93,23 @@ type ParticleSource = {
89
93
  * rule reads the same dial the shading does, and rain's fall speed is used
90
94
  * in the compute half. */
91
95
  paramsDecl: string
96
+ /** The effect defines `particleCover(p, uv) -> f32`: the shape alone, cheap.
97
+ * A cutout then draws TWICE — coverage to depth first, then the shading at
98
+ * depthCompare equal — so the lighting runs only for the visible layer. A
99
+ * discard forbids the hardware's own early rejection on a tile-based GPU,
100
+ * where every stacked fragment is otherwise shaded in full; the prepass is
101
+ * the shape of that rejection, done by hand and cheaply. */
102
+ cover: boolean
103
+ /** The side of this effect's grid, or 0 when it declared none. Particles READ
104
+ * the grid; the kernel that steps it lives in the grid pass. A blade of grass
105
+ * bending where a foot pressed is the whole case: the bend has to persist and
106
+ * recover, which is memory, and memory is what the grid is for. */
107
+ gridSize: number
92
108
  }
93
109
 
110
+ /** Where the shading stage's five scene-light bindings start — see scene-light-api.ts. */
111
+ export const PARTICLE_LIGHT_BINDING = 10
112
+
94
113
  /** Bytes per particle. Explicitly padded — see the struct below. */
95
114
  export const PARTICLE_STRIDE = 48
96
115
 
@@ -148,12 +167,14 @@ fn rzProject(p: vec3f) -> vec3f {
148
167
  fn rzCamPos() -> vec3f { return cam.camPos; }
149
168
  `
150
169
 
151
- /** Does the source define the particle contract? All three are required together. */
152
- export function particleEntryPoints(wgsl: string): { init: boolean; step: boolean; shade: boolean } {
170
+ /** Does the source define the particle contract? All three are required
171
+ * together; `cover` is optional and only means anything to a cutout. */
172
+ export function particleEntryPoints(wgsl: string): { init: boolean; step: boolean; shade: boolean; cover: boolean } {
153
173
  return {
154
174
  init: /\bfn\s+particleInit\s*\(/.test(wgsl),
155
175
  step: /\bfn\s+particleStep\s*\(/.test(wgsl),
156
176
  shade: /\bfn\s+particleShade\s*\(/.test(wgsl),
177
+ cover: /\bfn\s+particleCover\s*\(/.test(wgsl),
157
178
  }
158
179
  }
159
180
 
@@ -182,10 +203,15 @@ ${src.paramsDecl}
182
203
  audioApi(0, 4) +
183
204
  midiApi(0, 5) +
184
205
  lyricsApi(0, 6) +
206
+ // Always, even with no grid — then it samples a 1x1 of zeroes. Same reason
207
+ // composite compiles it unconditionally, plus one this module owns: an
208
+ // effect's whole file is spliced here, so a `gridStep` written for the grid
209
+ // pass has to RESOLVE here too, and the stubs that let it are in here.
210
+ gridReadApi(0, 8, 9, src.gridSize) +
185
211
  PRELUDE +
186
212
  // Stubbed: this module cannot read an attachment the scene pass writes — see
187
213
  // id-api.ts. The author's whole file compiles here, so the names must exist.
188
- idApi(false, 0, 0) + castDistanceStub() +
214
+ idApi(false, 0, 0) + castDistanceStub() + sceneLightApi(false, 0, 0) +
189
215
  "\n// ── user effect ──\n" +
190
216
  src.wgsl +
191
217
  /* wgsl */ `
@@ -230,7 +256,13 @@ fn main(@builtin(global_invocation_id) gid: vec3u) {
230
256
  export function buildParticleRenderShader(src: ParticleSource, cast: CastLayout): string {
231
257
  return (
232
258
  `override BLOOM: bool = ${src.bloom ? "true" : "false"};
233
- override ADDITIVE: bool = ${src.blend === "additive" ? "true" : "false"};\n` +
259
+ override ADDITIVE: bool = ${src.blend === "additive" ? "true" : "false"};
260
+ override CUTOUT: bool = ${src.blend === "cutout" ? "true" : "false"};
261
+ // A cutout with a prepass: the depth is already written and this pass draws at
262
+ // depthCompare equal, so it neither discards nor masks — the depth test IS the
263
+ // coverage, per sample, and a shader with no discard keeps the hardware's
264
+ // early rejection.
265
+ override PREPASSED: bool = ${src.blend === "cutout" && src.cover ? "true" : "false"};\n` +
234
266
  PARTICLE_STRUCT_WGSL +
235
267
  CAMERA_STRUCT +
236
268
  PARTICLE_UNIFORMS +
@@ -245,10 +277,18 @@ ${src.paramsDecl}
245
277
  audioApi(0, 4) +
246
278
  midiApi(0, 5) +
247
279
  lyricsApi(0, 6) +
280
+ // Always, even with no grid — then it samples a 1x1 of zeroes. Same reason
281
+ // composite compiles it unconditionally, plus one this module owns: an
282
+ // effect's whole file is spliced here, so a `gridStep` written for the grid
283
+ // pass has to RESOLVE here too, and the stubs that let it are in here.
284
+ gridReadApi(0, 8, 9, src.gridSize) +
248
285
  PRELUDE +
249
286
  // Stubbed: this module cannot read an attachment the scene pass writes — see
250
287
  // id-api.ts. The author's whole file compiles here, so the names must exist.
251
288
  idApi(false, 0, 0) + castDistanceStub() +
289
+ // The sun's shadow and the world's ambient, for real: the shadow pass has
290
+ // run by the time this draws, inside the scene pass. See scene-light-api.ts.
291
+ sceneLightApi(true, 0, PARTICLE_LIGHT_BINDING) +
252
292
  "\n// ── user effect ──\n" +
253
293
  src.wgsl +
254
294
  /* wgsl */ `
@@ -301,6 +341,12 @@ fn vs(@builtin(vertex_index) vi: u32, @builtin(instance_index) ii: u32) -> VSOut
301
341
 
302
342
  struct FSOut {
303
343
  @location(0) color: vec4f,
344
+ // Which of the pixel's MSAA samples this fragment covers. Alpha-to-coverage
345
+ // by hand: the hardware kind reads alpha off the first colour target, and
346
+ // the HDR target is rg11b10, which has no alpha to read. Every particle
347
+ // writes it; a blended one writes all four, which is what not declaring it
348
+ // would have meant.
349
+ @builtin(sample_mask) samples: u32,
304
350
  // The scene's aux target: (bloom mask, coverage). Materials write it, so a
305
351
  // particle that skipped it would punch a hole in the mask of whatever it drew
306
352
  // over.
@@ -328,8 +374,25 @@ fn fs(in: VSOut) -> FSOut {
328
374
  c.a *= pu.weight;
329
375
  // Weight 0 therefore discards every fragment, so an effect faded out costs
330
376
  // nothing past the vertex stage even on the frame the draw is still issued.
331
- if (c.a <= 0.0) { discard; }
377
+ // Not after a prepass: that pass already decided coverage into depth, and
378
+ // a discard here would cost this pass the early rejection it exists for.
379
+ if (!PREPASSED && c.a <= 0.0) { discard; }
332
380
  var out: FSOut;
381
+ out.samples = 0xFu;
382
+ if (PREPASSED) {
383
+ c.a = 1.0;
384
+ } else if (CUTOUT) {
385
+ // Alpha becomes coverage: 0..4 of the pixel's samples, and the fragment is
386
+ // then opaque wherever it lands. The pattern is turned by pixel position
387
+ // so a half-covered edge is not the same two samples on every pixel along
388
+ // it, which is what makes a2c edges look dithered rather than stepped.
389
+ let n = u32(clamp(c.a, 0.0, 1.0) * 4.0 + 0.5);
390
+ if (n == 0u) { discard; }
391
+ let low = (1u << n) - 1u;
392
+ let turn = (u32(in.clip.x) + u32(in.clip.y)) & 3u;
393
+ out.samples = ((low << turn) | (low >> (4u - turn))) & 0xFu;
394
+ c.a = 1.0;
395
+ }
333
396
  // PREMULTIPLIED: the scene's colour target blends with srcFactor \"one\", so a
334
397
  // straight-alpha fragment would come out over-bright wherever it is
335
398
  // translucent — which is most of a soft particle.
@@ -350,6 +413,30 @@ fn fs(in: VSOut) -> FSOut {
350
413
  out.mask = vec4f(mg.x, mg.y, 0.0, c.a);
351
414
  ${sceneIdPadWgsl("out")} return out;
352
415
  }
416
+ ` +
417
+ (src.blend === "cutout" && src.cover
418
+ ? /* wgsl */ `
419
+ /**
420
+ * The prepass: the shape to depth, nothing to colour. Every output is still
421
+ * declared — the pipeline takes the colour targets at writeMask 0 — and the
422
+ * sample mask is exactly what the single-pass cutout would have written, so
423
+ * the shading pass's equal test lands on the same samples.
424
+ */
425
+ @fragment
426
+ fn fsDepth(in: VSOut) -> FSOut {
427
+ let p = particles[in.id];
428
+ let a = particleCover(p, in.uv) * pu.weight;
429
+ let n = u32(clamp(a, 0.0, 1.0) * 4.0 + 0.5);
430
+ if (n == 0u) { discard; }
431
+ let low = (1u << n) - 1u;
432
+ let turn = (u32(in.clip.x) + u32(in.clip.y)) & 3u;
433
+ var out: FSOut;
434
+ out.samples = ((low << turn) | (low >> (4u - turn))) & 0xFu;
435
+ out.color = vec4f(0.0);
436
+ out.mask = vec4f(0.0);
437
+ ${sceneIdPadWgsl("out")} return out;
438
+ }
353
439
  `
440
+ : "")
354
441
  )
355
442
  }
@@ -1,4 +1,5 @@
1
1
  import { castDistanceStub } from "./cast-distance"
2
+ import { gridReadApi } from "./grid"
2
3
  import { RZ_LIGHT_STRUCT_WGSL } from "../lights"
3
4
  import { audioApi } from "../audio-api"
4
5
  import { lyricsApi } from "../lyrics-api"
@@ -9,6 +10,7 @@ import { EFFECT_SUBJECT_VEC4S } from "../cast-layout"
9
10
  import { clockApi, EFFECT_MATH_API, PARTICLE_STRUCT_WGSL, trailSlotsApi, viewportApi } from "./hosted-api"
10
11
  import { sceneIdFieldWgsl, sceneIdPadWgsl } from "./scene-contract"
11
12
  import { idApi } from "../id-api"
13
+ import { sceneLightApi } from "../scene-light-api"
12
14
  // Ribbons along a bone's recorded path, drawn as geometry.
13
15
  //
14
16
  // This is the effect the whole geometry path was built for. As a fullscreen
@@ -39,6 +41,9 @@ type TrailSource = {
39
41
  /** Additive, like most glowing ribbons, or straight alpha. */
40
42
  blend: "alpha" | "additive"
41
43
  bloom: boolean
44
+ /** The side of this effect's grid, or 0 when it declared none. See the note
45
+ * on the same field in particles.ts: a ribbon reads the grid, never steps it. */
46
+ gridSize: number
42
47
  }
43
48
 
44
49
  /**
@@ -291,9 +296,13 @@ fn rzTurnRadius(a: vec3f, b: vec3f, c: vec3f) -> f32 {
291
296
  audioApi(0, 4) +
292
297
  midiApi(0, 5) +
293
298
  lyricsApi(0, 6) +
299
+ // Always, with or without a grid — and required either way, because an
300
+ // effect's whole file is spliced here and a `gridStep` written for the grid
301
+ // pass has to resolve in this module too.
302
+ gridReadApi(0, 8, 9, src.gridSize) +
294
303
  // Stubbed: this module cannot read an attachment the scene pass writes — see
295
304
  // id-api.ts. The author's whole file compiles here, so the names must exist.
296
- idApi(false, 0, 0) + castDistanceStub() +
305
+ idApi(false, 0, 0) + castDistanceStub() + sceneLightApi(false, 0, 0) +
297
306
  "\n// ── user effect ──\n" +
298
307
  src.wgsl +
299
308
  /* wgsl */ `
@@ -0,0 +1,148 @@
1
+ import { SHADOW_CASCADES } from "../shadow-cascades"
2
+ // The scene's own light, as an effect author sees it: the sun's shadow and the
3
+ // world's ambient.
4
+ //
5
+ // A lawn standing in a character's shadow has to darken where the floor under
6
+ // it does, or the figure floats: the ground catcher draws her shadow, and the
7
+ // blades drawn over it hide it. And the shade has to be the SCENE'S shade — her
8
+ // shaded side takes the world's colour, and a lawn shaded by its own guess
9
+ // reads as a patch of different flowers beside her. Both are already computed
10
+ // for the materials every frame; this hands the same lookups to effects.
11
+ //
12
+ // Bound for real in the particle SHADING stage only. That is where geometry an
13
+ // effect draws inside the scene pass is shaded, and the shadow pass has already
14
+ // run by then. Every other module that compiles the author's file gets stubs:
15
+ // rzShadow answers 1, lit, which is what a point with no occlusion information
16
+ // is — the same answer sampleShadow gives outside every cascade — and
17
+ // rzWorldAmbient answers black, no light.
18
+
19
+ /**
20
+ * The world's light at a surface facing n — the flat colour, or the installed
21
+ * HDRI's irradiance (sh[0].w = 1), evaluated from folded SH coefficients (see
22
+ * ibl.ts for the folding; the shader is a plain polynomial in the normal).
23
+ *
24
+ * Written against whichever name the module gives the light uniform, so the
25
+ * materials, the ground and an effect all evaluate one polynomial.
26
+ */
27
+ export const worldAmbientWgsl = (u: string) => /* wgsl */ `
28
+ fn rzWorldAmbient(n: vec3f) -> vec3f {
29
+ if (${u}.sh[0].w < 0.5) { return ${u}.ambientColor.xyz; }
30
+ let x = n.x;
31
+ let y = n.y;
32
+ let z = n.z;
33
+ let c = ${u}.sh[0].xyz
34
+ + ${u}.sh[1].xyz * y + ${u}.sh[2].xyz * z + ${u}.sh[3].xyz * x
35
+ + ${u}.sh[4].xyz * (x * y) + ${u}.sh[5].xyz * (y * z)
36
+ + ${u}.sh[6].xyz * (3.0 * z * z - 1.0) + ${u}.sh[7].xyz * (x * z)
37
+ + ${u}.sh[8].xyz * (x * x - y * y);
38
+ return max(c, vec3f(0.0));
39
+ }
40
+ `
41
+
42
+ /**
43
+ * 3×3 PCF over one cascade's map, for a point already in that cascade's NDC.
44
+ *
45
+ * Shared with the materials, so an effect's shadow and her own shadow soften
46
+ * alike. The sampler is linear-filtered, which makes each tap a 2×2 bilinear
47
+ * compare. Unrolled — Safari's Metal backend doesn't unroll nested shadow
48
+ * loops reliably. The body expects `ndc` in scope and returns.
49
+ */
50
+ export const pcf9 = (map: string, ts: string, sampler = "shadowSampler") => /* wgsl */ `
51
+ let suv = vec2f(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
52
+ let cmpZ = ndc.z - 0.001;
53
+ let ts = ${ts};
54
+ let s00 = textureSampleCompareLevel(${map}, ${sampler}, suv + vec2f(-ts, -ts), cmpZ);
55
+ let s10 = textureSampleCompareLevel(${map}, ${sampler}, suv + vec2f(0.0, -ts), cmpZ);
56
+ let s20 = textureSampleCompareLevel(${map}, ${sampler}, suv + vec2f( ts, -ts), cmpZ);
57
+ let s01 = textureSampleCompareLevel(${map}, ${sampler}, suv + vec2f(-ts, 0.0), cmpZ);
58
+ let s11 = textureSampleCompareLevel(${map}, ${sampler}, suv, cmpZ);
59
+ let s21 = textureSampleCompareLevel(${map}, ${sampler}, suv + vec2f( ts, 0.0), cmpZ);
60
+ let s02 = textureSampleCompareLevel(${map}, ${sampler}, suv + vec2f(-ts, ts), cmpZ);
61
+ let s12 = textureSampleCompareLevel(${map}, ${sampler}, suv + vec2f(0.0, ts), cmpZ);
62
+ let s22 = textureSampleCompareLevel(${map}, ${sampler}, suv + vec2f( ts, ts), cmpZ);
63
+ return (s00 + s10 + s20 + s01 + s11 + s21 + s02 + s12 + s22) * (1.0 / 9.0);
64
+ `
65
+
66
+ /** Bindings the real accessors take, from `binding` upward. */
67
+ export const SCENE_LIGHT_API_BINDINGS = 5
68
+
69
+ /**
70
+ * `rzShadow(p)`: how much of the sun reaches world point `p` — 1 lit, 0 in a
71
+ * caster's shadow, soft in between.
72
+ *
73
+ * `rzWorldAmbient(n)`: the world's light arriving at a surface facing `n` —
74
+ * the materials' own ambient, so an effect's shade can match hers.
75
+ *
76
+ * With `on`, declares five bindings from `binding`:
77
+ * +0 the light uniform (ambient, the sun — direction, and in .w the shadow
78
+ * switch — and the HDRI's irradiance)
79
+ * +1 the cascade view-projections, then the caster sphere
80
+ * +2 the near cascade's map +3 the far cascade's map
81
+ * +4 the comparison sampler
82
+ *
83
+ * It takes a POINT and no normal. The materials' sampleShadow also rejects a
84
+ * surface facing away from the sun, which is right for skin and wrong for a
85
+ * blade of grass or a petal: those are thin, lit through, and shaded by their
86
+ * own N·L. The point is nudged toward the sun instead, which lifts a blade's
87
+ * root off a stage floor that casts into the same map.
88
+ */
89
+ export function sceneLightApi(on: boolean, group: number, binding: number): string {
90
+ if (!on) {
91
+ return /* wgsl */ `
92
+ fn rzShadow(p: vec3f) -> f32 { return 1.0; }
93
+ fn rzWorldAmbient(n: vec3f) -> vec3f { return vec3f(0.0); }
94
+ `
95
+ }
96
+ const n = SHADOW_CASCADES.length
97
+ return /* wgsl */ `
98
+ // The light uniform, as the materials lay it out; lights[0] is the sun.
99
+ struct _RzSceneLight { direction: vec4f, color: vec4f, }
100
+ struct _RzSceneLightU { ambientColor: vec4f, lights: array<_RzSceneLight, 4>, sh: array<vec4f, 9>, }
101
+ // The cascades' matrices, inner to outer, then every caster in one sphere
102
+ // (x, y, z, radius): radius 0 = nothing casts, negative = do not test.
103
+ struct _RzShadowBlock { viewProj: array<mat4x4f, ${n}>, casters: vec4f, }
104
+ @group(${group}) @binding(${binding}) var<uniform> _rzLight: _RzSceneLightU;
105
+ @group(${group}) @binding(${binding + 1}) var<uniform> _rzShadowVP: _RzShadowBlock;
106
+ @group(${group}) @binding(${binding + 2}) var _rzShadowNear: texture_depth_2d;
107
+ @group(${group}) @binding(${binding + 3}) var _rzShadowFar: texture_depth_2d;
108
+ @group(${group}) @binding(${binding + 4}) var _rzShadowCmp: sampler_comparison;
109
+
110
+ fn _rzShadowNearTaps(ndc: vec3f) -> f32 {
111
+ ${pcf9("_rzShadowNear", `1.0 / ${SHADOW_CASCADES[0].mapSize}.0`, "_rzShadowCmp")}
112
+ }
113
+
114
+ fn _rzShadowFarTaps(ndc: vec3f) -> f32 {
115
+ ${pcf9("_rzShadowFar", `1.0 / ${SHADOW_CASCADES[n - 1].mapSize}.0`, "_rzShadowCmp")}
116
+ }
117
+
118
+ fn rzShadow(p: vec3f) -> f32 {
119
+ // The scene's one shadow switch, on the sun — see sampleShadow.
120
+ let castAmt = _rzLight.lights[0].direction.w;
121
+ if (castAmt <= 0.0) { return 1.0; }
122
+ let toSun = normalize(-_rzLight.lights[0].direction.xyz);
123
+ // Can anything cast onto this point at all? The ground's test, for the same
124
+ // two reasons: most of a lawn is nowhere near her, and a scene with no
125
+ // models never renders the map, whose untouched zeroes compare as shadowed.
126
+ let cs = _rzShadowVP.casters;
127
+ if (cs.w == 0.0) { return 1.0; }
128
+ if (cs.w > 0.0) {
129
+ let toCaster = cs.xyz - p;
130
+ let along = dot(toCaster, toSun);
131
+ let perp = length(toCaster - toSun * along);
132
+ if (along <= -cs.w || perp > cs.w) { return 1.0; }
133
+ }
134
+ let q = p + toSun * 0.08;
135
+ let c0 = _rzShadowVP.viewProj[0] * vec4f(q, 1.0);
136
+ let n0 = c0.xyz / max(c0.w, 1e-6);
137
+ if (all(abs(n0.xy) < vec2f(0.98)) && n0.z > 0.0 && n0.z < 1.0) {
138
+ return mix(1.0, _rzShadowNearTaps(n0), castAmt);
139
+ }
140
+ let c1 = _rzShadowVP.viewProj[${n - 1}] * vec4f(q, 1.0);
141
+ let n1 = c1.xyz / max(c1.w, 1e-6);
142
+ if (all(abs(n1.xy) < vec2f(0.98)) && n1.z > 0.0 && n1.z < 1.0) {
143
+ return mix(1.0, _rzShadowFarTaps(n1), castAmt);
144
+ }
145
+ return 1.0;
146
+ }
147
+ ${worldAmbientWgsl("_rzLight")}`
148
+ }