reze-engine 0.62.1 → 0.64.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/dist/engine.d.ts +95 -57
  2. package/dist/engine.d.ts.map +1 -1
  3. package/dist/engine.js +466 -162
  4. package/dist/graph/compile.d.ts.map +1 -1
  5. package/dist/graph/compile.js +4 -1
  6. package/dist/graph/registry.d.ts +3 -0
  7. package/dist/graph/registry.d.ts.map +1 -1
  8. package/dist/graph/registry.js +133 -0
  9. package/dist/graph/schema.d.ts +12 -0
  10. package/dist/graph/schema.d.ts.map +1 -1
  11. package/dist/graph/slots.d.ts.map +1 -1
  12. package/dist/graph/slots.js +11 -20
  13. package/dist/index.d.ts +9 -1
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +1 -1
  16. package/dist/shaders/lights.d.ts +3 -2
  17. package/dist/shaders/lights.d.ts.map +1 -1
  18. package/dist/shaders/lights.js +16 -3
  19. package/dist/shaders/materials/common.d.ts.map +1 -1
  20. package/dist/shaders/materials/common.js +63 -56
  21. package/dist/shaders/materials/nodes.d.ts +1 -1
  22. package/dist/shaders/materials/nodes.d.ts.map +1 -1
  23. package/dist/shaders/materials/nodes.js +210 -0
  24. package/dist/shaders/passes/composite.d.ts.map +1 -1
  25. package/dist/shaders/passes/composite.js +13 -1
  26. package/dist/shaders/passes/cull.d.ts +1 -1
  27. package/dist/shaders/passes/cull.d.ts.map +1 -1
  28. package/dist/shaders/passes/cull.js +7 -3
  29. package/dist/shaders/passes/ground.d.ts.map +1 -1
  30. package/dist/shaders/passes/ground.js +32 -55
  31. package/dist/shaders/passes/particles.d.ts +11 -1
  32. package/dist/shaders/passes/particles.d.ts.map +1 -1
  33. package/dist/shaders/passes/particles.js +9 -4
  34. package/dist/shaders/scene-light-api.d.ts +18 -10
  35. package/dist/shaders/scene-light-api.d.ts.map +1 -1
  36. package/dist/shaders/scene-light-api.js +58 -46
  37. package/dist/shadow-cascades.d.ts +47 -20
  38. package/dist/shadow-cascades.d.ts.map +1 -1
  39. package/dist/shadow-cascades.js +36 -13
  40. package/dist/unity/character.d.ts +59 -0
  41. package/dist/unity/character.d.ts.map +1 -0
  42. package/dist/unity/character.js +120 -0
  43. package/dist/unity/globals.d.ts +93 -0
  44. package/dist/unity/globals.d.ts.map +1 -0
  45. package/dist/unity/globals.js +340 -0
  46. package/dist/unity/host.d.ts +137 -0
  47. package/dist/unity/host.d.ts.map +1 -0
  48. package/dist/unity/host.js +489 -0
  49. package/dist/unity/looks.d.ts +88 -0
  50. package/dist/unity/looks.d.ts.map +1 -0
  51. package/dist/unity/looks.js +295 -0
  52. package/dist/unity/skin.d.ts +9 -0
  53. package/dist/unity/skin.d.ts.map +1 -0
  54. package/dist/unity/skin.js +117 -0
  55. package/dist/unity/stage.d.ts +148 -0
  56. package/dist/unity/stage.d.ts.map +1 -0
  57. package/dist/unity/stage.js +375 -0
  58. package/dist/unity/state.d.ts +22 -0
  59. package/dist/unity/state.d.ts.map +1 -0
  60. package/dist/unity/state.js +170 -0
  61. package/package.json +1 -1
  62. package/src/engine.ts +504 -176
  63. package/src/graph/compile.ts +3 -1
  64. package/src/graph/registry.ts +142 -0
  65. package/src/graph/schema.ts +9 -0
  66. package/src/graph/slots.ts +11 -20
  67. package/src/index.ts +11 -0
  68. package/src/shaders/lights.ts +16 -3
  69. package/src/shaders/materials/common.ts +63 -57
  70. package/src/shaders/materials/nodes.ts +210 -0
  71. package/src/shaders/passes/composite.ts +13 -1
  72. package/src/shaders/passes/cull.ts +7 -3
  73. package/src/shaders/passes/ground.ts +32 -55
  74. package/src/shaders/passes/particles.ts +19 -5
  75. package/src/shaders/scene-light-api.ts +59 -46
  76. package/src/shadow-cascades.ts +61 -32
  77. package/src/unity/character.ts +169 -0
  78. package/src/unity/globals.ts +443 -0
  79. package/src/unity/host.ts +587 -0
  80. package/src/unity/looks.ts +372 -0
  81. package/src/unity/skin.ts +128 -0
  82. package/src/unity/stage.ts +568 -0
  83. package/src/unity/state.ts +172 -0
@@ -344,9 +344,11 @@ export function compileGraph(graph: ShaderGraph, opts: CompileOptions = {}): Com
344
344
  }
345
345
 
346
346
  const lines: string[] = []
347
+ let nodeTakesLight = false
347
348
  for (const id of order) {
348
349
  const node = nodes.get(id)!
349
350
  const s = spec(node)
351
+ if (s.takesLight) nodeTakesLight = true
350
352
  if (s.contextOutputs) continue
351
353
  const args: Record<string, Resolved> = {}
352
354
  for (const [socket, input] of Object.entries(s.inputs)) args[socket] = resolveInput(node, socket, input.type)
@@ -360,7 +362,7 @@ export function compileGraph(graph: ShaderGraph, opts: CompileOptions = {}): Com
360
362
  const fsBody = lines.join("\n")
361
363
  // A graph takes light when its body reads any: the four shading nodes all
362
364
  // pass `sun, amb`, and a hand-written node reaching for the lamps names them.
363
- const takesLight = /\bsun\b|\bamb\b|rzLight|rzLamp/.test(fsBody)
365
+ const takesLight = nodeTakesLight || /\bsun\b|\bamb\b|rzLight|rzLamp/.test(fsBody)
364
366
  const wgsl = assembleModule(opts.renderClass ?? "auto", opts.alphaMode ?? "opaque", fsBody, usesStyle.current, !!opacity, opts.blend ?? "over", takesLight)
365
367
  return { ok: true, wgsl, fsBody, slotMap, diagnostics, prunedNodes }
366
368
  }
@@ -26,6 +26,9 @@ export type NodeSpec = {
26
26
  contextOutputs?: Record<string, string>
27
27
  /** Swizzle applied to the node's variable per output socket (default: none). */
28
28
  outputSelect?: Record<string, string>
29
+ /** The node shades by the scene's light without naming it: its graph takes
30
+ * the lamps and the fog as the four shading nodes' graphs do. */
31
+ takesLight?: boolean
29
32
  }
30
33
 
31
34
  // ─── Literal formatting ───────────────────────────────────────────────
@@ -125,6 +128,54 @@ function foldSpecular(ior: string, level: string): string {
125
128
  return fmtFloat(Number(((r * r * 2 * Math.max(l, 0)) / 0.08).toPrecision(9)))
126
129
  }
127
130
 
131
+ const uberNode = (slot: number): NodeSpec => ({
132
+ inputs: {
133
+ base: C([1, 1, 1], true),
134
+ metallic: F(0),
135
+ roughness: F(0.6),
136
+ occlusion: F(1),
137
+ normal: { type: "vector", contextDefault: "n" },
138
+ direction: { type: "vector", requiresLink: true },
139
+ // Unity's v into the ramp: the property map's alpha, which picks a
140
+ // surface's band (skin 0.702, hair and pale cloth 0.506).
141
+ row: F(0.702),
142
+ receive_shadow: F(1),
143
+ rim_mask: F(1),
144
+ rim_mid: F(0.448),
145
+ rim_width: F(0.11),
146
+ rim_tint: C([0.9623, 0.9214, 0.9214]),
147
+ rim_intensity: F(1),
148
+ rim_albedo: F(0.9),
149
+ rim_in_light: F(1),
150
+ emission: C([0, 0, 0]),
151
+ reflection: F(1),
152
+ // A face's SDF shade (ag_face_sdf); left at -1, the material reads N·L.
153
+ shade: F(-1),
154
+ // 1: the game's FACE_MODE — no GGX, the environment at a flat 0.0157.
155
+ face: F(0),
156
+ },
157
+ outputs: { color: "color" },
158
+ emit: (a) =>
159
+ `ag_uber(${slot}u, ${a.base}, ${a.metallic}, ${a.roughness}, ${a.occlusion}, ${a.normal}, ${a.direction}, ${a.row}, ${a.receive_shadow}, ` +
160
+ `${a.rim_mask}, ${a.rim_mid}, ${a.rim_width}, ${a.rim_tint}, ${a.rim_intensity}, ${a.rim_albedo}, ${a.rim_in_light}, ${a.emission}, ${a.reflection}, ${a.shade}, ${a.face}, input.worldPos, v)`,
161
+ takesLight: true,
162
+ })
163
+ const UBER_NODES: Record<string, NodeSpec> = Object.fromEntries([0, 1, 2, 3].map((s) => [`ag_uber/${s}`, uberNode(s)]))
164
+ // The face's SDF shade, the image on the slot the type names — 104903's face
165
+ // template (SDF_04_shaonv) by default in the AG pack.
166
+ const faceSdfNode = (slot: number): NodeSpec => ({
167
+ inputs: {
168
+ uv: { type: "vector", contextDefault: "vec3f(input.uv, 0.0)" },
169
+ direction: { type: "vector", requiresLink: true },
170
+ normal: { type: "vector", contextDefault: "n" },
171
+ smoothness: F(0.1),
172
+ invert: F(0),
173
+ },
174
+ outputs: { value: "float" },
175
+ emit: (a) => `ag_face_sdf(${slot}u, ${a.uv}.xy, ${a.direction}, ${a.normal}, ${a.smoothness}, ${a.invert})`,
176
+ })
177
+ const FACE_SDF_NODES: Record<string, NodeSpec> = Object.fromEntries([0, 1, 2, 3].map((s) => [`ag_face_sdf/${s}`, faceSdfNode(s)]))
178
+
128
179
  export const NODE_REGISTRY: Record<string, NodeSpec> = {
129
180
  // ── Context inputs (template locals; no emission) ──
130
181
  texture: {
@@ -454,6 +505,97 @@ export const NODE_REGISTRY: Record<string, NodeSpec> = {
454
505
  emit: (a) => `group_tex3(${a.uv}.xy)`,
455
506
  },
456
507
 
508
+ // ── Aether Gazer's character shading ──
509
+ // The game's character shader (SimPipeline/Character/Debug, which most of its
510
+ // characters wear), taken apart into the steps it runs — see ag_* in
511
+ // nodes.ts for each step's derivation from the decompiled pass. A graph
512
+ // composes them as the game did: a ramp lookup driven by the key light, a
513
+ // matcap, a reflection, the rim and the fill, with the ramp and matcap
514
+ // images on the style group's slots.
515
+
516
+ // The key light: CharacterEffect's direction, held in a frame that turns
517
+ // with the camera about the vertical (sim_TowardMatrix) — the game lights a
518
+ // character from where the viewer is, not from the scene's sun.
519
+ ag_key_light: {
520
+ inputs: { inclination: F(24), azimuth: F(-28) },
521
+ outputs: { direction: "vector" },
522
+ emit: (a) => `ag_key_light(${a.inclination}, ${a.azimuth})`,
523
+ },
524
+ // Where on the shadow ramp this point falls: N·L under the mask's occlusion,
525
+ // and the sun's cast shadow on top when the character receives it.
526
+ ag_shade_term: {
527
+ inputs: {
528
+ normal: { type: "vector", contextDefault: "n" },
529
+ direction: { type: "vector", requiresLink: true },
530
+ occlusion: F(1),
531
+ occlusion_scale: F(1),
532
+ receive_shadow: F(1),
533
+ },
534
+ outputs: { value: "float" },
535
+ emit: (a) => `ag_shade_term(${a.normal}, ${a.direction}, ${a.occlusion}, ${a.occlusion_scale}, ${a.receive_shadow}, input.worldPos)`,
536
+ takesLight: true,
537
+ },
538
+ // A ramp image's coordinate: u from the shade term, v the ramp's row (the
539
+ // game's property map picks it per texel; a PMX carries one per material).
540
+ ag_ramp_uv: {
541
+ inputs: { value: F(0), row: F(0.5) },
542
+ outputs: { uv: "vector" },
543
+ emit: (a) => `ag_ramp_uv(${a.value}, ${a.row})`,
544
+ },
545
+ // The view-space reflection the matcap, the reflection map, the rim and the
546
+ // fill all read: its uv into a sphere image, and how much it faces the eye.
547
+ ag_view_reflection: {
548
+ inputs: { normal: { type: "vector", contextDefault: "n" } },
549
+ outputs: { uv: "vector", facing: "float" },
550
+ outputSelect: { uv: ".xyz", facing: ".w" },
551
+ emit: (a) => `ag_view_reflection(${a.normal}, v)`,
552
+ },
553
+ // The matcap's lift: a metal's shine, at least 1, where the mask says.
554
+ ag_matcap: {
555
+ inputs: { color: C([1, 1, 1], true), matcap: C([1, 1, 1]), strength: F(1), mask: F(1) },
556
+ outputs: { color: "color" },
557
+ emit: (a) => `ag_matcap(${a.color}, ${a.matcap}, ${a.strength}, ${a.mask})`,
558
+ },
559
+ // The rim: the two fixed screen-side directions CharacterEffect sets, cut to
560
+ // the silhouette, laid on as a colour dodge where the mask allows.
561
+ ag_rim: {
562
+ inputs: {
563
+ color: C([1, 1, 1], true),
564
+ facing: F(1),
565
+ uv: { type: "vector", requiresLink: true },
566
+ rim_color: C([0.8584906, 0.8584906, 0.8584906]),
567
+ threshold: F(0.1),
568
+ fade: F(0.113),
569
+ range: F(0.444),
570
+ inclination: F(0),
571
+ azimuth1: F(0.8499963),
572
+ azimuth2: F(1),
573
+ mask: F(1),
574
+ },
575
+ outputs: { color: "color" },
576
+ emit: (a) =>
577
+ `ag_rim(${a.color}, ${a.facing}, ${a.uv}, ${a.rim_color}, ${a.threshold}, ${a.fade}, ${a.range}, ${a.inclination}, ${a.azimuth1}, ${a.azimuth2}, ${a.mask})`,
578
+ },
579
+ // The fill: a colour laid toward the silhouette, inner to outer.
580
+ ag_fill: {
581
+ inputs: {
582
+ color: C([1, 1, 1], true),
583
+ facing: F(1),
584
+ inner: C([0, 0, 0]),
585
+ outer: C([0, 0, 0]),
586
+ amount: F(0),
587
+ softness: F(0),
588
+ },
589
+ outputs: { color: "color" },
590
+ emit: (a) => `ag_fill(${a.color}, ${a.facing}, ${a.inner}, ${a.outer}, ${a.amount}, ${a.softness})`,
591
+ },
592
+
593
+ // Aether Gazer's current character shading (PBR/Uber) as one closure, the way
594
+ // Principled is one: the ramp image on the group slot named by the type.
595
+ // Defaults are 104903's skin.
596
+ ...UBER_NODES,
597
+ ...FACE_SDF_NODES,
598
+
457
599
  // ── Blender 5.2 colour utilities ──
458
600
  separate_color: {
459
601
  inputs: { color: C([1, 1, 1], true) },
@@ -66,6 +66,15 @@ export type ShaderGraph = {
66
66
  * matching (e.g. ["hair"]). Ignored by the compiler; round-tripped. A graph is pure
67
67
  * shading — pass integration lives on the style group's renderClass, not here. */
68
68
  tags?: string[]
69
+ /**
70
+ * The images this look samples on `tex_image/0`…`tex_image/3`, by URL —
71
+ * a ramp, a matcap, the maps a game's look is built on. They belong to the
72
+ * LOOK, not to the model it is put on: they are read at coordinates the
73
+ * shading computes (a light amount, a reflection), never at the mesh's uvs,
74
+ * so one graph carries them to any model. A style group that brings its own
75
+ * images keeps those.
76
+ */
77
+ images?: ({ url: string; srgb?: boolean } | null)[]
69
78
  }
70
79
 
71
80
  export type Diagnostic = {
@@ -137,20 +137,17 @@ ${discard}
137
137
  var n = safe_normal(input.normal);
138
138
  let v = normalize(camera.viewPos - input.worldPos);${flip}
139
139
  ${gate}
140
- let l = -light.lights[0].direction.xyz;
141
- let sun = select(light.lights[0].color.xyz * light.lights[0].color.w, modelLight.sun.rgb, modelLight.sun.w > 0.5);
140
+ // The key: the directional light this draw's layers take — the sun unless a
141
+ // light on its own layer stands in. The shadow stays the sun's atlas, as the
142
+ // game keeps the scene's shadow under a character's own key.
143
+ let rz_key = rzMainLight();
144
+ let l = -light.lights[rz_key].direction.xyz;
145
+ let sun = light.lights[rz_key].color.xyz * light.lights[rz_key].color.w;
142
146
  let shadow = sampleShadow(input.worldPos, n);
143
- // The world: flat colour, or the HDRI's irradiance at this normal — which
144
- // is what makes a loaded sky actually light her instead of only backing her.
145
- // The model's fill is NOT here: see the epilogue.
146
- //
147
- // A model given a flat sky (setModelFlatSky - the cast, for an anime look)
148
- // takes the sky's AVERAGE instead: its colour and brightness from every
149
- // direction alike. The directional sky shaded a face like PBR - soft
150
- // realistic gradients the toon ramp never drew - where the game gives its
151
- // characters one flat base light. A flat World is its own average, so a
152
- // scene without an HDRI renders exactly as before.
153
- let amb = select(rzWorldAmbient(n), rzWorldAmbientAvg(), modelLight.opts.x > 0.5);
147
+ // The ambient: this object's own where loaded data gave it one — the game
148
+ // gives every renderer its own — else the world's flat colour or the HDRI's
149
+ // irradiance at this normal.
150
+ let amb = rzObjectAmbient(n);
154
151
  let tex_color = tex_s.rgb;
155
152
  // The normal the lamps' diffuse layer is lit by, for a principled walk to
156
153
  // compute it on the way (rzLightsDiffuseOnce). n is final from here on.
@@ -194,13 +191,7 @@ function epilogue(renderClass: RenderClass, alphaMode: AlphaMode, hasOpacity: bo
194
191
  // painted sheet — is Blender's Emission shader, which no lamp reaches; adding
195
192
  // the lamps to it turned X340's floor shadow, a white-RGB picture at a soft
196
193
  // alpha, into a glowing disc under the spot above it.
197
- //
198
- // The model's fill (Engine.setModelFill) rides here too, as brightness: the
199
- // surface colour times the fill, on every pixel of her alike. It used to
200
- // enter the graph's ambient, where an NPR ramp read it as LIGHT — sliding it
201
- // moved her shadows, and under a hard step flipped whole regions at once.
202
- // After the graph it lifts her evenly and leaves every shadow where it is.
203
- const LIT = takesLight ? ` + (rzLightsDiffuseOnce(input.worldPos, n) + modelLight.fill.rgb) * albedo` : ""
194
+ const LIT = takesLight ? ` + rzLightsDiffuseOnce(input.worldPos, n) * albedo` : ""
204
195
 
205
196
  const ALBEDO = ` let albedo = tex_color;
206
197
  `
package/src/index.ts CHANGED
@@ -19,6 +19,8 @@ export {
19
19
  type MaterialPresetMap,
20
20
  type ModelTransform,
21
21
  type SceneLight,
22
+ RENDERING_LAYER_DEFAULT,
23
+ RENDERING_LAYER_CHARACTER,
22
24
  type ModelAttachment,
23
25
  type GizmoDragEvent,
24
26
  type GizmoDragCallback,
@@ -215,3 +217,12 @@ export {
215
217
  */
216
218
  export { EFFECT_MATH_API, PARTICLE_STRUCT_WGSL } from "./shaders/passes/hosted-api"
217
219
  export type { EyeTrackingOptions } from "./model"
220
+
221
+ /**
222
+ * The game's own materials (src/unity): a stage package drawn by its translated
223
+ * shaders (Engine.setNativeStage) and a model dressed in a native look
224
+ * (Engine.setModelNativeLook) — the shapes their files take.
225
+ */
226
+ export type { NativeStagePackage, NativeStageReader } from "./unity/stage"
227
+ export type { NativeLook, NativeMaterialSpec, NativeTexture } from "./unity/looks"
228
+ export type { NativeShader, NativeShaderInfo } from "./unity/host"
@@ -28,7 +28,8 @@ import { subjectMaskApi } from "./cast-api"
28
28
  // [0..2] position, world space [3] radius
29
29
  // [4..6] colour PREMULTIPLIED by intensity [7] type
30
30
  // [8..10] aim, unit, pointing away from the light [11] cos of the outer angle
31
- // [12] cos of the inner angle [13..15] spare
31
+ // [12] cos of the inner angle
32
+ // [13] the rendering layers it does NOT reach (u32 bits) [14..15] spare
32
33
  //
33
34
  // then the grid: LIGHT_MASK_WORDS words of lamp bits per cell.
34
35
  //
@@ -40,6 +41,12 @@ import { subjectMaskApi } from "./cast-api"
40
41
  // branchless: it stores aim (0,0,0) and both cosines at -1, which makes the
41
42
  // cone term below saturate to exactly 1. `type` says which a light is for
42
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.
43
50
 
44
51
  import { castDistanceStub } from "./passes/cast-distance"
45
52
  import { audioApi } from "./audio-api"
@@ -243,8 +250,9 @@ fn lightEmitMain(@builtin(global_invocation_id) gid: vec3u) {
243
250
  `
244
251
  }
245
252
 
246
- /** The rz*Light accessors, with the buffer declared at the given binding. */
247
- export function lightsApi(group: number, binding: number): string {
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 {
248
256
  const R = LIGHT_HEADER / 4
249
257
  const S = LIGHT_STRIDE / 4
250
258
  return /* wgsl */ `
@@ -286,6 +294,10 @@ fn rzLightAim(i: u32) -> vec3f { return _rzLightVec(i, 2u).xyz; }
286
294
  * light stores (-1, -1), which saturates the cone term to 1. */
287
295
  fn rzLightCone(i: u32) -> vec2f { return vec2f(_rzLightVec(i, 2u).w, _rzLightVec(i, 3u).x); }
288
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
+
289
301
  /** The document lamps that can reach p: its grid cell's bits, or the outside
290
302
  * mask beyond the grid. Written so a NaN position fails the inside test. */
291
303
  fn _rzLightCellMask(p: vec3f) -> vec4u {
@@ -312,6 +324,7 @@ fn _rzLightCellMask(p: vec3f) -> vec4u {
312
324
  * writes in candela over 683.
313
325
  */
314
326
  fn _rzLightOne(i: u32, p: vec3f, n: vec3f) -> vec3f {
327
+ if (!_rzLightReaches(i)) { return vec3f(0.0); }
315
328
  let pr = _rzLightVec(i, 0u);
316
329
  let d = pr.xyz - p;
317
330
  let dist = length(d);
@@ -1,7 +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
+ import { sunShadowWgsl } from "../scene-light-api"
5
5
  import { WORLD_AMBIENT_WGSL } from "../lights"
6
6
 
7
7
  // Shared WGSL blocks concatenated by every material shader.
@@ -61,6 +61,9 @@ struct LightUniforms {
61
61
  * amount 0 is off. Then the view-projection its map was drawn with. */
62
62
  castShadow: vec4f,
63
63
  castVP: mat4x4f,
64
+ /** The rendering layers each directional light reaches, slot by slot — a
65
+ * draw is lit by the first whose bits it shares (rzMainLight). */
66
+ dirLayers: vec4u,
64
67
  };
65
68
 
66
69
  // Per-material uniforms. Every material binds this layout even if it ignores fields;
@@ -148,13 +151,10 @@ struct LightVP { viewProj: array<mat4x4f, ${SHADOW_CASCADES.length}>, };
148
151
  @group(0) @binding(0) var<uniform> camera: CameraUniforms;
149
152
  @group(0) @binding(1) var<uniform> light: LightUniforms;
150
153
  @group(0) @binding(2) var diffuseSampler: sampler;
151
- @group(0) @binding(3) var shadowMap: texture_depth_2d;
154
+ // The sun's shadow atlas: every cascade in its own tile — see shadow-cascades.ts.
155
+ @group(0) @binding(3) var shadowAtlas: texture_depth_2d;
152
156
  @group(0) @binding(4) var shadowSampler: sampler_comparison;
153
157
  @group(0) @binding(5) var<uniform> lightVP: LightVP;
154
- // The far cascade's map — coarser texels over a much wider box, so the stage
155
- // keeps its shadows when the crisp near volume ends. Binding 7: 6 is the
156
- // positional lights, 9 the BRDF LUT.
157
- @group(0) @binding(7) var shadowMapFar: texture_depth_2d;
158
158
  // The world's own sky, for a surface that REFLECTS it rather than merely being
159
159
  // lit by it. Equirect, in the same layout and the same linear radiance the
160
160
  // composite draws the dome from, and mipped — a rough surface reads a coarser
@@ -188,14 +188,37 @@ fn _rzCastShadow(wp: vec3f) -> f32 {
188
188
  }
189
189
  // binding(9) brdfLut is declared inside NODES_WGSL (nodes.ts).
190
190
  @group(1) @binding(0) var<storage, read> skinMats: array<mat4x4f>;
191
- // The light this model takes apart from the scene's — see Engine.setModelFill
192
- // and Engine.setModelSun. fill brightens it after its graph (fill ×
193
- // surface colour, graph/slots.ts), zero for a model nobody gave one; sun replaces the scene's sun colour while its w is set,
194
- // which is how a stage keeps the daylight its game lit it by while the cast
195
- // keeps the key the scene set for them. opts.x (Engine.setModelFlatSky) gives
196
- // it the sky's average in place of its shape - see graph/slots.ts.
197
- struct ModelLight { fill: vec4f, sun: vec4f, opts: vec4f }
198
- @group(1) @binding(1) var<uniform> modelLight: ModelLight;
191
+ // What this object's lighting is, apart from the scene's, as the game's
192
+ // pipeline gives each renderer its own:
193
+ // layers.x its rendering-layer bits, which decide the lights that reach it —
194
+ // a stage's daylight and the cast's key are two lights on two
195
+ // layers, as the game lights them;
196
+ // ambient.x 1 while it has an ambient of its own (Engine.setModelAmbient),
197
+ // 0 for the world's;
198
+ // sh that ambient's irradiance, folded as the world's (ibl.ts).
199
+ struct ObjectLight { layers: vec4u, ambient: vec4f, sh: array<vec4f, 9> }
200
+ @group(1) @binding(1) var<uniform> objectLight: ObjectLight;
201
+
202
+ /** The light arriving at a surface of this object facing n: its own ambient
203
+ * where loaded data gave it one (a stage's probes, a character's), else the
204
+ * world's. The same polynomial rzWorldAmbient evaluates. */
205
+ fn rzObjectAmbient(n: vec3f) -> vec3f {
206
+ if (objectLight.ambient.x < 0.5) { return rzWorldAmbient(n); }
207
+ let s = objectLight.sh;
208
+ let c = s[0].xyz + s[1].xyz * n.y + s[2].xyz * n.z + s[3].xyz * n.x
209
+ + s[4].xyz * (n.x * n.y) + s[5].xyz * (n.y * n.z) + s[6].xyz * (3.0 * n.z * n.z - 1.0)
210
+ + s[7].xyz * (n.x * n.z) + s[8].xyz * (n.x * n.x - n.y * n.y);
211
+ return max(c, vec3f(0.0));
212
+ }
213
+
214
+ /** The directional light slot that lights this draw: the first whose layers
215
+ * it shares, else the sun's. */
216
+ fn rzMainLight() -> u32 {
217
+ for (var k = 0u; k < 4u; k++) {
218
+ if ((light.dirLayers[k] & objectLight.layers.x) != 0u) { return k; }
219
+ }
220
+ return 0u;
221
+ }
199
222
  @group(2) @binding(0) var diffuseTexture: texture_2d<f32>;
200
223
  @group(2) @binding(1) var<uniform> material: MaterialUniforms;
201
224
  // Reserved for future sphere/toon graph nodes; graphs that don't read them get the
@@ -226,58 +249,41 @@ fn safe_normal(nIn: vec3f) -> vec3f {
226
249
  `;
227
250
 
228
251
  // ─── Shadow sampler (3×3 PCF, cascade-selected) ─────────────────────
229
- // Normal-bias 0.08, depth-bias 0.001 NDC; the kernel is pcf9 in scene-light-api.ts,
230
- // shared with effects. Texel sizes are interpolated
231
- // from SHADOW_CASCADES so they cannot go stale the way the hardcoded 1/2048
232
- // once did (PCF taps landed TWO texels apart after the map grew to 4096,
233
- // quantizing self-shadow edges into crawling gray squares).
252
+ // Normal-bias 0.08, depth-bias 0.001 NDC; the lookup is sunShadowWgsl in
253
+ // scene-light-api.ts, shared with the ground and effects.
234
254
  //
235
- // Selection: the crisp near cascade wherever its box covers the point — with a
236
- // margin that keeps the whole PCF kernel inside the map, so selection never
237
- // mixes maps mid-kernel — else the far cascade, else LIT. Lit is a fix, not
238
- // merely a default: the old single-volume path clamped, and past the light's
239
- // far plane the comparison failed against every stored depth, silently
240
- // shadowing any stage deeper than the box.
241
-
255
+ // Selection: the first cascade whose box covers the point — with a margin that
256
+ // keeps the whole PCF kernel inside its tile — else LIT. Lit is a fix, not
257
+ // merely a default: past every cascade there is no occlusion information, and
258
+ // comparing against unrelated depths would silently shadow any stage deeper
259
+ // than the boxes.
242
260
 
243
261
  const SAMPLE_SHADOW_WGSL = /* wgsl */ `
262
+ ${sunShadowWgsl("_rzSun", "shadowAtlas", "shadowSampler", "lightVP.viewProj")}
244
263
 
245
- fn sampleShadowNear(ndc: vec3f) -> f32 {
246
- ${pcf9("shadowMap", `1.0 / ${SHADOW_CASCADES[0].mapSize}.0`)}
247
- }
248
-
249
- fn sampleShadowFar(ndc: vec3f) -> f32 {
250
- ${pcf9("shadowMapFar", `1.0 / ${SHADOW_CASCADES[SHADOW_CASCADES.length - 1].mapSize}.0`)}
264
+ /** The sun's cast shadow alone, without sampleShadow's facing test: what a
265
+ * character's ramp is min'd with (its own N·L carries the terminator). */
266
+ fn rzSunOcclusion(worldPos: vec3f, n: vec3f) -> f32 {
267
+ let castAmt = light.lights[0].direction.w;
268
+ if (castAmt <= 0.0) { return 1.0; }
269
+ let a = _rzSunLocate(worldPos + n * 0.08);
270
+ if (a.w < 0.0) { return 1.0; }
271
+ return mix(1.0, _rzSunTaps(a), castAmt);
251
272
  }
252
273
 
253
274
  fn sampleShadow(worldPos: vec3f, n: vec3f) -> f32 {
254
275
  // THE SCENE'S ONE SHADOW SWITCH, and it lives on the sun because the sun is
255
- // the only thing that casts. Positional lamps are diffuse-only — rzLightsDiffuse
256
- // has no shadow term at all — so turning this off turns off every shadow there
257
- // is, on the ground and on the cast alike. Gated here rather than by not
258
- // rendering the map: the map is also what the ground catcher reads, and a host
259
- // that wants a figure lit with no shadow still wants everything else about the
260
- // frame unchanged.
261
- //
262
- // Lit is the answer when it is off. Returning 0 would be "fully shadowed",
263
- // which is the opposite of what a disabled shadow means.
276
+ // the only thing that casts. Lit is the answer when it is off: returning 0
277
+ // would be "fully shadowed", the opposite of what a disabled shadow means.
264
278
  let castAmt = light.lights[0].direction.w;
265
279
  if (castAmt <= 0.0) { return 1.0; }
266
- if (dot(n, -light.lights[0].direction.xyz) <= 0.0) { return 0.0; }
267
- let biasedPos = worldPos + n * 0.08;
268
- let c0 = lightVP.viewProj[0] * vec4f(biasedPos, 1.0);
269
- let n0 = c0.xyz / max(c0.w, 1e-6);
270
- if (all(abs(n0.xy) < vec2f(0.98)) && n0.z > 0.0 && n0.z < 1.0) {
271
- return mix(1.0, sampleShadowNear(n0), castAmt);
272
- }
273
- let c1 = lightVP.viewProj[1] * vec4f(biasedPos, 1.0);
274
- let n1 = c1.xyz / max(c1.w, 1e-6);
275
- if (all(abs(n1.xy) < vec2f(0.98)) && n1.z > 0.0 && n1.z < 1.0) {
276
- return mix(1.0, sampleShadowFar(n1), castAmt);
277
- }
278
- // Outside every cascade there is no occlusion information; lit is the only
279
- // honest answer.
280
- return 1.0;
280
+ // A surface turned from the sun is in its own shade — when the sun is what
281
+ // keys it. Under a key of its own layer the atlas is only the occluders'
282
+ // shadow, and the key's N·L decides its terminator.
283
+ if (rzMainLight() == 0u && dot(n, -light.lights[0].direction.xyz) <= 0.0) { return 0.0; }
284
+ let a = _rzSunLocate(worldPos + n * 0.08);
285
+ if (a.w < 0.0) { return 1.0; }
286
+ return mix(1.0, _rzSunTaps(a), castAmt);
281
287
  }
282
288
 
283
289
  `;
@@ -499,4 +505,4 @@ fn rz_dissolve_threshold(restPos: vec3f) -> f32 {
499
505
  // The FSOut struct is NOT in here any more — see commonFsOutWgsl above. Every
500
506
  // consumer of this constant appends it immediately, which is where it was.
501
507
  export const COMMON_MATERIAL_PRELUDE_WGSL =
502
- COMMON_BINDINGS_WGSL + WORLD_AMBIENT_WGSL + lightsApi(0, 6) + SAMPLE_SHADOW_WGSL + COMMON_VS_WGSL
508
+ COMMON_BINDINGS_WGSL + WORLD_AMBIENT_WGSL + lightsApi(0, 6, "objectLight.layers.x") + SAMPLE_SHADOW_WGSL + COMMON_VS_WGSL