reze-engine 0.43.0 → 0.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/README.md +40 -438
  2. package/dist/camera.d.ts +3 -0
  3. package/dist/camera.d.ts.map +1 -1
  4. package/dist/camera.js +33 -8
  5. package/dist/engine.d.ts +841 -53
  6. package/dist/engine.d.ts.map +1 -1
  7. package/dist/engine.js +3498 -451
  8. package/dist/graph/registry.d.ts +2 -2
  9. package/dist/graph/registry.d.ts.map +1 -1
  10. package/dist/graph/registry.js +1 -1
  11. package/dist/graph/slots.d.ts +0 -1
  12. package/dist/graph/slots.d.ts.map +1 -1
  13. package/dist/graph/slots.js +37 -9
  14. package/dist/hdr.d.ts +18 -0
  15. package/dist/hdr.d.ts.map +1 -0
  16. package/dist/hdr.js +162 -0
  17. package/dist/ibl.d.ts +19 -0
  18. package/dist/ibl.d.ts.map +1 -0
  19. package/dist/ibl.js +113 -0
  20. package/dist/ik-solver.d.ts +2 -1
  21. package/dist/ik-solver.d.ts.map +1 -1
  22. package/dist/index.d.ts +5 -1
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +10 -0
  25. package/dist/math.d.ts +20 -1
  26. package/dist/math.d.ts.map +1 -1
  27. package/dist/math.js +23 -16
  28. package/dist/midi-loader.d.ts +10 -0
  29. package/dist/midi-loader.d.ts.map +1 -0
  30. package/dist/midi-loader.js +247 -0
  31. package/dist/model.d.ts +2 -13
  32. package/dist/model.d.ts.map +1 -1
  33. package/dist/param-track.d.ts +48 -0
  34. package/dist/param-track.d.ts.map +1 -0
  35. package/dist/param-track.js +80 -0
  36. package/dist/physics/types.d.ts.map +1 -1
  37. package/dist/physics/types.js +3 -0
  38. package/dist/reflection.d.ts +27 -0
  39. package/dist/reflection.d.ts.map +1 -0
  40. package/dist/reflection.js +93 -0
  41. package/dist/shaders/anchor-table.d.ts +56 -0
  42. package/dist/shaders/anchor-table.d.ts.map +1 -0
  43. package/dist/shaders/anchor-table.js +128 -0
  44. package/dist/shaders/cast-api.d.ts +2 -0
  45. package/dist/shaders/cast-api.d.ts.map +1 -0
  46. package/dist/shaders/cast-api.js +121 -0
  47. package/dist/shaders/cast-layout.d.ts +21 -0
  48. package/dist/shaders/cast-layout.d.ts.map +1 -0
  49. package/dist/shaders/cast-layout.js +20 -0
  50. package/dist/shaders/lights.d.ts +79 -0
  51. package/dist/shaders/lights.d.ts.map +1 -0
  52. package/dist/shaders/lights.js +269 -0
  53. package/dist/shaders/lyrics-api.d.ts +39 -0
  54. package/dist/shaders/lyrics-api.d.ts.map +1 -0
  55. package/dist/shaders/lyrics-api.js +187 -0
  56. package/dist/shaders/materials/common.d.ts +2 -4
  57. package/dist/shaders/materials/common.d.ts.map +1 -1
  58. package/dist/shaders/materials/common.js +87 -35
  59. package/dist/shaders/midi-api.d.ts +10 -0
  60. package/dist/shaders/midi-api.d.ts.map +1 -0
  61. package/dist/shaders/midi-api.js +114 -0
  62. package/dist/shaders/passes/composite.d.ts +28 -22
  63. package/dist/shaders/passes/composite.d.ts.map +1 -1
  64. package/dist/shaders/passes/composite.js +165 -138
  65. package/dist/shaders/passes/cull.d.ts +2 -0
  66. package/dist/shaders/passes/cull.d.ts.map +1 -0
  67. package/dist/shaders/passes/cull.js +138 -0
  68. package/dist/shaders/passes/field-blit.d.ts +26 -0
  69. package/dist/shaders/passes/field-blit.d.ts.map +1 -0
  70. package/dist/shaders/passes/field-blit.js +65 -0
  71. package/dist/shaders/passes/grid.d.ts +31 -0
  72. package/dist/shaders/passes/grid.d.ts.map +1 -0
  73. package/dist/shaders/passes/grid.js +169 -0
  74. package/dist/shaders/passes/ground.d.ts +13 -1
  75. package/dist/shaders/passes/ground.d.ts.map +1 -1
  76. package/dist/shaders/passes/ground.js +170 -25
  77. package/dist/shaders/passes/hosted-api.d.ts +57 -0
  78. package/dist/shaders/passes/hosted-api.d.ts.map +1 -0
  79. package/dist/shaders/passes/hosted-api.js +166 -0
  80. package/dist/shaders/passes/id-debug.d.ts +28 -0
  81. package/dist/shaders/passes/id-debug.d.ts.map +1 -0
  82. package/dist/shaders/passes/id-debug.js +74 -0
  83. package/dist/shaders/passes/particles.d.ts +10 -2
  84. package/dist/shaders/passes/particles.d.ts.map +1 -1
  85. package/dist/shaders/passes/particles.js +37 -109
  86. package/dist/shaders/passes/scene-contract.d.ts +128 -0
  87. package/dist/shaders/passes/scene-contract.d.ts.map +1 -0
  88. package/dist/shaders/passes/scene-contract.js +207 -0
  89. package/dist/shaders/passes/sim.d.ts +34 -0
  90. package/dist/shaders/passes/sim.d.ts.map +1 -0
  91. package/dist/shaders/passes/sim.js +169 -0
  92. package/dist/shaders/passes/trails.d.ts +14 -2
  93. package/dist/shaders/passes/trails.d.ts.map +1 -1
  94. package/dist/shaders/passes/trails.js +55 -90
  95. package/dist/shaders/score-api.d.ts +10 -0
  96. package/dist/shaders/score-api.d.ts.map +1 -0
  97. package/dist/shaders/score-api.js +114 -0
  98. package/dist/shadow-cascades.d.ts +45 -0
  99. package/dist/shadow-cascades.d.ts.map +1 -0
  100. package/dist/shadow-cascades.js +70 -0
  101. package/dist/vmd-loader.d.ts +3 -2
  102. package/dist/vmd-loader.d.ts.map +1 -1
  103. package/package.json +1 -1
  104. package/src/camera.ts +31 -8
  105. package/src/engine.ts +4015 -556
  106. package/src/graph/registry.ts +2 -2
  107. package/src/graph/slots.ts +37 -9
  108. package/src/hdr.ts +156 -0
  109. package/src/ibl.ts +115 -0
  110. package/src/ik-solver.ts +1 -1
  111. package/src/index.ts +12 -0
  112. package/src/math.ts +23 -17
  113. package/src/midi-loader.ts +246 -0
  114. package/src/model.ts +3 -3
  115. package/src/param-track.ts +83 -0
  116. package/src/physics/types.ts +4 -1
  117. package/src/reflection.ts +94 -0
  118. package/src/shaders/anchor-table.ts +147 -0
  119. package/src/shaders/cast-api.ts +123 -0
  120. package/src/shaders/cast-layout.ts +20 -0
  121. package/src/shaders/lights.ts +280 -0
  122. package/src/shaders/lyrics-api.ts +202 -0
  123. package/src/shaders/materials/common.ts +89 -35
  124. package/src/shaders/midi-api.ts +116 -0
  125. package/src/shaders/passes/composite.ts +182 -139
  126. package/src/shaders/passes/cull.ts +139 -0
  127. package/src/shaders/passes/grid.ts +178 -0
  128. package/src/shaders/passes/ground.ts +172 -25
  129. package/src/shaders/passes/hosted-api.ts +171 -0
  130. package/src/shaders/passes/id-debug.ts +75 -0
  131. package/src/shaders/passes/particles.ts +54 -112
  132. package/src/shaders/passes/scene-contract.ts +266 -0
  133. package/src/shaders/passes/trails.ts +77 -93
  134. package/src/shadow-cascades.ts +97 -0
  135. package/src/vmd-loader.ts +2 -2
@@ -0,0 +1,178 @@
1
+ import { audioApi } from "../audio-api"
2
+ import { lyricsApi } from "../lyrics-api"
3
+ import { anchorAliasWgsl } from "../anchor-table"
4
+ import { midiApi } from "../midi-api"
5
+ import { EFFECT_SCENE_API } from "./composite"
6
+ import { type CastLayout } from "./particles"
7
+ import { clockApi, trailSlotsApi, viewportApi } from "./hosted-api"
8
+
9
+ // A persistent grid an effect can step and read: the one thing an effect could
10
+ // not have before, which is MEMORY.
11
+ //
12
+ // Every other mount is a pure function of position and time. A field shader
13
+ // recomputes its noise from scratch every frame; a trail reads history the
14
+ // engine happens to keep. Neither can build on what it produced last frame, and
15
+ // a whole class of effect is nothing but that:
16
+ //
17
+ // Fog that a dancer walks through does not "look displaced near her" — it IS
18
+ // displaced, and stays displaced, and creeps back over seconds. Water does not
19
+ // look rippled where a foot landed; a ripple leaves and propagates and
20
+ // reflects off the far edge long after the foot is gone. Both are the same
21
+ // shape of thing — a grid, stepped by its own previous value.
22
+ //
23
+ // It is also the only way to get the LOOK of a fluid, not merely its motion.
24
+ // Advection compounds: each frame's field is the last one pushed sideways, and a
25
+ // few hundred frames of that stretches and folds a smooth field into filaments
26
+ // and vortex sheets finer than the grid storing them. No noise function
27
+ // produces that, at any octave count, because the structure is not a function of
28
+ // position at all — it is a function of history.
29
+ //
30
+ // The engine owns the ping-pong, the dispatch and the sampling. The author owns
31
+ // the KERNEL, which is the part that differs: advection for smoke, the wave
32
+ // equation for water, a decaying stamp for footprints. That split is the same
33
+ // one the particle mounts make, and for the same reason — the loop is identical
34
+ // in every such effect and the physics never is.
35
+
36
+ // A megatexel of rgba16float is 8MB, and two of those is the whole cost. What
37
+ // actually bounds this is the STEP: one invocation per texel per frame, each
38
+ // taking a dozen samples, so doubling the side quadruples the work.
39
+ export const GRID_MAX = 1024
40
+ /** rgba16float, ping-ponged. Two of these is the whole memory cost. */
41
+ export const SIM_FORMAT: GPUTextureFormat = "rgba16float"
42
+
43
+ /** `// @grid 256` — the grid's resolution, in texels per side. */
44
+ export function parseGridSize(wgsl: string, max: number): number {
45
+ const m = /^\s*\/\/\s*@grid\s+(\d+)\s*$/m.exec(wgsl)
46
+ if (!m) return 0
47
+ // Clamped rather than rejected, as the particle count is: an author asking for
48
+ // 4096 gets the most the engine will give and a scene that still runs.
49
+ return Math.max(8, Math.min(max, parseInt(m[1], 10)))
50
+ }
51
+
52
+ /** Whether this effect drives a grid at all. */
53
+ export function gridEntryPoint(wgsl: string): boolean {
54
+ return /\bfn\s+gridStep\s*\(/.test(wgsl)
55
+ }
56
+
57
+ /**
58
+ * `rzGrid(uv)`, for a shader that READS the grid rather than steps it.
59
+ *
60
+ * Always compiled in, even for an effect with no grid — it then samples a 1×1
61
+ * of zeroes. An accessor that exists only sometimes is one an author has to
62
+ * guard, and a missing function is a compile error rather than a blank result.
63
+ */
64
+ export function gridReadApi(group: number, tex: number, samp: number, size: number): string {
65
+ return /* wgsl */ `
66
+ const RZ_GRID_SIZE: f32 = ${size > 0 ? size : 1}.0;
67
+ @group(${group}) @binding(${tex}) var _rzGridTex: texture_2d<f32>;
68
+ @group(${group}) @binding(${samp}) var _rzGridSamp: sampler;
69
+
70
+ /**
71
+ * The grid, bilinearly sampled. uv is 0..1 across it, and what that MEANS in
72
+ * the world is the effect's own business — the engine deliberately does not
73
+ * impose a mapping, because a fog laid over a stage and a ripple field around a
74
+ * character want different ones and both are two lines of arithmetic.
75
+ */
76
+ fn rzGrid(uv: vec2f) -> vec4f {
77
+ return textureSampleLevel(_rzGridTex, _rzGridSamp, clamp(uv, vec2f(0.0), vec2f(1.0)), 0.0);
78
+ }
79
+ fn rzGridSize() -> f32 { return RZ_GRID_SIZE; }
80
+ fn rzGridTexel() -> f32 { return 1.0 / RZ_GRID_SIZE; }
81
+
82
+ // ── The step-only half, defined here too and never called here ──
83
+ //
84
+ // One effect file is spliced into EVERY module it has a mount in, so a file
85
+ // with a grid compiles its gridStep inside the field and particle shaders as
86
+ // well — where it is dead code that nothing calls, but still code that has to
87
+ // resolve. Leaving these out is a compile error on a function the author was
88
+ // right to write. rzGridPrev reads the current grid rather than a previous one,
89
+ // which is the honest answer outside the step: there is no previous frame here.
90
+ fn rzGridPrev(uv: vec2f) -> vec4f { return rzGrid(uv); }
91
+ fn rzGridFrame() -> i32 { return 1; }
92
+ `
93
+ }
94
+
95
+ const SIM_UNIFORMS = /* wgsl */ `
96
+ struct SimU {
97
+ time: f32,
98
+ dt: f32,
99
+ size: f32,
100
+ frame: f32,
101
+ }
102
+ `
103
+
104
+ /**
105
+ * The step shader: one invocation per texel, once per frame.
106
+ *
107
+ * The previous grid is bound as a SAMPLED texture and the next as a storage
108
+ * texture, which is what makes the two different resources and the whole thing
109
+ * legal — a shader cannot read and write one texture coherently, and the
110
+ * ping-pong is not an optimisation but the only correct way to do this.
111
+ *
112
+ * Sampled rather than only fetched, because advection needs to read BETWEEN
113
+ * texels: a semi-Lagrangian step asks "what was at the place this parcel came
114
+ * from", and that place is almost never a texel centre. Point-sampling it is
115
+ * what turns smoke into a staircase.
116
+ */
117
+ export function buildSimShader(wgsl: string, size: number, cast: CastLayout): string {
118
+ return (
119
+ SIM_UNIFORMS +
120
+ /* wgsl */ `
121
+ @group(0) @binding(0) var<uniform> su: SimU;
122
+ @group(0) @binding(1) var _rzGridPrevTex: texture_2d<f32>;
123
+ @group(0) @binding(2) var _rzGridSamp: sampler;
124
+ @group(0) @binding(3) var _rzGridOut: texture_storage_2d<${SIM_FORMAT}, write>;
125
+ @group(0) @binding(4) var<storage, read> _rzCast: array<vec4f>;
126
+ // The same view uniform the composite reads, so the scene API below is the real
127
+ // thing here rather than a stub — a kernel can ask where a bone is, and a
128
+ // foreground spliced into this module resolves its camera calls.
129
+ @group(0) @binding(6) var<uniform> viewU: array<vec4<f32>, 15>;
130
+ ${clockApi("su.time", "su.dt")}${viewportApi("viewU[6].w")}${trailSlotsApi(cast.trailCount)}
131
+ /** Texels per side. */
132
+ fn rzGridSize() -> f32 { return su.size; }
133
+ /** One texel, in uv — the step a neighbour lookup takes. */
134
+ fn rzGridTexel() -> f32 { return 1.0 / su.size; }
135
+ /**
136
+ * Steps since this effect was installed. Zero on the very first one, which is
137
+ * the only chance to seed a grid: everything after it builds on what is there.
138
+ */
139
+ fn rzGridFrame() -> i32 { return i32(su.frame); }
140
+
141
+ /** The grid as it was LAST frame, bilinear. The only input a kernel really has. */
142
+ fn rzGridPrev(uv: vec2f) -> vec4f {
143
+ return textureSampleLevel(_rzGridPrevTex, _rzGridSamp, clamp(uv, vec2f(0.0), vec2f(1.0)), 0.0);
144
+ }
145
+ ` +
146
+ EFFECT_SCENE_API +
147
+ // The alias _rzSlot, which the scene API's accessors route through. Every
148
+ // module embedding EFFECT_SCENE_API must splice this; the sim module was
149
+ // the one that did not, and every gridStep effect failed to install with
150
+ // "unresolved call target" from the day the identity fallback was removed.
151
+ // Found by tools/validate-wgsl.mjs on its first run.
152
+ anchorAliasWgsl(cast.alias) +
153
+ audioApi(0, 5) +
154
+ midiApi(0, 7) +
155
+ lyricsApi(0, 8) +
156
+ // rzGrid itself, so a kernel may read the grid it is writing — through the
157
+ // PREVIOUS frame's texture, which is the only version of it that exists
158
+ // while the current one is still being written.
159
+ /* wgsl */ `
160
+ fn rzGrid(uv: vec2f) -> vec4f { return rzGridPrev(uv); }
161
+ ` +
162
+ "\n// ── user effect (setEffect) ──\n" +
163
+ wgsl +
164
+ /* wgsl */ `
165
+
166
+ @compute @workgroup_size(8, 8)
167
+ fn main(@builtin(global_invocation_id) gid: vec3u) {
168
+ let n = u32(${size});
169
+ if (gid.x >= n || gid.y >= n) { return; }
170
+ let xy = vec2i(i32(gid.x), i32(gid.y));
171
+ // The texel CENTRE, so a kernel that reads its own uv back through rzGridPrev
172
+ // lands on itself rather than a quarter-texel off.
173
+ let uv = (vec2f(f32(gid.x), f32(gid.y)) + vec2f(0.5)) / su.size;
174
+ textureStore(_rzGridOut, xy, gridStep(uv, textureLoad(_rzGridPrevTex, xy, 0), su.dt));
175
+ }
176
+ `
177
+ )
178
+ }
@@ -1,23 +1,49 @@
1
+ import { sceneFsOutWgsl, sceneIdWriteWgsl } from "./scene-contract"
2
+ import { lightsApi } from "../lights"
3
+ import { SHADOW_CASCADES } from "../../shadow-cascades"
4
+ import { WORLD_AMBIENT_WGSL } from "../lights"
5
+
1
6
  // Ground shadow-catcher: receives directional shadow, grid lines, frosted noise,
2
7
  // radial distance fade. Writes bloom mask = 0 (ground never bloom-bleeds).
8
+ //
9
+ // A FUNCTION, not a constant, since the id attachment is a device capability:
10
+ // the struct this returns depends on what the probe at init found, and a string
11
+ // baked at import time cannot know that. Called once, when the module is built.
3
12
 
4
- export const GROUND_SHADOW_SHADER_WGSL = /* wgsl */ `
13
+ export function groundShaderWgsl(): string {
14
+ return /* wgsl */ `
5
15
  struct CameraUniforms { view: mat4x4f, projection: mat4x4f, viewPos: vec3f, _p: f32, };
6
16
  struct Light { direction: vec4f, color: vec4f, };
7
- struct LightUniforms { ambientColor: vec4f, lights: array<Light, 4>, };
17
+ struct LightUniforms { ambientColor: vec4f, lights: array<Light, 4>, sh: array<vec4f, 9>, };
8
18
  struct GroundShadowMat {
9
19
  diffuseColor: vec3f, fadeStart: f32,
10
20
  fadeEnd: f32, shadowStrength: f32, pcfTexel: f32, gridSpacing: f32,
11
21
  gridLineWidth: f32, gridLineOpacity: f32, noiseStrength: f32, opacity: f32,
12
- gridLineColor: vec3f, _pad2: f32,
22
+ gridLineColor: vec3f, mirror: f32,
23
+ mirrorBlur: f32, _mb0: f32, _mb1: f32, _mb2: f32,
13
24
  };
14
- struct LightVP { viewProj: mat4x4f, };
25
+ // One view-projection per shadow cascade, inner to outer — same buffer and
26
+ // same order the materials read.
27
+ struct LightVP { viewProj: array<mat4x4f, ${SHADOW_CASCADES.length}>, };
15
28
  @group(0) @binding(0) var<uniform> camera: CameraUniforms;
16
29
  @group(0) @binding(1) var<uniform> light: LightUniforms;
17
30
  @group(0) @binding(2) var shadowMap: texture_depth_2d;
18
31
  @group(0) @binding(3) var shadowSampler: sampler_comparison;
19
32
  @group(0) @binding(4) var<uniform> material: GroundShadowMat;
20
33
  @group(0) @binding(5) var<uniform> lightVP: LightVP;
34
+ // The far cascade's map, binding 7 as in the materials' layout.
35
+ @group(0) @binding(7) var shadowMapFar: texture_depth_2d;
36
+ // The floor mirror (step 7D): the reflection target, the camera that rendered
37
+ // it, and an ordinary sampler — the two shadow bindings above are comparison.
38
+ // params = (projA, projB, _, _) — the depth-linearisation pair of the SHARED
39
+ // projection, refreshed per frame beside the matrix.
40
+ struct MirrorVP { viewProj: mat4x4f, params: vec4f, };
41
+ @group(0) @binding(8) var<uniform> mirrorVP: MirrorVP;
42
+ @group(0) @binding(9) var mirrorTex: texture_2d<f32>;
43
+ @group(0) @binding(10) var linearSampler: sampler;
44
+ @group(0) @binding(11) var mirrorDepth: texture_depth_multisampled_2d;
45
+ ${WORLD_AMBIENT_WGSL}
46
+ ${lightsApi(0, 6)}
21
47
 
22
48
  fn hash2(p: vec2f) -> f32 {
23
49
  var p3 = fract(vec3f(p.x, p.y, p.x) * 0.1031);
@@ -48,8 +74,7 @@ struct VO { @builtin(position) position: vec4f, @location(0) worldPos: vec3f, @l
48
74
  var o: VO; o.worldPos = position; o.normal = normal;
49
75
  o.position = camera.projection * camera.view * vec4f(position, 1.0); return o;
50
76
  }
51
- struct FSOut { @location(0) color: vec4f, @location(1) mask: vec4f };
52
- @fragment fn fs(i: VO) -> FSOut {
77
+ ${sceneFsOutWgsl()}@fragment fn fs(i: VO) -> FSOut {
53
78
  // Derivatives first, unconditionally: WGSL requires fwidth in uniform control
54
79
  // flow and everything below branches on world position.
55
80
  let gp = i.worldPos.xz / material.gridSpacing;
@@ -68,15 +93,20 @@ struct FSOut { @location(0) color: vec4f, @location(1) mask: vec4f };
68
93
  return out;
69
94
  }
70
95
 
71
- let lclip = lightVP.viewProj * vec4f(i.worldPos, 1.0);
72
- let ndc = lclip.xyz / max(lclip.w, 1e-6);
73
- // Outside the light's frustum there IS no shadow information — the clamped
96
+ // Outside a cascade's frustum there IS no shadow information — the clamped
74
97
  // border samples compare against unrelated depths and read "shadowed",
75
98
  // darkening the whole far plane with a visible band at the frustum edge
76
99
  // (masked by the opaque surface normally, glaring in green-screen mode where
77
- // only the shadow-catcher term renders). Fade to fully lit near the border.
100
+ // only the shadow-catcher term renders). So each cascade fades out near its
101
+ // border — the near one INTO the far one's answer, the far one into lit.
102
+ let l0 = lightVP.viewProj[0] * vec4f(i.worldPos, 1.0);
103
+ let ndc = l0.xyz / max(l0.w, 1e-6);
78
104
  let inZ = select(0.0, 1.0, ndc.z > 0.0 && ndc.z < 1.0);
79
105
  let frustum = (1.0 - smoothstep(0.88, 0.96, abs(ndc.x))) * (1.0 - smoothstep(0.88, 0.96, abs(ndc.y))) * inZ;
106
+ let l1 = lightVP.viewProj[1] * vec4f(i.worldPos, 1.0);
107
+ let ndc1 = l1.xyz / max(l1.w, 1e-6);
108
+ let inZ1 = select(0.0, 1.0, ndc1.z > 0.0 && ndc1.z < 1.0);
109
+ let frustum1 = (1.0 - smoothstep(0.88, 0.96, abs(ndc1.x))) * (1.0 - smoothstep(0.88, 0.96, abs(ndc1.y))) * inZ1;
80
110
 
81
111
  // THE cost of this shader, measured: a full-screen ground ran 25 shadow
82
112
  // comparisons per pixel, and each one is hardware-bilinear, so ~100 depth
@@ -88,10 +118,23 @@ struct FSOut { @location(0) color: vec4f, @location(1) mask: vec4f };
88
118
  // almost entirely once the comparison sampler's own 2x2 filter is counted.
89
119
  // Nine taps instead of twenty-five for the same blur radius.
90
120
  //
91
- // Skipped altogether outside the light frustum, where the mix below discards
92
- // the result anyway: the shadow map covers a small area around the character
93
- // and the ground is vastly larger than it.
121
+ // The far cascade's taps run ONLY where the near one is fading or absent
122
+ // (frustum < 1), so a pixel in the near core costs exactly what it did with
123
+ // one cascade — and that core is where the camera usually looks.
94
124
  var vis = 1.0;
125
+ if (frustum < 1.0 && frustum1 > 0.0) {
126
+ let suv1 = vec2f(ndc1.x * 0.5 + 0.5, 0.5 - ndc1.y * 0.5);
127
+ let suv1_c = clamp(suv1, vec2f(0.02), vec2f(0.98));
128
+ let st1 = ${1 / SHADOW_CASCADES[SHADOW_CASCADES.length - 1].mapSize} * 2.0;
129
+ let compareZ1 = ndc1.z - 0.0035;
130
+ var acc1 = 0.0;
131
+ for (var y = -1; y <= 1; y++) {
132
+ for (var x = -1; x <= 1; x++) {
133
+ acc1 += textureSampleCompareLevel(shadowMapFar, shadowSampler, suv1_c + vec2f(f32(x), f32(y)) * st1, compareZ1);
134
+ }
135
+ }
136
+ vis = mix(1.0, acc1 * (1.0 / 9.0), frustum1);
137
+ }
95
138
  if (frustum > 0.0) {
96
139
  let suv = vec2f(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
97
140
  let suv_c = clamp(suv, vec2f(0.02), vec2f(0.98));
@@ -105,7 +148,9 @@ struct FSOut { @location(0) color: vec4f, @location(1) mask: vec4f };
105
148
  acc += textureSampleCompareLevel(shadowMap, shadowSampler, suv_c + vec2f(f32(x), f32(y)) * st, compareZ);
106
149
  }
107
150
  }
108
- vis = mix(1.0, acc * (1.0 / 9.0), frustum);
151
+ // The base is whatever the far cascade decided, so the near border blends
152
+ // cascade to cascade rather than snapping to lit mid-floor.
153
+ vis = mix(vis, acc * (1.0 / 9.0), frustum);
109
154
  }
110
155
 
111
156
  // Frosted/matte micro-texture. Scenes leaving it at zero were still paying
@@ -123,24 +168,126 @@ struct FSOut { @location(0) color: vec4f, @location(1) mask: vec4f };
123
168
  smoothstep(halfLine - gridDeriv.x, halfLine + gridDeriv.x, gridFrac.x),
124
169
  smoothstep(halfLine - gridDeriv.y, halfLine + gridDeriv.y, gridFrac.y)
125
170
  );
126
- let sun = light.ambientColor.xyz + light.lights[0].color.xyz * light.lights[0].color.w * max(dot(n, -light.lights[0].direction.xyz), 0.0);
171
+ let sun = rzWorldAmbient(n) + light.lights[0].color.xyz * light.lights[0].color.w * max(dot(n, -light.lights[0].direction.xyz), 0.0);
172
+ // The positional layer reaches the floor too. A stage light that lit the
173
+ // cast and not the ground under her would read as a sticker, which is the
174
+ // same failure the shadow catcher exists to prevent.
175
+ let lamps = rzLightsDiffuse(i.worldPos, n);
127
176
  let dark = (1.0 - vis) * material.shadowStrength;
128
- var baseColor = material.diffuseColor * sun * (1.0 - dark * 0.65);
177
+ // The shadow darkens the SUN only. The dark term comes from the sun's shadow
178
+ // map, so applying it to the lamps too would have a lamp on the far side of
179
+ // the stage dimmed by a shadow cast away from it — the one light in the scene
180
+ // that provably is not blocking it.
181
+ // (No backticks in here: this string is a TS template literal and one ends it
182
+ // mid-shader, which is exactly how this line broke the build a moment ago.)
183
+ var baseColor = material.diffuseColor * (sun * (1.0 - dark * 0.65) + lamps);
129
184
  baseColor *= noiseTint;
130
- let finalColor = mix(baseColor, material.gridLineColor, gridLine * material.gridLineOpacity * edgeFade);
131
- // Whole-ground opacity fades the SURFACE (color, grid) but the shadow stays —
132
- // as opacity drops, the received shadow becomes a translucent dark layer
133
- // (Blender's Shadow Catcher), so models still feel grounded on a photo or
134
- // 360 backdrop. At opacity 1 this reduces exactly to the plain surface.
185
+ // The floor mirror — its OWN LAYER, not a blend into the surface. The
186
+ // reflection was rendered by the MIRROR camera, so projecting this
187
+ // fragment's world position through that camera yields exactly the texel
188
+ // where its reflection landed — projective mapping, no screen-space guess.
189
+ // There is deliberately NO strength dial: mirror is on or off, and how much
190
+ // of it shows is the SURFACE's own opacity covering it — the same
191
+ // independence the grid won earlier. The branch is on a uniform, so the
192
+ // whole cost vanishes for the scenes that leave it off.
193
+ var reflShadowed = vec3f(0.0);
194
+ if (material.mirror > 0.0) {
195
+ let mc = mirrorVP.viewProj * vec4f(i.worldPos, 1.0);
196
+ let mndc = mc.xyz / max(mc.w, 1e-6);
197
+ let muv = clamp(vec2f(mndc.x * 0.5 + 0.5, 0.5 - mndc.y * 0.5), vec2f(0.0), vec2f(1.0));
198
+ var lod = 0.0;
199
+ if (material.mirrorBlur > 0.0) {
200
+ // DEPTH-PROPORTIONAL softness: the real driver is how far BEHIND the
201
+ // mirror surface the reflected geometry sits — a foot on the floor
202
+ // reflects sharp, a head reflects soft, the empty backdrop softest.
203
+ //
204
+ // Read the mirror pass's own depth at this texel, linearise it with the
205
+ // shared projection's pair (viewZ = projB / (z - projA); the formula
206
+ // inverts both depth conventions, see the composite's linearDepth), and
207
+ // reconstruct the reflected image point along the ray from the MIRRORED
208
+ // eye through this fragment. The plane is y = 0, so mirroring the eye
209
+ // and the camera forward is a sign flip on y, and the image point's
210
+ // depth below the plane IS the reflected object's height above it.
211
+ let dims = vec2f(textureDimensions(mirrorDepth));
212
+ let texel = clamp(vec2i(muv * dims), vec2i(0), vec2i(dims) - vec2i(1));
213
+ let z = textureLoad(mirrorDepth, texel, 0);
214
+ let viewZ = clamp(mirrorVP.params.y / (z - mirrorVP.params.x), 0.05, 100000.0);
215
+ let eyeM = vec3f(camera.viewPos.x, -camera.viewPos.y, camera.viewPos.z);
216
+ let fwd = vec3f(camera.view[0][2], camera.view[1][2], camera.view[2][2]);
217
+ let fwdM = vec3f(fwd.x, -fwd.y, fwd.z);
218
+ let toFrag = i.worldPos - eyeM;
219
+ let dir = toFrag * inverseSqrt(max(dot(toFrag, toFrag), 1e-8));
220
+ let t = viewZ / max(dot(dir, fwdM), 1e-4);
221
+ let height = max(-(eyeM.y + dir.y * t), 0.0);
222
+ // Full softness by BLUR_SPAN units above the floor — about a character's
223
+ // height. The hardware clamps the level, so the nominal 5.0 needs no
224
+ // knowledge of the real chain length, and blur 0 reads exactly level 0.
225
+ let BLUR_SPAN = 14.0;
226
+ lod = material.mirrorBlur * 5.0 * clamp(height / BLUR_SPAN, 0.0, 1.0);
227
+ }
228
+ let refl = textureSampleLevel(mirrorTex, linearSampler, muv, lod).rgb;
229
+ // Still under the received shadow: a polished floor in shade shows a dim
230
+ // reflection, and a mirror that ignored the shadow would glow in it.
231
+ reflShadowed = refl * (1.0 - dark * 0.65);
232
+ }
233
+ // THREE LAYERS, composited premultiplied, bottom to top: the shadow the
234
+ // catcher receives, the ground surface, and the grid.
235
+ //
236
+ // The grid used to be MIXED INTO the surface and then weighted by the
237
+ // surface's own alpha, so turning the ground down to nothing took the grid
238
+ // with it — you could not have a bare grid over a photo, which is most of
239
+ // what a grid is for. It is its own layer now: the ground plane keeps an
240
+ // alpha, and the grid is simply on or off.
135
241
  let surfA = edgeFade * material.opacity;
136
- let catchA = dark * 0.65 * edgeFade * (1.0 - material.opacity);
137
- let outA = surfA + catchA;
138
- out.color = vec4f(finalColor * surfA, outA);
242
+ let gridA = gridLine * material.gridLineOpacity * edgeFade;
243
+ // The mirror is opaque across the ground's extent when on — it always shows
244
+ // SOMETHING, because the backdrop rides in the reflection target itself.
245
+ let mirrA = material.mirror * edgeFade;
246
+ // As opacity drops the received shadow becomes a translucent dark layer
247
+ // (Blender's Shadow Catcher), so models still feel grounded on a photo or a
248
+ // 360 backdrop. Colourless by construction: it darkens by covering. The
249
+ // MIRROR subsumes it: the reflection already carries the received shadow,
250
+ // and a catcher stacked on top would darken the same shadow twice.
251
+ let catchA = dark * 0.65 * edgeFade * (1.0 - material.opacity) * (1.0 - material.mirror);
252
+
253
+ // FOUR LAYERS, premultiplied, bottom to top: reflection, received shadow,
254
+ // ground surface, grid. Each with its own coverage, none scaling another —
255
+ // ground opacity REVEALS the mirror rather than the mirror carrying a
256
+ // strength of its own.
257
+ var pm = reflShadowed * mirrA;
258
+ var cov = mirrA;
259
+ // Shadow catcher over the reflection-or-nothing: black, it covers.
260
+ pm = pm * (1.0 - catchA);
261
+ cov = catchA + cov * (1.0 - catchA);
262
+ // Surface over shadow.
263
+ pm = baseColor * surfA + pm * (1.0 - surfA);
264
+ cov = surfA + cov * (1.0 - surfA);
265
+ // Grid over surface. Unshadowed, as it has always been — a painted line reads
266
+ // as paint, and darkening it was never what the mix did either.
267
+ pm = material.gridLineColor * gridA + pm * (1.0 - gridA);
268
+ cov = gridA + cov * (1.0 - gridA);
269
+
270
+ let outA = cov;
271
+ out.color = vec4f(pm, outA);
139
272
  // mask.r = 0: ground never contributes to bloom. mask.g = 1.0 with src.a =
140
273
  // outA turns the aux blend into alpha-over, so the drawable alpha goes
141
274
  // from outA at the center to 0 at the radial edge — letting the page
142
275
  // background show through under the premultiplied canvas alphaMode.
143
276
  out.mask = vec4f(0.0, 1.0, 0.0, outA);
144
- return out;
277
+ ${sceneIdWriteWgsl("out", `${GROUND_MATERIAL_ID}u`, `${GROUND_OBJECT_ID}u`)} return out;
145
278
  }
146
279
  `
280
+ }
281
+
282
+ /**
283
+ * The ground belongs to no model instance, so it takes ids of its own — at the
284
+ * TOP of the u16 range, not at the bottom.
285
+ *
286
+ * Both of the engine's counters are 1-based (model id is instance count + 1,
287
+ * material id counts from one), so 1 is the first model's first material and
288
+ * the floor would have shared it. 0 stays unclaimed in the other direction: it
289
+ * is what the attachment clears to, and so what every pixel nothing drew
290
+ * reports.
291
+ */
292
+ export const GROUND_MATERIAL_ID = 0xffff
293
+ export const GROUND_OBJECT_ID = 0xffff
@@ -0,0 +1,171 @@
1
+ // The half of the effect API whose VALUES are module-local.
2
+ //
3
+ // An effect file is spliced WHOLE into every module it has a mount in. A file
4
+ // with a trail and a lightEmit compiles its trail code inside the light module,
5
+ // where it never runs but still has to resolve — so `rzTime()` has to exist
6
+ // there even though a compute pass that writes lamp positions has no obvious
7
+ // clock, and `rzFalloff()` has to exist there even though nothing calls it.
8
+ //
9
+ // EFFECT_SCENE_API (composite.ts) is the other half: names that mean the same
10
+ // thing everywhere, written once. These are the ones that cannot be, because
11
+ // each module keeps its clock and its viewport in a different uniform. So they
12
+ // are written once HERE and the expression is the parameter — which is the
13
+ // same trick the grid pass already used for one of them, generalised to all.
14
+ //
15
+ // The rule this file exists to make keepable: a hosting module includes every
16
+ // block below. Not the ones its own mount happens to need — every one, because
17
+ // what lands in it is the author's whole file.
18
+ //
19
+ // EXCEPT that EFFECT_SCENE_API already carries EFFECT_MATH_API and the Particle
20
+ // struct, so the three modules built on it take those two by inheritance and
21
+ // must not add them again. tests/hosted-api.test.mjs fails on a name declared
22
+ // twice for exactly this reason — it is the mistake sharing blocks invites.
23
+
24
+ /**
25
+ * The particle record, laid out by hand — declared in EVERY hosting module.
26
+ *
27
+ * Not only in the particle ones: `fn particleStep(p: Particle)` is part of the
28
+ * author's file, so the trail, field and lightEmit modules compile that
29
+ * signature as dead code and a missing struct is a compile error on a function
30
+ * the author was right to write. The trail module used to carry a hand-copied
31
+ * duplicate for exactly this reason.
32
+ *
33
+ * `age` and `life` sit in the padding that vec3f alignment would waste anyway
34
+ * (a vec3f occupies 12 bytes but aligns the next field to 16), so the struct is
35
+ * 48 bytes rather than the 64 a naive ordering costs. At 4096 particles that is
36
+ * 192KB instead of 256KB, and it is read every frame by both stages.
37
+ *
38
+ * `life <= 0` means "not alive" and is what the pool checks to recycle a slot,
39
+ * so a freshly zeroed buffer is entirely dead and every particle is born on the
40
+ * first step rather than needing a separate seeding pass.
41
+ *
42
+ * RzLight is the same kind of thing and lives in lights.ts, because it is that
43
+ * module's own type; every hosting module splices it for this same reason.
44
+ */
45
+ export const PARTICLE_STRUCT_WGSL = /* wgsl */ `
46
+ struct Particle {
47
+ pos: vec3f,
48
+ age: f32,
49
+ vel: vec3f,
50
+ life: f32,
51
+ size: f32,
52
+ rot: f32,
53
+ seed: f32,
54
+ // Aspect along the direction of travel. 1 or less is a square billboard; a
55
+ // raindrop is 10 or 20. Zero-initialised, so an effect that never sets it gets
56
+ // the square it expects.
57
+ stretch: f32,
58
+ }
59
+ `
60
+
61
+ /**
62
+ * `rzTime`/`rzDt`, from whatever the module calls its clock.
63
+ *
64
+ * A module with no clock of its own passes a literal. Zero dt is honest there:
65
+ * nothing spliced into it is being stepped, so a kernel that integrates would
66
+ * integrate by nothing rather than by a plausible-looking wrong number.
67
+ */
68
+ export function clockApi(timeExpr: string, dtExpr: string): string {
69
+ return /* wgsl */ `
70
+ fn rzTime() -> f32 { return ${timeExpr}; }
71
+ fn rzDt() -> f32 { return ${dtExpr}; }
72
+ `
73
+ }
74
+
75
+ /** `rzViewportHeight` — the render target's height in pixels, which is what a
76
+ * point size in world units has to be divided by to become a pixel radius. */
77
+ export function viewportApi(heightExpr: string): string {
78
+ return /* wgsl */ `
79
+ fn rzViewportHeight() -> f32 { return ${heightExpr}; }
80
+ `
81
+ }
82
+
83
+ /**
84
+ * The one cast number that is PER EFFECT rather than per engine.
85
+ *
86
+ * Author-visible, and published effects loop over it, so its meaning is pinned:
87
+ * how many of THIS effect's anchors asked for a trail. Deliberately not the
88
+ * anchor address space — that is RZ_MAX_ANCHORS in CAST_API, and the two being
89
+ * one number was the old trail bug. Everything else about the cast's shape is a
90
+ * constant and lives there.
91
+ */
92
+ export function trailSlotsApi(trailCount: number): string {
93
+ return /* wgsl */ `
94
+ const RZ_TRAIL_SLOTS: i32 = ${trailCount};
95
+ `
96
+ }
97
+
98
+ /**
99
+ * Pure math — no bindings, no uniforms, identical in every module.
100
+ *
101
+ * These were duplicated in the particle and trail modules, byte for byte in the
102
+ * case of rzFalloff and to a `+ vec3f(0.0)` in the case of rzValueNoise. Two
103
+ * copies of a noise function is one bad merge away from an effect that looks
104
+ * different depending on which mount drew it, which is not a bug anyone would
105
+ * think to look for.
106
+ *
107
+ * EVERY hosting module includes this and none defines any of these itself, so
108
+ * "which module am I in" can never change what a helper returns.
109
+ */
110
+ export const EFFECT_MATH_API = /* wgsl */ `
111
+ fn rzHash11(x: f32) -> f32 {
112
+ var p = fract(x * 0.1031);
113
+ p = p * (p + 33.33);
114
+ return fract(p * (p + p));
115
+ }
116
+ fn rzHash21(p: vec2f) -> f32 {
117
+ var p3 = fract(vec3f(p.x, p.y, p.x) * 0.1031);
118
+ p3 = p3 + dot(p3, p3.yzx + 33.33);
119
+ return fract((p3.x + p3.y) * p3.z);
120
+ }
121
+ /** Three independent randoms from one seed — the usual need when spawning. */
122
+ fn rzHash13(x: f32) -> vec3f {
123
+ return vec3f(rzHash11(x), rzHash11(x + 17.13), rzHash11(x + 41.71));
124
+ }
125
+ /**
126
+ * Compact-support falloff: 1 at the centre, exactly 0 at r, smooth between.
127
+ *
128
+ * It reaches exactly zero rather than merely getting small, because a glow that
129
+ * never quite ends has to be culled somewhere, and culling it wherever it looks
130
+ * close enough is what put a visible hard edge on the first halo effect.
131
+ */
132
+ fn rzFalloff(d: f32, r: f32) -> f32 {
133
+ let x = clamp(d / max(r, 1e-6), 0.0, 1.0);
134
+ let f = 1.0 - x;
135
+ return f * f * f;
136
+ }
137
+ fn rzHash31(p: vec3f) -> f32 {
138
+ var p3 = fract(p * 0.1031);
139
+ p3 = p3 + dot(p3, p3.zyx + 31.32);
140
+ return fract((p3.x + p3.y) * p3.z);
141
+ }
142
+ fn rzValueNoise(p: vec3f) -> f32 {
143
+ let i = floor(p);
144
+ let f = fract(p);
145
+ let u = f * f * (3.0 - 2.0 * f);
146
+ let n000 = rzHash31(i);
147
+ let n100 = rzHash31(i + vec3f(1.0, 0.0, 0.0));
148
+ let n010 = rzHash31(i + vec3f(0.0, 1.0, 0.0));
149
+ let n110 = rzHash31(i + vec3f(1.0, 1.0, 0.0));
150
+ let n001 = rzHash31(i + vec3f(0.0, 0.0, 1.0));
151
+ let n101 = rzHash31(i + vec3f(1.0, 0.0, 1.0));
152
+ let n011 = rzHash31(i + vec3f(0.0, 1.0, 1.0));
153
+ let n111 = rzHash31(i + vec3f(1.0, 1.0, 1.0));
154
+ let x00 = mix(n000, n100, u.x);
155
+ let x10 = mix(n010, n110, u.x);
156
+ let x01 = mix(n001, n101, u.x);
157
+ let x11 = mix(n011, n111, u.x);
158
+ return mix(mix(x00, x10, u.y), mix(x01, x11, u.y), u.z);
159
+ }
160
+ /** Divergence-free flow — the field a wisp of smoke follows without a solver. */
161
+ fn rzCurlNoise(p: vec3f) -> vec3f {
162
+ let e = 0.1;
163
+ let dx = vec3f(e, 0.0, 0.0);
164
+ let dy = vec3f(0.0, e, 0.0);
165
+ let dz = vec3f(0.0, 0.0, e);
166
+ let x0 = rzValueNoise(p - dx); let x1 = rzValueNoise(p + dx);
167
+ let y0 = rzValueNoise(p - dy); let y1 = rzValueNoise(p + dy);
168
+ let z0 = rzValueNoise(p - dz); let z1 = rzValueNoise(p + dz);
169
+ return normalize(vec3f((y1 - y0) - (z1 - z0), (z1 - z0) - (x1 - x0), (x1 - x0) - (y1 - y0)) + vec3f(1e-6));
170
+ }
171
+ `