reze-engine 0.64.0 → 0.66.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 (173) hide show
  1. package/dist/engine.d.ts +300 -94
  2. package/dist/engine.d.ts.map +1 -1
  3. package/dist/engine.js +1628 -787
  4. package/dist/graph/compile.d.ts.map +1 -1
  5. package/dist/graph/compile.js +5 -0
  6. package/dist/graph/presets/body.d.ts.map +1 -1
  7. package/dist/graph/presets/body.js +26 -24
  8. package/dist/graph/presets/cloth_rough.js +8 -8
  9. package/dist/graph/presets/cloth_smooth.js +8 -8
  10. package/dist/graph/presets/default.js +4 -4
  11. package/dist/graph/presets/eye.js +10 -10
  12. package/dist/graph/presets/face.d.ts.map +1 -1
  13. package/dist/graph/presets/face.js +28 -26
  14. package/dist/graph/presets/hair.js +6 -6
  15. package/dist/graph/presets/metal.js +8 -8
  16. package/dist/graph/presets/stockings.d.ts.map +1 -1
  17. package/dist/graph/presets/stockings.js +4 -11
  18. package/dist/graph/presets/unlit.d.ts.map +1 -1
  19. package/dist/graph/presets/unlit.js +9 -13
  20. package/dist/graph/registry.d.ts.map +1 -1
  21. package/dist/graph/registry.js +128 -157
  22. package/dist/graph/render-class.d.ts +5 -2
  23. package/dist/graph/render-class.d.ts.map +1 -1
  24. package/dist/graph/slots.d.ts.map +1 -1
  25. package/dist/graph/slots.js +12 -2
  26. package/dist/index.d.ts +1 -1
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/model.d.ts +23 -1
  29. package/dist/model.d.ts.map +1 -1
  30. package/dist/model.js +41 -1
  31. package/dist/outline-normals.d.ts +12 -0
  32. package/dist/outline-normals.d.ts.map +1 -0
  33. package/dist/outline-normals.js +100 -0
  34. package/dist/pmx-loader.d.ts +7 -0
  35. package/dist/pmx-loader.d.ts.map +1 -1
  36. package/dist/pmx-loader.js +17 -3
  37. package/dist/shaders/anchor-table.d.ts +11 -0
  38. package/dist/shaders/anchor-table.d.ts.map +1 -1
  39. package/dist/shaders/anchor-table.js +9 -3
  40. package/dist/shaders/directives.d.ts +42 -5
  41. package/dist/shaders/directives.d.ts.map +1 -1
  42. package/dist/shaders/directives.js +51 -4
  43. package/dist/shaders/lights.d.ts +11 -7
  44. package/dist/shaders/lights.d.ts.map +1 -1
  45. package/dist/shaders/lights.js +315 -271
  46. package/dist/shaders/materials/common.d.ts.map +1 -1
  47. package/dist/shaders/materials/common.js +30 -5
  48. package/dist/shaders/materials/nodes.d.ts +1 -1
  49. package/dist/shaders/materials/nodes.d.ts.map +1 -1
  50. package/dist/shaders/materials/nodes.js +191 -271
  51. package/dist/shaders/passes/bloom.d.ts +4 -3
  52. package/dist/shaders/passes/bloom.d.ts.map +1 -1
  53. package/dist/shaders/passes/bloom.js +58 -87
  54. package/dist/shaders/passes/composite.d.ts +1 -1
  55. package/dist/shaders/passes/composite.d.ts.map +1 -1
  56. package/dist/shaders/passes/composite.js +121 -77
  57. package/dist/shaders/passes/ground.js +1 -1
  58. package/dist/shaders/passes/mirror.d.ts +18 -12
  59. package/dist/shaders/passes/mirror.d.ts.map +1 -1
  60. package/dist/shaders/passes/mirror.js +59 -13
  61. package/dist/shaders/passes/outline.d.ts.map +1 -1
  62. package/dist/shaders/passes/outline.js +71 -14
  63. package/dist/shaders/passes/particles.d.ts.map +1 -1
  64. package/dist/shaders/passes/particles.js +9 -3
  65. package/dist/shaders/passes/probe.d.ts +45 -0
  66. package/dist/shaders/passes/probe.d.ts.map +1 -0
  67. package/dist/shaders/passes/probe.js +230 -0
  68. package/dist/shaders/passes/scene-contract.d.ts +13 -0
  69. package/dist/shaders/passes/scene-contract.d.ts.map +1 -1
  70. package/dist/shaders/passes/scene-contract.js +4 -0
  71. package/dist/shaders/scene-light-api.d.ts.map +1 -1
  72. package/dist/shaders/scene-light-api.js +1 -0
  73. package/dist/unity/globals.d.ts +7 -0
  74. package/dist/unity/globals.d.ts.map +1 -1
  75. package/dist/unity/globals.js +4 -3
  76. package/dist/unity/host.d.ts +18 -0
  77. package/dist/unity/host.d.ts.map +1 -1
  78. package/dist/unity/host.js +144 -70
  79. package/dist/unity/looks.d.ts +47 -0
  80. package/dist/unity/looks.d.ts.map +1 -1
  81. package/dist/unity/looks.js +150 -6
  82. package/dist/unity/skin.d.ts.map +1 -1
  83. package/dist/unity/skin.js +5 -2
  84. package/dist/unity/stage.d.ts +24 -0
  85. package/dist/unity/stage.d.ts.map +1 -1
  86. package/dist/unity/stage.js +59 -7
  87. package/package.json +1 -1
  88. package/src/engine.ts +2010 -1033
  89. package/src/graph/compile.ts +4 -0
  90. package/src/graph/presets/body.ts +26 -24
  91. package/src/graph/presets/cloth_rough.ts +8 -8
  92. package/src/graph/presets/cloth_smooth.ts +8 -8
  93. package/src/graph/presets/default.ts +4 -4
  94. package/src/graph/presets/eye.ts +10 -10
  95. package/src/graph/presets/face.ts +28 -26
  96. package/src/graph/presets/hair.ts +6 -6
  97. package/src/graph/presets/metal.ts +8 -8
  98. package/src/graph/presets/stockings.ts +4 -11
  99. package/src/graph/presets/unlit.ts +9 -13
  100. package/src/graph/registry.ts +133 -158
  101. package/src/graph/render-class.ts +5 -2
  102. package/src/graph/slots.ts +12 -2
  103. package/src/index.ts +1 -0
  104. package/src/model.ts +50 -1
  105. package/src/outline-normals.ts +110 -0
  106. package/src/pmx-loader.ts +18 -3
  107. package/src/shaders/anchor-table.ts +15 -3
  108. package/src/shaders/directives.ts +88 -6
  109. package/src/shaders/lights.ts +464 -415
  110. package/src/shaders/materials/common.ts +30 -5
  111. package/src/shaders/materials/nodes.ts +191 -271
  112. package/src/shaders/passes/bloom.ts +59 -87
  113. package/src/shaders/passes/composite.ts +121 -77
  114. package/src/shaders/passes/ground.ts +1 -1
  115. package/src/shaders/passes/mirror.ts +60 -13
  116. package/src/shaders/passes/outline.ts +71 -14
  117. package/src/shaders/passes/particles.ts +9 -3
  118. package/src/shaders/passes/probe.ts +240 -0
  119. package/src/shaders/passes/scene-contract.ts +17 -0
  120. package/src/shaders/scene-light-api.ts +1 -0
  121. package/src/unity/globals.ts +7 -3
  122. package/src/unity/host.ts +93 -20
  123. package/src/unity/looks.ts +182 -9
  124. package/src/unity/skin.ts +5 -2
  125. package/src/unity/stage.ts +66 -7
  126. package/dist/physics-debug.d.ts +0 -30
  127. package/dist/physics-debug.d.ts.map +0 -1
  128. package/dist/physics-debug.js +0 -526
  129. package/dist/shaders/agx-lut.d.ts +0 -11
  130. package/dist/shaders/agx-lut.d.ts.map +0 -1
  131. package/dist/shaders/agx-lut.js +0 -29
  132. package/dist/shaders/dfg_lut.d.ts +0 -3
  133. package/dist/shaders/dfg_lut.d.ts.map +0 -1
  134. package/dist/shaders/dfg_lut.js +0 -129
  135. package/dist/shaders/ltc_mag_lut.d.ts +0 -3
  136. package/dist/shaders/ltc_mag_lut.d.ts.map +0 -1
  137. package/dist/shaders/ltc_mag_lut.js +0 -1033
  138. package/dist/shaders/materials/body.d.ts +0 -2
  139. package/dist/shaders/materials/body.d.ts.map +0 -1
  140. package/dist/shaders/materials/body.js +0 -95
  141. package/dist/shaders/materials/cloth_rough.d.ts +0 -2
  142. package/dist/shaders/materials/cloth_rough.d.ts.map +0 -1
  143. package/dist/shaders/materials/cloth_rough.js +0 -69
  144. package/dist/shaders/materials/cloth_smooth.d.ts +0 -2
  145. package/dist/shaders/materials/cloth_smooth.d.ts.map +0 -1
  146. package/dist/shaders/materials/cloth_smooth.js +0 -61
  147. package/dist/shaders/materials/default.d.ts +0 -2
  148. package/dist/shaders/materials/default.d.ts.map +0 -1
  149. package/dist/shaders/materials/default.js +0 -43
  150. package/dist/shaders/materials/eye.d.ts +0 -2
  151. package/dist/shaders/materials/eye.d.ts.map +0 -1
  152. package/dist/shaders/materials/eye.js +0 -60
  153. package/dist/shaders/materials/face.d.ts +0 -2
  154. package/dist/shaders/materials/face.d.ts.map +0 -1
  155. package/dist/shaders/materials/face.js +0 -95
  156. package/dist/shaders/materials/hair.d.ts +0 -2
  157. package/dist/shaders/materials/hair.d.ts.map +0 -1
  158. package/dist/shaders/materials/hair.js +0 -90
  159. package/dist/shaders/materials/metal.d.ts +0 -2
  160. package/dist/shaders/materials/metal.d.ts.map +0 -1
  161. package/dist/shaders/materials/metal.js +0 -77
  162. package/dist/shaders/materials/mmd_classic.d.ts +0 -2
  163. package/dist/shaders/materials/mmd_classic.d.ts.map +0 -1
  164. package/dist/shaders/materials/mmd_classic.js +0 -66
  165. package/dist/shaders/materials/stockings.d.ts +0 -2
  166. package/dist/shaders/materials/stockings.d.ts.map +0 -1
  167. package/dist/shaders/materials/stockings.js +0 -122
  168. package/dist/shaders/passes/physics-debug.d.ts +0 -2
  169. package/dist/shaders/passes/physics-debug.d.ts.map +0 -1
  170. package/dist/shaders/passes/physics-debug.js +0 -69
  171. package/src/shaders/agx-lut.ts +0 -30
  172. package/src/shaders/dfg_lut.ts +0 -131
  173. package/src/shaders/ltc_mag_lut.ts +0 -1035
@@ -1,7 +1,7 @@
1
- // EEVEE 3.6 bloom pyramid: blit (Karis prefilter) → 13-tap downsamples → 9-tap tent upsamples.
2
- // Mirrors source/blender/draw/engines/eevee/shaders/effect_bloom_frag.glsl. Firefly suppression
3
- // lives in the blit (Karis luminance-weighted 4-tap average). A single-pass Gaussian cannot
4
- // reproduce this — hot pixels dominate and produce the sparkle halo.
1
+ // Bloom — Aether Gazer's own chain (AGSimPostFX.Bloom, URP-style): a soft-knee
2
+ // prefilter to half size, a separable Gaussian down to a few pixels, then back
3
+ // up level by level, each blend leaning toward the coarser level by `scatter`.
4
+ // The composite adds the result × tint × intensity before the view transform.
5
5
 
6
6
  const FULLSCREEN_VS = /* wgsl */ `
7
7
  @vertex fn vs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
@@ -11,114 +11,86 @@ const FULLSCREEN_VS = /* wgsl */ `
11
11
  }
12
12
  `
13
13
 
14
- // Full-res HDR → half-res. Karis 4-tap firefly average + EEVEE quadratic knee threshold + clamp.
15
- export const BLOOM_BLIT_SHADER_WGSL = `${FULLSCREEN_VS}
14
+ // Full-res HDR → half-res. The game takes ONE bilinear sample at the half-res
15
+ // pixel's centre, which is the 2×2 box of the block under it; here the four
16
+ // texels are loaded so each can be un-premultiplied (the scene target is
17
+ // premultiplied; its alpha lives in the aux target's .g) and gated by the bloom
18
+ // mask (.r) first.
19
+ export const BLOOM_PREFILTER_SHADER_WGSL = `${FULLSCREEN_VS}
16
20
  @group(0) @binding(0) var hdrTex: texture_2d<f32>;
17
- @group(0) @binding(1) var<uniform> prefilter: vec4<f32>; // threshold, knee, clamp, _unused
21
+ @group(0) @binding(1) var<uniform> params: vec4<f32>; // threshold, knee, clamp, scatter
18
22
  @group(0) @binding(2) var maskTex: texture_2d<f32>;
19
23
 
20
- fn luminance(c: vec3f) -> f32 {
21
- return dot(max(c, vec3f(0.0)), vec3f(0.2126, 0.7152, 0.0722));
22
- }
23
- fn fetch(c: vec2<i32>, clampV: f32) -> vec3f {
24
+ fn fetch(c: vec2<i32>) -> vec3f {
24
25
  let d = vec2<i32>(textureDimensions(hdrTex));
25
26
  let cc = clamp(c, vec2<i32>(0), d - vec2<i32>(1));
26
- let s = textureLoad(hdrTex, cc, 0);
27
- // hdrTex is rg11b10ufloat (no alpha channel). Alpha lives in maskTex.g, written
28
- // alongside the bloom mask in .r by the scene pass. Scene uses src-alpha blend
29
- // with clear alpha 0 → hdr rgb is premultiplied; divide by the aux alpha to
30
- // recover straight color before the Karis luminance weighting.
31
27
  let aux = textureLoad(maskTex, cc, 0);
32
- let mask = aux.r;
33
- let rgb = max(s.rgb / max(aux.g, 1e-6), vec3f(0.0));
34
- let masked = rgb * mask;
35
- // Blender clamps each tap BEFORE Karis average (eevee_bloom: color = min(clampIntensity, color)).
36
- return select(masked, min(masked, vec3f(clampV)), clampV > 0.0);
28
+ return max(textureLoad(hdrTex, cc, 0).rgb / max(aux.g, 1e-6), vec3f(0.0)) * aux.r;
37
29
  }
38
30
 
39
31
  @fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
40
- let dst = vec2<i32>(p.xy - vec2f(0.5));
41
- let base = dst * 2;
42
- let clampV = prefilter.z;
43
- let a = fetch(base + vec2<i32>(0, 0), clampV);
44
- let b = fetch(base + vec2<i32>(1, 0), clampV);
45
- let c = fetch(base + vec2<i32>(0, 1), clampV);
46
- let d = fetch(base + vec2<i32>(1, 1), clampV);
47
- // Karis partial average: weight each tap by 1/(1+luma) — suppresses fireflies.
48
- let wa = 1.0 / (1.0 + luminance(a));
49
- let wb = 1.0 / (1.0 + luminance(b));
50
- let wc = 1.0 / (1.0 + luminance(c));
51
- let wd = 1.0 / (1.0 + luminance(d));
52
- let avg = (a * wa + b * wb + c * wc + d * wd) / max(wa + wb + wc + wd, 1e-6);
53
- // EEVEE quadratic threshold (brightness = max-channel, then soft-knee curve).
54
- let bright = max(avg.r, max(avg.g, avg.b));
55
- let soft = clamp(bright - prefilter.x + prefilter.y, 0.0, 2.0 * prefilter.y);
56
- let q = (soft * soft) / (4.0 * max(prefilter.y, 1e-4) + 1e-6);
57
- let contrib = max(q, bright - prefilter.x) / max(bright, 1e-4);
58
- return vec4f(max(avg * contrib, vec3f(0.0)), 1.0);
32
+ let base = vec2<i32>(p.xy - vec2f(0.5)) * 2;
33
+ let avg = (fetch(base) + fetch(base + vec2<i32>(1, 0)) + fetch(base + vec2<i32>(0, 1)) + fetch(base + vec2<i32>(1, 1))) * 0.25;
34
+ let c = min(avg, vec3f(params.z));
35
+ // URP's quadratic soft knee on the brightest channel.
36
+ let bright = max(c.r, max(c.g, c.b));
37
+ let knee = params.y;
38
+ let soft = clamp(bright - params.x + knee, 0.0, 2.0 * knee);
39
+ let curve = soft * soft / (4.0 * knee + 1e-4);
40
+ return vec4f(c * (max(curve, bright - params.x) / max(bright, 1e-4)), 1.0);
59
41
  }
60
42
  `
61
43
 
62
- // Jimenez/COD 13-tap dual-box — 5 weighted 2×2 averages, rejects nyquist ringing.
63
- export const BLOOM_DOWNSAMPLE_SHADER_WGSL = `${FULLSCREEN_VS}
44
+ // Horizontal 9-tap Gaussian (σ ≈ 2 at the destination's scale), its taps two
45
+ // SOURCE texels apart: the destination is half the source, so this both halves
46
+ // and blurs. Each tap lands on a texel boundary, so bilinear averages pairs.
47
+ export const BLOOM_BLUR_H_SHADER_WGSL = `${FULLSCREEN_VS}
64
48
  @group(0) @binding(0) var srcTex: texture_2d<f32>;
65
49
  @group(0) @binding(1) var srcSamp: sampler;
66
50
 
67
- fn samp(uv: vec2f, off: vec2f) -> vec3f {
68
- return textureSampleLevel(srcTex, srcSamp, uv + off, 0.0).rgb;
51
+ @fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
52
+ let srcDims = vec2f(textureDimensions(srcTex));
53
+ let dstDims = max(floor(srcDims * 0.5), vec2f(1.0));
54
+ let uv = p.xy / dstDims;
55
+ let dx = vec2f(2.0 / srcDims.x, 0.0);
56
+ var w = array<f32, 5>(0.22702703, 0.19459459, 0.12162162, 0.05405405, 0.01621622);
57
+ var o = textureSampleLevel(srcTex, srcSamp, uv, 0.0).rgb * w[0];
58
+ for (var i = 1; i < 5; i++) {
59
+ let off = dx * f32(i);
60
+ o += (textureSampleLevel(srcTex, srcSamp, uv - off, 0.0).rgb + textureSampleLevel(srcTex, srcSamp, uv + off, 0.0).rgb) * w[i];
61
+ }
62
+ return vec4f(o, 1.0);
69
63
  }
64
+ `
65
+
66
+ // Vertical pass at the same size: the same 9-tap Gaussian as five bilinear taps.
67
+ export const BLOOM_BLUR_V_SHADER_WGSL = `${FULLSCREEN_VS}
68
+ @group(0) @binding(0) var srcTex: texture_2d<f32>;
69
+ @group(0) @binding(1) var srcSamp: sampler;
70
70
 
71
71
  @fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
72
- let srcDims = vec2f(textureDimensions(srcTex));
73
- let t = 1.0 / srcDims;
74
- // fragCoord.xy reports pixel centers (e.g. 0.5,0.5 for first pixel) — divide by dst dims directly.
75
- let dstDims = srcDims * 0.5;
76
- let uv = p.xy / max(dstDims, vec2f(1.0));
77
- let A = samp(uv, t * vec2f(-2.0, -2.0));
78
- let B = samp(uv, t * vec2f( 0.0, -2.0));
79
- let C = samp(uv, t * vec2f( 2.0, -2.0));
80
- let D = samp(uv, t * vec2f(-1.0, -1.0));
81
- let E = samp(uv, t * vec2f( 1.0, -1.0));
82
- let F = samp(uv, t * vec2f(-2.0, 0.0));
83
- let G = samp(uv, t * vec2f( 0.0, 0.0));
84
- let H = samp(uv, t * vec2f( 2.0, 0.0));
85
- let I = samp(uv, t * vec2f(-1.0, 1.0));
86
- let J = samp(uv, t * vec2f( 1.0, 1.0));
87
- let K = samp(uv, t * vec2f(-2.0, 2.0));
88
- let L = samp(uv, t * vec2f( 0.0, 2.0));
89
- let M = samp(uv, t * vec2f( 2.0, 2.0));
90
- var o = (D + E + I + J) * (0.5 / 4.0);
91
- o = o + (A + B + G + F) * (0.125 / 4.0);
92
- o = o + (B + C + H + G) * (0.125 / 4.0);
93
- o = o + (F + G + L + K) * (0.125 / 4.0);
94
- o = o + (G + H + M + L) * (0.125 / 4.0);
72
+ let dims = vec2f(textureDimensions(srcTex));
73
+ let uv = p.xy / dims;
74
+ let near = vec2f(0.0, 1.3846154 / dims.y);
75
+ let far = vec2f(0.0, 3.2307692 / dims.y);
76
+ var o = textureSampleLevel(srcTex, srcSamp, uv, 0.0).rgb * 0.22702703;
77
+ o += (textureSampleLevel(srcTex, srcSamp, uv - near, 0.0).rgb + textureSampleLevel(srcTex, srcSamp, uv + near, 0.0).rgb) * 0.31621623;
78
+ o += (textureSampleLevel(srcTex, srcSamp, uv - far, 0.0).rgb + textureSampleLevel(srcTex, srcSamp, uv + far, 0.0).rgb) * 0.07027027;
95
79
  return vec4f(o, 1.0);
96
80
  }
97
81
  `
98
82
 
99
- // 9-tap tent, progressively added to matching downsample mip. Blender radius = sample scale.
83
+ // One level up: this level's blur leaned toward the coarser one by scatter.
100
84
  export const BLOOM_UPSAMPLE_SHADER_WGSL = `${FULLSCREEN_VS}
101
- @group(0) @binding(0) var srcTex: texture_2d<f32>; // coarser accumulator
102
- @group(0) @binding(1) var baseTex: texture_2d<f32>; // matching downsample mip
85
+ @group(0) @binding(0) var highTex: texture_2d<f32>; // down[i]
86
+ @group(0) @binding(1) var lowTex: texture_2d<f32>; // coarser: down[i+1] at the top, up[i+1] after
103
87
  @group(0) @binding(2) var srcSamp: sampler;
104
- @group(0) @binding(3) var<uniform> upU: vec4<f32>; // sampleScale, _, _, _
88
+ @group(0) @binding(3) var<uniform> params: vec4<f32>; // threshold, knee, clamp, scatter
105
89
 
106
90
  @fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
107
- let srcDims = vec2f(textureDimensions(srcTex));
108
- let baseDims = vec2f(textureDimensions(baseTex));
109
- let uv = p.xy / max(baseDims, vec2f(1.0));
110
- let t = upU.x / srcDims;
111
- var o = textureSampleLevel(srcTex, srcSamp, uv + t * vec2f(-1.0, -1.0), 0.0).rgb * 1.0;
112
- o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 0.0, -1.0), 0.0).rgb * 2.0;
113
- o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 1.0, -1.0), 0.0).rgb * 1.0;
114
- o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f(-1.0, 0.0), 0.0).rgb * 2.0;
115
- o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 0.0, 0.0), 0.0).rgb * 4.0;
116
- o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 1.0, 0.0), 0.0).rgb * 2.0;
117
- o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f(-1.0, 1.0), 0.0).rgb * 1.0;
118
- o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 0.0, 1.0), 0.0).rgb * 2.0;
119
- o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 1.0, 1.0), 0.0).rgb * 1.0;
120
- o = o * (1.0 / 16.0);
121
- let base = textureSampleLevel(baseTex, srcSamp, uv, 0.0).rgb;
122
- return vec4f(o + base, 1.0);
91
+ let uv = p.xy / vec2f(textureDimensions(highTex));
92
+ let high = textureSampleLevel(highTex, srcSamp, uv, 0.0).rgb;
93
+ let low = textureSampleLevel(lowTex, srcSamp, uv, 0.0).rgb;
94
+ return vec4f(mix(high, low, params.w), 1.0);
123
95
  }
124
96
  `
@@ -13,7 +13,7 @@ import { castDistanceApi, CAST_FIELD_DIV } from "./cast-distance"
13
13
  import { lyricsApi, lyricsTextApi } from "../lyrics-api"
14
14
  import { midiApi } from "../midi-api"
15
15
  import { gridReadApi } from "./grid"
16
- // Composite: HDR scene + bloom pyramid → Filmic tone map → gamma → swapchain.
16
+ // Composite: HDR scene + bloom → view transform (soft / neutral / aces / none) → gamma → swapchain.
17
17
  // Bloom tint/intensity applied at combine (EEVEE treats them as combine-stage params, not prefilter).
18
18
  //
19
19
  // The shader is a TEMPLATE: buildCompositeShader() emits either the base pass or
@@ -138,14 +138,6 @@ override APPLY_GAMMA: bool = true;
138
138
  // re-premultiply the tonemapped color for output so the premultiplied canvas
139
139
  // alphaMode composites the WebGPU surface over the page background correctly.
140
140
  @group(0) @binding(4) var maskTex: texture_2d<f32>;
141
- // Filmic tone curve baked to a WIDTH×1 r16float LUT (bakeFilmicLut on the CPU side).
142
- // Domain: log2(linear) mapped to [0,13]. Replaces the old array<f32,14> that was indexed
143
- // by a runtime u32 (a Metal-backend smell — dynamic local-array indexing lowers to a
144
- // per-invocation copy/switch) and interpolated piecewise-linearly (C0, so segment slope
145
- // discontinuities showed as Mach bands in smooth skin/shadow gradients). The LUT is a
146
- // monotone-cubic (Fritsch–Carlson) fit through the same 14 anchors — same values, C1
147
- // continuity kills the banding — sampled with hardware linear filtering.
148
- @group(0) @binding(5) var filmicLut: texture_2d<f32>;
149
141
  // viewU[0] = (exposure, invGamma, grain amount, grain seed); viewU[1] = (tint.rgb, intensity)
150
142
  // viewU[2] = (background.rgb, mode) — display-space sRGB, composited UNDER the
151
143
  // scene post-tonemap. BASE-layer mode: 0 transparent (DOM shows),
@@ -177,9 +169,6 @@ override APPLY_GAMMA: bool = true;
177
169
  // track the camera radius. Cleared depth (1.0) inverts to the far
178
170
  // plane, so empty sky reads as maximally defocused background.
179
171
  @group(0) @binding(9) var<uniform> dofU: array<vec4<f32>, 3>;
180
- // Blender's AgX, as the 57³ lookup it ships as rather than a reconstruction of
181
- // it. Sampled in the log-encoded E-Gamut space the cube expects — see agxTransform.
182
- @group(0) @binding(10) var agxLut: texture_3d<f32>;
183
172
  // The scene's own colour grade, as a cube — see _rzStageGrade. sRGB-encoded
184
173
  // storage, so what a sample returns is already linear.
185
174
  @group(0) @binding(12) var stageGradeLut: texture_3d<f32>;
@@ -215,8 +204,6 @@ override APPLY_GAMMA: bool = true;
215
204
  fn rzFieldMerge(top: vec4f, bot: vec4f) -> vec4f {
216
205
  return vec4f(top.rgb + bot.rgb * (1.0 - top.a), top.a + bot.a * (1.0 - top.a));
217
206
  }
218
- // Must match FILMIC_LUT_WIDTH in engine.ts (bakeFilmicLut).
219
- const FILMIC_LUT_W: f32 = 256.0;
220
207
 
221
208
  fn linearDepth(coord: vec2<i32>) -> f32 {
222
209
  let z = textureLoad(depthTex, coord, 0);
@@ -249,79 +236,138 @@ fn sceneSample(coord: vec2<i32>, fullSzI: vec2<i32>, fullSz: vec2f) -> vec4f {
249
236
  return vec4f((sHdr + sBloom) * sAlpha, sAlpha);
250
237
  }
251
238
 
252
- fn filmic(x: f32) -> f32 {
253
- // Reference checkpoints (Blender 3.6 Filmic MHC, sobotka/filmic-blender
254
- // look_medium-high-contrast.spi1d): linear 0.18 → ~0.395, linear 1.0 → ~0.83.
255
- // NOTE: version-pinned to Blender 3.6 — 4.x defaults to AgX, not Filmic.
256
- let t = clamp(log2(max(x, 1e-10)) + 10.0, 0.0, 13.0);
257
- // Map t∈[0,13] to the texel-center of baked sample j = t·(W-1)/13.
258
- let u = (t * (FILMIC_LUT_W - 1.0) / 13.0 + 0.5) / FILMIC_LUT_W;
259
- // textureSampleLevel (explicit LOD, no derivatives) is legal in non-uniform flow.
260
- return textureSampleLevel(filmicLut, bloomSamp, vec2f(u, 0.5), 0.0).r;
261
- }
262
-
263
- /** The sRGB display encoding — Blender's "Standard" view transform, which is what
264
- * NPR work uses: no film curve at all, so the colours a graph computes are the
265
- * colours that land. Not a 2.2 power law; sRGB has a linear toe. */
239
+ /** The sRGB display encoding — "none": no tone curve at all, so the colours a
240
+ * graph computes are the colours that land. Not a 2.2 power law; sRGB has a
241
+ * linear toe. Every transform below ends in it. */
266
242
  fn srgb_encode(x: f32) -> f32 {
267
243
  let c = max(x, 0.0);
268
244
  return select(1.055 * pow(c, 1.0 / 2.4) - 0.055, c * 12.92, c <= 0.0031308);
269
245
  }
270
246
 
271
247
  /**
272
- * AgX, the chain config.ocio states for "AgX Base Rec.1886" on an sRGB display:
273
- *
274
- * scene-linear Rec.709 → 3x3 into FilmLight E-Gamut
275
- * → log2 across 25 stops, [-12.47393, +12.5261] → 0..1
276
- * → the 57³ cube
277
- * → Rec.1886 (pure γ2.4) decoded, then re-encoded as sRGB for the swapchain
278
- *
279
- * The last step is not pedantry: Rec.1886 is a pure power law and sRGB has a
280
- * linear toe, so skipping it lifts the darkest few code values.
281
- *
282
- * Blender interpolates the cube tetrahedrally and a GPU sampler is trilinear.
283
- * The difference is small and confined to saturated gradients; if it ever shows,
284
- * tetrahedral is a dozen lines here.
248
+ * "soft" — Aether Gazer's own curve, from its final pass
249
+ * (Hidden/SimPipeline/Final, decompiled): (1 - e^(-exposure x))^contrast, at the
250
+ * 2.5 and 1.4 its scenes set, into a linear target the swapchain then encodes.
251
+ * The game's ACES branch is off in every scene measured.
285
252
  */
286
- fn agxTransform(c: vec3f) -> vec3f {
287
- let m = mat3x3<f32>(
288
- vec3f(0.55937113, 0.07622071, 0.06552670),
289
- vec3f(0.30478326, 0.78797183, 0.16454675),
290
- vec3f(0.13584556, 0.13580748, 0.76992653),
291
- );
292
- let e = m * max(c, vec3f(0.0));
293
- // The cube's domain: an unlit pixel would take log2 to -inf, so floor it at
294
- // the bottom stop rather than sampling outside the LUT.
295
- let t = clamp((log2(max(e, vec3f(1e-10))) + vec3f(12.47393)) / 25.0, vec3f(0.0), vec3f(1.0));
296
- // Half-texel inset, so the end stops are the cube's own values and not a blend
297
- // with the clamp-to-edge border.
298
- let n = 57.0;
299
- let uvw = t * ((n - 1.0) / n) + (0.5 / n);
300
- let formed = textureSampleLevel(agxLut, bloomSamp, uvw, 0.0).rgb;
301
- let linear = pow(max(formed, vec3f(0.0)), vec3f(2.4));
302
- return vec3f(srgb_encode(linear.r), srgb_encode(linear.g), srgb_encode(linear.b));
253
+ fn softTransform(c: vec3f) -> vec3f {
254
+ let t = pow(max(vec3f(1.0) - exp(-2.5 * max(c, vec3f(0.0))), vec3f(0.0)), vec3f(1.4));
255
+ return vec3f(srgb_encode(t.r), srgb_encode(t.g), srgb_encode(t.b));
256
+ }
257
+
258
+ fn _rzSrgbEncode3(c: vec3f) -> vec3f {
259
+ return vec3f(srgb_encode(c.r), srgb_encode(c.g), srgb_encode(c.b));
303
260
  }
304
261
 
305
262
  /**
306
- * Aether Gazer's own curve, from its final pass (Hidden/SimPipeline/Final,
307
- * decompiled): (1 - e^(-exposure x))^contrast, at the 2.5 and 1.4 its scenes
308
- * set, into a linear target the swapchain then encodes. The game's ACES branch
309
- * is off in every scene measured.
263
+ * "neutral" — Unity URP's Neutral tonemapper as the Khronos PBR Neutral
264
+ * operator, to the published formula: below the start of compression colour
265
+ * passes through (less a small toe offset); above it the peak rolls off toward
266
+ * 1 and desaturates in proportion to how much it lost.
310
267
  */
311
- fn agTransform(c: vec3f) -> vec3f {
312
- let t = pow(max(vec3f(1.0) - exp(-2.5 * max(c, vec3f(0.0))), vec3f(0.0)), vec3f(1.4));
313
- return vec3f(srgb_encode(t.r), srgb_encode(t.g), srgb_encode(t.b));
268
+ fn neutralTransform(color: vec3f) -> vec3f {
269
+ let startCompression = 0.8 - 0.04;
270
+ let desaturation = 0.15;
271
+ var c = max(color, vec3f(0.0));
272
+ let x = min(c.r, min(c.g, c.b));
273
+ let offset = select(0.04, x - 6.25 * x * x, x < 0.08);
274
+ c = c - vec3f(offset);
275
+ let peak = max(c.r, max(c.g, c.b));
276
+ if (peak >= startCompression) {
277
+ let d = 1.0 - startCompression;
278
+ let newPeak = 1.0 - d * d / (peak + d - startCompression);
279
+ c = c * (newPeak / peak);
280
+ let g = 1.0 - 1.0 / (desaturation * (peak - newPeak) + 1.0);
281
+ c = mix(c, vec3f(newPeak), g);
282
+ }
283
+ return _rzSrgbEncode3(clamp(c, vec3f(0.0), vec3f(1.0)));
284
+ }
285
+
286
+ // "aces" — Unity URP's ACES: unity_to_ACES then AcesTonemap, both from the Core
287
+ // RP's Color.hlsl (the branch URP compiles, not full ACES): the RRT's glow and
288
+ // red modifier and global desaturation, the luminance fit of RRT +
289
+ // ODT.Academy.RGBmonitor_100nits_dim, the dim-surround gamma, the ODT
290
+ // desaturation, then AP1 → XYZ → D60→D65 → Rec.709, saturated as URP's
291
+ // ApplyTonemap does. HLSL's row-major matrices are written as rows here.
292
+ fn _rzRows(r0: vec3f, r1: vec3f, r2: vec3f, v: vec3f) -> vec3f {
293
+ return vec3f(dot(r0, v), dot(r1, v), dot(r2, v));
294
+ }
295
+ fn _rzAp1ToXyz(v: vec3f) -> vec3f {
296
+ return _rzRows(vec3f(0.6624541811, 0.1340042065, 0.1561876870),
297
+ vec3f(0.2722287168, 0.6740817658, 0.0536895174),
298
+ vec3f(-0.0055746495, 0.0040607335, 1.0103391003), v);
299
+ }
300
+ fn _rzAp1Luma(v: vec3f) -> f32 {
301
+ return dot(v, vec3f(0.272229, 0.674082, 0.0536895));
302
+ }
303
+ fn acesTransform(color: vec3f) -> vec3f {
304
+ // unity_to_ACES: linear sRGB → AP0.
305
+ var aces = _rzRows(vec3f(0.4397010, 0.3829780, 0.1773350),
306
+ vec3f(0.0897923, 0.8134230, 0.0967616),
307
+ vec3f(0.0175440, 0.1115440, 0.8707040), max(color, vec3f(0.0)));
308
+ // Glow module.
309
+ let mi = min(aces.r, min(aces.g, aces.b));
310
+ let ma = max(aces.r, max(aces.g, aces.b));
311
+ let saturation = (max(ma, 1e-4) - max(mi, 1e-4)) / max(ma, 1e-2);
312
+ let k = max(aces.b * (aces.b - aces.g) + aces.g * (aces.g - aces.r) + aces.r * (aces.r - aces.b), 0.0);
313
+ let ycIn = (aces.b + aces.g + aces.r + 1.75 * sqrt(k)) / 3.0;
314
+ let sx = (saturation - 0.4) / 0.2;
315
+ let st = max(1.0 - abs(sx / 2.0), 0.0);
316
+ let s = (1.0 + select(-1.0, 1.0, sx >= 0.0) * (1.0 - st * st)) / 2.0;
317
+ let glowGain = 0.05 * s;
318
+ let glowMid = 0.08;
319
+ var glow = glowGain * (glowMid / max(ycIn, 1e-6) - 0.5);
320
+ if (ycIn <= 2.0 / 3.0 * glowMid) { glow = glowGain; }
321
+ if (ycIn >= 2.0 * glowMid) { glow = 0.0; }
322
+ aces = aces * (1.0 + glow);
323
+ // Red modifier.
324
+ var hue = 0.0;
325
+ if (!(aces.r == aces.g && aces.g == aces.b)) {
326
+ hue = degrees(atan2(sqrt(3.0) * (aces.g - aces.b), 2.0 * aces.r - aces.g - aces.b));
327
+ }
328
+ if (hue < 0.0) { hue = hue + 360.0; }
329
+ var centered = hue;
330
+ if (centered < -180.0) { centered = centered + 360.0; } else if (centered > 180.0) { centered = centered - 360.0; }
331
+ var hueWeight = smoothstep(0.0, 1.0, 1.0 - abs(2.0 * centered / 135.0));
332
+ hueWeight = hueWeight * hueWeight;
333
+ aces.r = aces.r + hueWeight * saturation * (0.03 - aces.r) * (1.0 - 0.82);
334
+ // AP0 → AP1 (ACEScg), then the RRT's global desaturation.
335
+ var cg = max(_rzRows(vec3f(1.4514393161, -0.2365107469, -0.2149285693),
336
+ vec3f(-0.0765537734, 1.1762296998, -0.0996759264),
337
+ vec3f(0.0083161484, -0.0060324498, 0.9977163014), aces), vec3f(0.0));
338
+ cg = mix(vec3f(_rzAp1Luma(cg)), cg, vec3f(0.96));
339
+ // The luminance fit of RRT + ODT.Academy.RGBmonitor_100nits_dim.
340
+ let post = (cg * (2.785085 * cg + 0.107772)) / (cg * (2.936045 * cg + 0.887122) + 0.806889);
341
+ // Dark → dim surround: Y^0.9811, in xyY.
342
+ var xyz = _rzAp1ToXyz(post);
343
+ let div = max(xyz.x + xyz.y + xyz.z, 1e-4);
344
+ let xyY = vec3f(xyz.x / div, xyz.y / div, pow(clamp(xyz.y, 0.0, 65504.0), 0.9811));
345
+ let m = xyY.z / max(xyY.y, 1e-4);
346
+ xyz = vec3f(xyY.x * m, xyY.z, (1.0 - xyY.x - xyY.y) * m);
347
+ var lin = _rzRows(vec3f(1.6410233797, -0.3248032942, -0.2364246952),
348
+ vec3f(-0.6636628587, 1.6153315917, 0.0167563477),
349
+ vec3f(0.0117218943, -0.0082844420, 0.9883948585), xyz);
350
+ // The ODT's desaturation, then to the display's primaries.
351
+ lin = mix(vec3f(_rzAp1Luma(lin)), lin, vec3f(0.93));
352
+ xyz = _rzRows(vec3f(0.98722400, -0.00611327, 0.0159533),
353
+ vec3f(-0.00759836, 1.00186000, 0.0053302),
354
+ vec3f(0.00307257, -0.00509595, 1.0816800), _rzAp1ToXyz(lin));
355
+ let rec709 = _rzRows(vec3f(3.2409699419, -1.5373831776, -0.4986107603),
356
+ vec3f(-0.9692436363, 1.8759675015, 0.0415550574),
357
+ vec3f(0.0556300797, -0.2039769589, 1.0569715142), xyz);
358
+ return _rzSrgbEncode3(clamp(rec709, vec3f(0.0), vec3f(1.0)));
314
359
  }
315
360
 
316
- /** Which display transform, chosen per frame at viewU[6].y (0 filmic, 1 standard, 2 agx, 3 aether-gazer).
317
- * A uniform branch rather than a pipeline variant: switching is rare, and both
318
- * arms are cheap enough that specialising the pipeline would buy nothing. */
361
+ /** Which display transform, chosen per frame at viewU[6].y (0 soft, 1 none,
362
+ * 2 neutral, 3 aces). A uniform branch rather than a pipeline variant: switching
363
+ * is rare, and every arm is cheap enough that specialising the pipeline would
364
+ * buy nothing. */
319
365
  fn viewTransform(c: vec3f) -> vec3f {
320
366
  let mode = viewU[6].y;
321
- if (mode > 2.5) { return agTransform(c); }
322
- if (mode > 1.5) { return agxTransform(c); }
323
- if (mode > 0.5) { return vec3f(srgb_encode(c.r), srgb_encode(c.g), srgb_encode(c.b)); }
324
- return vec3f(filmic(c.r), filmic(c.g), filmic(c.b));
367
+ if (mode > 2.5) { return acesTransform(c); }
368
+ if (mode > 1.5) { return neutralTransform(c); }
369
+ if (mode > 0.5) { return _rzSrgbEncode3(c); }
370
+ return softTransform(c);
325
371
  }
326
372
 
327
373
  fn _rzSrgbDecode(x: f32) -> f32 {
@@ -590,11 +636,9 @@ const COMPOSITE_BODY = /* wgsl */ `
590
636
  // ── Film grain, on the SCENE ONLY ─────────────────────────────────────────
591
637
  //
592
638
  // Applied here, before the background is composited under, so it rides on what
593
- // the engine drew and nothing else. That placement is the whole point when the
594
- // background is footage: the plate came off a real sensor and already carries
595
- // its own grain, and a second helping over the top would grade the photograph
596
- // rather than match it. A clean CG figure on a grainy plate is one of the
597
- // loudest tells there is — the noise gives it away long before the geometry.
639
+ // the engine drew and nothing else: a backdrop photo or video already carries
640
+ // its own grain, and a second helping over the top would grade the picture
641
+ // rather than match it.
598
642
  //
599
643
  // Multiplicative and weighted toward the mid-tones, which is how film behaves:
600
644
  // little grain in the blacks, and the highlights clip it off.
@@ -145,7 +145,7 @@ struct MirrorVP { viewProj: mat4x4f, params: vec4f, };
145
145
  struct MirrorClip { plane: vec4f, on: f32, _p0: f32, _p1: f32, _p2: f32, };
146
146
  @group(0) @binding(14) var<uniform> clip: MirrorClip;
147
147
  ${WORLD_AMBIENT_WGSL}
148
- ${lightsApi(0, 6, "1u")}
148
+ ${lightsApi(0, 6, "1u", { binding: 15, sampler: "linearSampler" })}
149
149
 
150
150
  struct VO { @builtin(position) position: vec4f, @location(0) worldPos: vec3f, @location(1) normal: vec3f, };
151
151
  @vertex fn vs(@location(0) position: vec3f, @location(1) normal: vec3f, @location(2) uv: vec2f) -> VO {
@@ -218,20 +218,52 @@ ${sceneIdPadWgsl("out")}
218
218
  }
219
219
 
220
220
  /**
221
- * The mirror in the SHADOW pass — the frame throwing shade on the floor.
222
- *
223
- * Its own shader because the mirror has no vertex buffer and no skeleton: the
224
- * quad is six generated vertices and the model matrix is the whole of it, while
225
- * the ordinary shadow shader is built for skinned geometry with an alpha test.
226
- *
227
- * Depth only, no fragment stage. The pane is opaque across its whole rectangle
228
- * — glass included, because this glass is not see-through — so there is nothing
229
- * to alpha-test and nothing to colour.
230
- *
231
- * The cascade index arrives in its own tiny uniform rather than as a dynamic
232
- * offset: the cascade matrices sit 64 bytes apart in one array, and a dynamic
233
- * uniform offset has to be 256-aligned.
221
+ * Downsample for the reflection's COLOUR chain, one pass per mip level: the
222
+ * Jimenez/COD 13-tap dual box — five weighted 2x2 averages, which rejects the
223
+ * nyquist ringing a plain box would leave in a blurred reflection.
234
224
  */
225
+ export const MIRROR_DOWNSAMPLE_WGSL = /* wgsl */ `
226
+ @vertex fn vs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
227
+ let x = f32((vi & 1u) << 2u) - 1.0;
228
+ let y = f32((vi & 2u) << 1u) - 1.0;
229
+ return vec4f(x, y, 0.0, 1.0);
230
+ }
231
+
232
+ @group(0) @binding(0) var srcTex: texture_2d<f32>;
233
+ @group(0) @binding(1) var srcSamp: sampler;
234
+
235
+ fn samp(uv: vec2f, off: vec2f) -> vec3f {
236
+ return textureSampleLevel(srcTex, srcSamp, uv + off, 0.0).rgb;
237
+ }
238
+
239
+ @fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
240
+ let srcDims = vec2f(textureDimensions(srcTex));
241
+ let t = 1.0 / srcDims;
242
+ // fragCoord.xy reports pixel centers (e.g. 0.5,0.5 for first pixel) — divide by dst dims directly.
243
+ let dstDims = srcDims * 0.5;
244
+ let uv = p.xy / max(dstDims, vec2f(1.0));
245
+ let A = samp(uv, t * vec2f(-2.0, -2.0));
246
+ let B = samp(uv, t * vec2f( 0.0, -2.0));
247
+ let C = samp(uv, t * vec2f( 2.0, -2.0));
248
+ let D = samp(uv, t * vec2f(-1.0, -1.0));
249
+ let E = samp(uv, t * vec2f( 1.0, -1.0));
250
+ let F = samp(uv, t * vec2f(-2.0, 0.0));
251
+ let G = samp(uv, t * vec2f( 0.0, 0.0));
252
+ let H = samp(uv, t * vec2f( 2.0, 0.0));
253
+ let I = samp(uv, t * vec2f(-1.0, 1.0));
254
+ let J = samp(uv, t * vec2f( 1.0, 1.0));
255
+ let K = samp(uv, t * vec2f(-2.0, 2.0));
256
+ let L = samp(uv, t * vec2f( 0.0, 2.0));
257
+ let M = samp(uv, t * vec2f( 2.0, 2.0));
258
+ var o = (D + E + I + J) * (0.5 / 4.0);
259
+ o = o + (A + B + G + F) * (0.125 / 4.0);
260
+ o = o + (B + C + H + G) * (0.125 / 4.0);
261
+ o = o + (F + G + L + K) * (0.125 / 4.0);
262
+ o = o + (G + H + M + L) * (0.125 / 4.0);
263
+ return vec4f(o, 1.0);
264
+ }
265
+ `
266
+
235
267
  /**
236
268
  * Downsample for the reflection's COVERAGE chain.
237
269
  *
@@ -263,6 +295,21 @@ struct VSOut { @builtin(position) pos: vec4f, @location(0) uv: vec2f, };
263
295
  }
264
296
  `
265
297
 
298
+ /**
299
+ * The mirror in the SHADOW pass — the frame throwing shade on the floor.
300
+ *
301
+ * Its own shader because the mirror has no vertex buffer and no skeleton: the
302
+ * quad is six generated vertices and the model matrix is the whole of it, while
303
+ * the ordinary shadow shader is built for skinned geometry with an alpha test.
304
+ *
305
+ * Depth only, no fragment stage. The pane is opaque across its whole rectangle
306
+ * — glass included, because this glass is not see-through — so there is nothing
307
+ * to alpha-test and nothing to colour.
308
+ *
309
+ * The cascade index arrives in its own tiny uniform rather than as a dynamic
310
+ * offset: the cascade matrices sit 64 bytes apart in one array, and a dynamic
311
+ * uniform offset has to be 256-aligned.
312
+ */
266
313
  export function mirrorShadowWgsl(cascades: number): string {
267
314
  return /* wgsl */ `
268
315
  struct LightVP { viewProj: array<mat4x4f, ${cascades}>, };