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
  const FULLSCREEN_VS = /* wgsl */ `
6
6
  @vertex fn vs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
7
7
  let x = f32((vi & 1u) << 2u) - 1.0;
@@ -9,112 +9,83 @@ const FULLSCREEN_VS = /* wgsl */ `
9
9
  return vec4f(x, y, 0.0, 1.0);
10
10
  }
11
11
  `;
12
- // Full-res HDR → half-res. Karis 4-tap firefly average + EEVEE quadratic knee threshold + clamp.
13
- export const BLOOM_BLIT_SHADER_WGSL = `${FULLSCREEN_VS}
12
+ // Full-res HDR → half-res. The game takes ONE bilinear sample at the half-res
13
+ // pixel's centre, which is the 2×2 box of the block under it; here the four
14
+ // texels are loaded so each can be un-premultiplied (the scene target is
15
+ // premultiplied; its alpha lives in the aux target's .g) and gated by the bloom
16
+ // mask (.r) first.
17
+ export const BLOOM_PREFILTER_SHADER_WGSL = `${FULLSCREEN_VS}
14
18
  @group(0) @binding(0) var hdrTex: texture_2d<f32>;
15
- @group(0) @binding(1) var<uniform> prefilter: vec4<f32>; // threshold, knee, clamp, _unused
19
+ @group(0) @binding(1) var<uniform> params: vec4<f32>; // threshold, knee, clamp, scatter
16
20
  @group(0) @binding(2) var maskTex: texture_2d<f32>;
17
21
 
18
- fn luminance(c: vec3f) -> f32 {
19
- return dot(max(c, vec3f(0.0)), vec3f(0.2126, 0.7152, 0.0722));
20
- }
21
- fn fetch(c: vec2<i32>, clampV: f32) -> vec3f {
22
+ fn fetch(c: vec2<i32>) -> vec3f {
22
23
  let d = vec2<i32>(textureDimensions(hdrTex));
23
24
  let cc = clamp(c, vec2<i32>(0), d - vec2<i32>(1));
24
- let s = textureLoad(hdrTex, cc, 0);
25
- // hdrTex is rg11b10ufloat (no alpha channel). Alpha lives in maskTex.g, written
26
- // alongside the bloom mask in .r by the scene pass. Scene uses src-alpha blend
27
- // with clear alpha 0 → hdr rgb is premultiplied; divide by the aux alpha to
28
- // recover straight color before the Karis luminance weighting.
29
25
  let aux = textureLoad(maskTex, cc, 0);
30
- let mask = aux.r;
31
- let rgb = max(s.rgb / max(aux.g, 1e-6), vec3f(0.0));
32
- let masked = rgb * mask;
33
- // Blender clamps each tap BEFORE Karis average (eevee_bloom: color = min(clampIntensity, color)).
34
- return select(masked, min(masked, vec3f(clampV)), clampV > 0.0);
26
+ return max(textureLoad(hdrTex, cc, 0).rgb / max(aux.g, 1e-6), vec3f(0.0)) * aux.r;
35
27
  }
36
28
 
37
29
  @fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
38
- let dst = vec2<i32>(p.xy - vec2f(0.5));
39
- let base = dst * 2;
40
- let clampV = prefilter.z;
41
- let a = fetch(base + vec2<i32>(0, 0), clampV);
42
- let b = fetch(base + vec2<i32>(1, 0), clampV);
43
- let c = fetch(base + vec2<i32>(0, 1), clampV);
44
- let d = fetch(base + vec2<i32>(1, 1), clampV);
45
- // Karis partial average: weight each tap by 1/(1+luma) — suppresses fireflies.
46
- let wa = 1.0 / (1.0 + luminance(a));
47
- let wb = 1.0 / (1.0 + luminance(b));
48
- let wc = 1.0 / (1.0 + luminance(c));
49
- let wd = 1.0 / (1.0 + luminance(d));
50
- let avg = (a * wa + b * wb + c * wc + d * wd) / max(wa + wb + wc + wd, 1e-6);
51
- // EEVEE quadratic threshold (brightness = max-channel, then soft-knee curve).
52
- let bright = max(avg.r, max(avg.g, avg.b));
53
- let soft = clamp(bright - prefilter.x + prefilter.y, 0.0, 2.0 * prefilter.y);
54
- let q = (soft * soft) / (4.0 * max(prefilter.y, 1e-4) + 1e-6);
55
- let contrib = max(q, bright - prefilter.x) / max(bright, 1e-4);
56
- return vec4f(max(avg * contrib, vec3f(0.0)), 1.0);
30
+ let base = vec2<i32>(p.xy - vec2f(0.5)) * 2;
31
+ let avg = (fetch(base) + fetch(base + vec2<i32>(1, 0)) + fetch(base + vec2<i32>(0, 1)) + fetch(base + vec2<i32>(1, 1))) * 0.25;
32
+ let c = min(avg, vec3f(params.z));
33
+ // URP's quadratic soft knee on the brightest channel.
34
+ let bright = max(c.r, max(c.g, c.b));
35
+ let knee = params.y;
36
+ let soft = clamp(bright - params.x + knee, 0.0, 2.0 * knee);
37
+ let curve = soft * soft / (4.0 * knee + 1e-4);
38
+ return vec4f(c * (max(curve, bright - params.x) / max(bright, 1e-4)), 1.0);
57
39
  }
58
40
  `;
59
- // Jimenez/COD 13-tap dual-box — 5 weighted 2×2 averages, rejects nyquist ringing.
60
- export const BLOOM_DOWNSAMPLE_SHADER_WGSL = `${FULLSCREEN_VS}
41
+ // Horizontal 9-tap Gaussian (σ ≈ 2 at the destination's scale), its taps two
42
+ // SOURCE texels apart: the destination is half the source, so this both halves
43
+ // and blurs. Each tap lands on a texel boundary, so bilinear averages pairs.
44
+ export const BLOOM_BLUR_H_SHADER_WGSL = `${FULLSCREEN_VS}
61
45
  @group(0) @binding(0) var srcTex: texture_2d<f32>;
62
46
  @group(0) @binding(1) var srcSamp: sampler;
63
47
 
64
- fn samp(uv: vec2f, off: vec2f) -> vec3f {
65
- return textureSampleLevel(srcTex, srcSamp, uv + off, 0.0).rgb;
48
+ @fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
49
+ let srcDims = vec2f(textureDimensions(srcTex));
50
+ let dstDims = max(floor(srcDims * 0.5), vec2f(1.0));
51
+ let uv = p.xy / dstDims;
52
+ let dx = vec2f(2.0 / srcDims.x, 0.0);
53
+ var w = array<f32, 5>(0.22702703, 0.19459459, 0.12162162, 0.05405405, 0.01621622);
54
+ var o = textureSampleLevel(srcTex, srcSamp, uv, 0.0).rgb * w[0];
55
+ for (var i = 1; i < 5; i++) {
56
+ let off = dx * f32(i);
57
+ o += (textureSampleLevel(srcTex, srcSamp, uv - off, 0.0).rgb + textureSampleLevel(srcTex, srcSamp, uv + off, 0.0).rgb) * w[i];
58
+ }
59
+ return vec4f(o, 1.0);
66
60
  }
61
+ `;
62
+ // Vertical pass at the same size: the same 9-tap Gaussian as five bilinear taps.
63
+ export const BLOOM_BLUR_V_SHADER_WGSL = `${FULLSCREEN_VS}
64
+ @group(0) @binding(0) var srcTex: texture_2d<f32>;
65
+ @group(0) @binding(1) var srcSamp: sampler;
67
66
 
68
67
  @fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
69
- let srcDims = vec2f(textureDimensions(srcTex));
70
- let t = 1.0 / srcDims;
71
- // fragCoord.xy reports pixel centers (e.g. 0.5,0.5 for first pixel) — divide by dst dims directly.
72
- let dstDims = srcDims * 0.5;
73
- let uv = p.xy / max(dstDims, vec2f(1.0));
74
- let A = samp(uv, t * vec2f(-2.0, -2.0));
75
- let B = samp(uv, t * vec2f( 0.0, -2.0));
76
- let C = samp(uv, t * vec2f( 2.0, -2.0));
77
- let D = samp(uv, t * vec2f(-1.0, -1.0));
78
- let E = samp(uv, t * vec2f( 1.0, -1.0));
79
- let F = samp(uv, t * vec2f(-2.0, 0.0));
80
- let G = samp(uv, t * vec2f( 0.0, 0.0));
81
- let H = samp(uv, t * vec2f( 2.0, 0.0));
82
- let I = samp(uv, t * vec2f(-1.0, 1.0));
83
- let J = samp(uv, t * vec2f( 1.0, 1.0));
84
- let K = samp(uv, t * vec2f(-2.0, 2.0));
85
- let L = samp(uv, t * vec2f( 0.0, 2.0));
86
- let M = samp(uv, t * vec2f( 2.0, 2.0));
87
- var o = (D + E + I + J) * (0.5 / 4.0);
88
- o = o + (A + B + G + F) * (0.125 / 4.0);
89
- o = o + (B + C + H + G) * (0.125 / 4.0);
90
- o = o + (F + G + L + K) * (0.125 / 4.0);
91
- o = o + (G + H + M + L) * (0.125 / 4.0);
68
+ let dims = vec2f(textureDimensions(srcTex));
69
+ let uv = p.xy / dims;
70
+ let near = vec2f(0.0, 1.3846154 / dims.y);
71
+ let far = vec2f(0.0, 3.2307692 / dims.y);
72
+ var o = textureSampleLevel(srcTex, srcSamp, uv, 0.0).rgb * 0.22702703;
73
+ o += (textureSampleLevel(srcTex, srcSamp, uv - near, 0.0).rgb + textureSampleLevel(srcTex, srcSamp, uv + near, 0.0).rgb) * 0.31621623;
74
+ o += (textureSampleLevel(srcTex, srcSamp, uv - far, 0.0).rgb + textureSampleLevel(srcTex, srcSamp, uv + far, 0.0).rgb) * 0.07027027;
92
75
  return vec4f(o, 1.0);
93
76
  }
94
77
  `;
95
- // 9-tap tent, progressively added to matching downsample mip. Blender radius = sample scale.
78
+ // One level up: this level's blur leaned toward the coarser one by scatter.
96
79
  export const BLOOM_UPSAMPLE_SHADER_WGSL = `${FULLSCREEN_VS}
97
- @group(0) @binding(0) var srcTex: texture_2d<f32>; // coarser accumulator
98
- @group(0) @binding(1) var baseTex: texture_2d<f32>; // matching downsample mip
80
+ @group(0) @binding(0) var highTex: texture_2d<f32>; // down[i]
81
+ @group(0) @binding(1) var lowTex: texture_2d<f32>; // coarser: down[i+1] at the top, up[i+1] after
99
82
  @group(0) @binding(2) var srcSamp: sampler;
100
- @group(0) @binding(3) var<uniform> upU: vec4<f32>; // sampleScale, _, _, _
83
+ @group(0) @binding(3) var<uniform> params: vec4<f32>; // threshold, knee, clamp, scatter
101
84
 
102
85
  @fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {
103
- let srcDims = vec2f(textureDimensions(srcTex));
104
- let baseDims = vec2f(textureDimensions(baseTex));
105
- let uv = p.xy / max(baseDims, vec2f(1.0));
106
- let t = upU.x / srcDims;
107
- var o = textureSampleLevel(srcTex, srcSamp, uv + t * vec2f(-1.0, -1.0), 0.0).rgb * 1.0;
108
- o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 0.0, -1.0), 0.0).rgb * 2.0;
109
- o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 1.0, -1.0), 0.0).rgb * 1.0;
110
- o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f(-1.0, 0.0), 0.0).rgb * 2.0;
111
- o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 0.0, 0.0), 0.0).rgb * 4.0;
112
- o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 1.0, 0.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( 0.0, 1.0), 0.0).rgb * 2.0;
115
- o = o + textureSampleLevel(srcTex, srcSamp, uv + t * vec2f( 1.0, 1.0), 0.0).rgb * 1.0;
116
- o = o * (1.0 / 16.0);
117
- let base = textureSampleLevel(baseTex, srcSamp, uv, 0.0).rgb;
118
- return vec4f(o + base, 1.0);
86
+ let uv = p.xy / vec2f(textureDimensions(highTex));
87
+ let high = textureSampleLevel(highTex, srcSamp, uv, 0.0).rgb;
88
+ let low = textureSampleLevel(lowTex, srcSamp, uv, 0.0).rgb;
89
+ return vec4f(mix(high, low, params.w), 1.0);
119
90
  }
120
91
  `;
@@ -97,7 +97,7 @@ type CompositeEffectSource = {
97
97
  * Depends on `viewU` and `_rzCast` being declared by the including module, and
98
98
  * on nothing else: no textures, no samplers.
99
99
  */
100
- export declare const EFFECT_SCENE_API = "\n// \u2500\u2500 The effect API \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n//\n// Named rz*, for the engine. The prefix earns its place twice: user code is\n// concatenated into THIS module, so an unprefixed rzAnchor() would collide with\n// exactly the helper an author would write, and the old bg* prefix stopped being\n// true in 0.41.0 when effects gained a mount over the finished frame.\n//\n// The bg* names below are permanent aliases, not a deprecation with an end date.\n// A published link is immutable, so a scene pinned to a bg* effect has to keep\n// compiling forever. They are one-line and inlined; no new function gets one.\n\n/** Canvas size in pixels \u2014 for aspect correction. */\nfn rzResolution() -> vec2f { return viewU[6].zw; }\n\n/** The camera's world position. */\nfn rzCameraPos() -> vec3f { return viewU[10].xyz; }\n\n/** How many characters the SCENE holds, up to four \u2014 the number the cast api\n * narrows to this effect's own subjects. See subjectMaskApi. */\nfn _rzCastLive() -> i32 { return i32(viewU[10].w); }\n\n/**\n * A world point as the camera sees it: xy the uv it lands on, z its distance\n * along the VIEW AXIS in metres.\n *\n * The exact inverse of the ray this pass builds per pixel, so it is the cheap way\n * to work with anything anchored in the world. Marching a curve or a trail in 3D\n * costs a distance evaluation per sample per pixel; projecting its points once\n * and measuring in 2D costs a subtraction, which is the difference between a\n * ribbon that runs at 4K and one that does not.\n *\n * z is directly comparable to the depth handed to foreground(), so occlusion is\n * a single test: draw where your z is nearer than the scene's. It is returned\n * SIGNED and unclamped \u2014 behind the camera is negative, and worth rejecting\n * before you use the uv, which is meaningless there.\n */\nfn rzProject(p: vec3f) -> vec3f {\n let d = p - viewU[10].xyz;\n let z = dot(d, viewU[5].xyz);\n // Guard only the divide. z itself is returned as it is, so the caller can see\n // the sign; clamping it here would put points behind the lens on the horizon.\n let inv = 1.0 / select(z, 1e-4, z < 1e-4);\n let ndc = vec2f(dot(d, viewU[3].xyz) * inv / viewU[3].w, dot(d, viewU[4].xyz) * inv / viewU[4].w);\n return vec3f(ndc * 0.5 + 0.5, z);\n}\n\nfn rzCamPos() -> vec3f { return rzCameraPos(); }\nfn rzCameraRight() -> vec3f { return viewU[3].xyz; }\nfn rzCameraUp() -> vec3f { return viewU[4].xyz; }\nfn rzCameraForward() -> vec3f { return viewU[5].xyz; }\n\n// The cast \u2014 subjects, anchors, trails \u2014 is CAST_API, shared verbatim with the\n// particle and trail modules. It used to be written out here, a second time, and\n// the two copies had drifted: this one had rzAnchor and that one did not.\n\nconst RZ_SUBJECTS: i32 = 4;\nconst RZ_SAMPLES: i32 = 128;\n/** The anchor ADDRESS SPACE \u2014 how many an effect may declare, not how many it\n * did. RZ_TRAIL_SLOTS is the per-effect number and is not this one; the two\n * being one number was the old trail bug. */\nconst RZ_MAX_ANCHORS: i32 = 48;\nconst RZ_TRAIL_SAMPLES: i32 = 128;\n\nstruct RzSubject {\n /** On the FLOOR, under the body \u2014 where a ring or a magic circle belongs. */\n root: vec3f,\n /** At the hips, the middle of the body \u2014 where an aura belongs. */\n center: vec3f,\n /** Bounding sphere: xyz centre, w radius. Deliberately generous \u2014 cull with it. */\n bounds: vec4f,\n /**\n * How much of this character is still THERE: 1 whole, 0 gone.\n *\n * What setModelDissolve last set on them, which the material pass has already\n * acted on by the time an effect runs \u2014 so an effect drawing what LEAVES a\n * dissolving body (sparks, ash, a ghost) reads the same number the body was\n * taken apart with, rather than keeping a clock of its own and hoping the two\n * agree. 1 on a scene that never dissolves anybody.\n */\n dissolve: f32,\n /**\n * Where she LOOKS: a unit direction from between the eyes, in world space.\n * Exact while her eyes are on something (Engine.setEyeTracking); otherwise\n * the motion's own eye turn undone through the range map \u2014 an eye bone\n * turns a fraction of the way toward what it looks at, and this gives back\n * the whole of it. An effect that should go where the look goes \u2014 a beam,\n * a light \u2014 takes this rather than an eye bone's axis, and it holds in\n * either mode. Zero, with looking false, only for a model without eyes.\n */\n gaze: vec3f,\n looking: bool,\n /** False past the end of the cast, and every field is then zero. */\n valid: bool,\n}\n\nstruct RzAnchor {\n pos: vec3f,\n /** World units per second, from the previous frame. Direction for a trail,\n * magnitude for anything that should react to how hard someone is moving. */\n vel: vec3f,\n /** The bone's forward axis \u2014 which way a foot points, where a head looks. */\n fwd: vec3f,\n /** False when this rig has no such bone. Check it: the alternative is drawing\n * a hand effect at the world origin on every model that spells it differently. */\n valid: bool,\n}\n\n/**\n * How many characters THIS EFFECT is on, up to four.\n *\n * Not how many the scene holds. An effect applies to the whole cast unless the\n * scene says otherwise (Engine.setEffectSubjects), and where it says otherwise\n * this counts only the models named \u2014 so an effect aimed at one dancer sees one\n * subject, at index 0, and an author who wrote a loop over the cast gets the\n * targeting for free.\n *\n * Which is the whole design: the FILTER IS IN THE INDEX SPACE, not in the\n * shaders. There is no mask an effect can read, because an effect asking \"am I\n * allowed on this one\" is an effect that can get the answer wrong, and 18 of the\n * shipped built-ins would have had to be rewritten to ask at all.\n */\nfn rzSubjectCount() -> i32 {\n let live = _rzCastLive();\n let mask = _rzSubjectMask();\n var n = 0;\n for (var i = 0; i < live; i++) {\n if ((mask & (1u << u32(i))) != 0u) { n++; }\n }\n return n;\n}\n\n/** This effect's i-th subject \u2192 the scene's slot, or -1 past its own count.\n * Every accessor below goes through it, which is what keeps one effect's\n * subject 1 from being another's. */\nfn _rzSubjectSlot(i: i32) -> i32 {\n if (i < 0) { return -1; }\n let live = _rzCastLive();\n let mask = _rzSubjectMask();\n var n = 0;\n for (var s = 0; s < live; s++) {\n if ((mask & (1u << u32(s))) == 0u) { continue; }\n if (n == i) { return s; }\n n++;\n }\n return -1;\n}\n\n/** Which model this is, stable across a scene \u2014 for per-subject variation. */\nfn rzSubjectId(i: i32) -> u32 {\n let g = _rzSubjectSlot(i);\n if (g < 0) { return 0u; }\n return u32(_rzCast[g * 4 + 1].w);\n}\n\nfn rzSubject(i: i32) -> RzSubject {\n var s: RzSubject;\n let g = _rzSubjectSlot(i);\n s.valid = g >= 0;\n if (!s.valid) { return s; }\n let b = g * 4;\n s.root = _rzCast[b].xyz;\n s.dissolve = _rzCast[b].w;\n s.center = _rzCast[b + 1].xyz;\n s.bounds = _rzCast[b + 2];\n s.gaze = _rzCast[b + 3].xyz;\n s.looking = _rzCast[b + 3].w > 0.5;\n return s;\n}\n\n/**\n * Where a named bone is, this frame.\n *\n * The slot is the author's own: the Nth #anchor in their file, in the order\n * they wrote them. _rzSlot turns that into the scene's address, which is what\n * keeps two effects that both anchor to a wrist from reading each other's.\n */\nfn rzAnchor(subject: i32, slot: i32) -> RzAnchor {\n var a: RzAnchor;\n a.valid = false;\n let g = _rzSlot(slot);\n let s = _rzSubjectSlot(subject);\n if (s < 0 || g < 0 || g >= RZ_MAX_ANCHORS) { return a; }\n let b = 16 + (g * 4 + s) * 3;\n a.valid = _rzCast[b].w > 0.5;\n a.pos = _rzCast[b].xyz;\n a.vel = _rzCast[b + 1].xyz;\n a.fwd = _rzCast[b + 2].xyz;\n return a;\n}\n\n/**\n * How many samples of a path are recorded \u2014 0 for an anchor that asked for no\n * trail, and for one that has not moved yet.\n *\n * Bounded by the anchor cap, NOT by how many anchors asked for a trail. Those\n * are different index spaces: storage is addressed by anchor slot, so an\n * untrailed #anchor followed by a trailed one put the trail at index 1 with a\n * bound of 1 and rzTrail returned zero \u2014 a ribbon that silently did not draw.\n */\nfn rzTrailCount(subject: i32, slot: i32) -> i32 {\n let g = _rzSlot(slot);\n let s = _rzSubjectSlot(subject);\n if (s < 0 || g < 0 || g >= RZ_MAX_ANCHORS) { return 0; }\n return i32(_rzCast[16 + (g * 4 + s) * 3 + 2].w);\n}\n\n/** Sample i of a path: xyz where it was, w how many seconds ago. i = 0 is now. */\nfn rzTrail(subject: i32, slot: i32, i: i32) -> vec4f {\n let n = rzTrailCount(subject, slot);\n if (i < 0 || i >= n) { return vec4f(0.0); }\n let base = 592 + (_rzSlot(slot) * 4 + _rzSubjectSlot(subject)) * RZ_TRAIL_SAMPLES;\n return _rzCast[base + i];\n}\n\n\n// The hashes, the noise and rzFalloff \u2014 EFFECT_MATH_API, the same text the\n// particle and trail modules get. The field module used to be missing most of\n// it, so a helper an author wrote for one mount failed to compile in another\n// for no reason visible in the file.\n\nfn rzHash11(x: f32) -> f32 {\n var p = fract(x * 0.1031);\n p = p * (p + 33.33);\n return fract(p * (p + p));\n}\nfn rzHash21(p: vec2f) -> f32 {\n var p3 = fract(vec3f(p.x, p.y, p.x) * 0.1031);\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n/** Three independent randoms from one seed \u2014 the usual need when spawning. */\nfn rzHash13(x: f32) -> vec3f {\n return vec3f(rzHash11(x), rzHash11(x + 17.13), rzHash11(x + 41.71));\n}\n/**\n * Compact-support falloff: 1 at the centre, exactly 0 at r, smooth between.\n *\n * It reaches exactly zero rather than merely getting small, because a glow that\n * never quite ends has to be culled somewhere, and culling it wherever it looks\n * close enough is what put a visible hard edge on the first halo effect.\n */\nfn rzFalloff(d: f32, r: f32) -> f32 {\n let x = clamp(d / max(r, 1e-6), 0.0, 1.0);\n let f = 1.0 - x;\n return f * f * f;\n}\nfn rzHash31(p: vec3f) -> f32 {\n var p3 = fract(p * 0.1031);\n p3 = p3 + dot(p3, p3.zyx + 31.32);\n return fract((p3.x + p3.y) * p3.z);\n}\nfn rzValueNoise(p: vec3f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let n000 = rzHash31(i);\n let n100 = rzHash31(i + vec3f(1.0, 0.0, 0.0));\n let n010 = rzHash31(i + vec3f(0.0, 1.0, 0.0));\n let n110 = rzHash31(i + vec3f(1.0, 1.0, 0.0));\n let n001 = rzHash31(i + vec3f(0.0, 0.0, 1.0));\n let n101 = rzHash31(i + vec3f(1.0, 0.0, 1.0));\n let n011 = rzHash31(i + vec3f(0.0, 1.0, 1.0));\n let n111 = rzHash31(i + vec3f(1.0, 1.0, 1.0));\n let x00 = mix(n000, n100, u.x);\n let x10 = mix(n010, n110, u.x);\n let x01 = mix(n001, n101, u.x);\n let x11 = mix(n011, n111, u.x);\n return mix(mix(x00, x10, u.y), mix(x01, x11, u.y), u.z);\n}\n/** Divergence-free flow \u2014 the field a wisp of smoke follows without a solver. */\nfn rzCurlNoise(p: vec3f) -> vec3f {\n let e = 0.1;\n let dx = vec3f(e, 0.0, 0.0);\n let dy = vec3f(0.0, e, 0.0);\n let dz = vec3f(0.0, 0.0, e);\n let x0 = rzValueNoise(p - dx); let x1 = rzValueNoise(p + dx);\n let y0 = rzValueNoise(p - dy); let y1 = rzValueNoise(p + dy);\n let z0 = rzValueNoise(p - dz); let z1 = rzValueNoise(p + dz);\n return normalize(vec3f((y1 - y0) - (z1 - z0), (z1 - z0) - (x1 - x0), (x1 - x0) - (y1 - y0)) + vec3f(1e-6));\n}\n\nstruct Particle {\n pos: vec3f,\n age: f32,\n vel: vec3f,\n life: f32,\n size: f32,\n rot: f32,\n seed: f32,\n // Aspect along the direction of travel. 1 or less is a square billboard; a\n // raindrop is 10 or 20. Zero-initialised, so an effect that never sets it gets\n // the square it expects.\n stretch: f32,\n}\n\n\n\nfn bgResolution() -> vec2f { return rzResolution(); }\nfn bgCameraPos() -> vec3f { return rzCameraPos(); }\nfn bgSubjectCount() -> i32 { return rzSubjectCount(); }\n\n/**\n * Where a character IS, in world space \u2014 at the hips, not on the floor.\n *\n * An effect that wants to RESPOND to the cast \u2014 a glow that follows someone,\n * dust kicked up where they are \u2014 needs to know where they are, and the ray and\n * the depth cannot tell it: they describe the pixel, not the scene.\n *\n * The value is model.position + \u30BB\u30F3\u30BF\u30FC + \u5168\u3066\u306E\u89AA. \u30BB\u30F3\u30BF\u30FC sits at hip\n * height on every standard MMD rig, so this is a point in the middle of the\n * body. It is NOT the contact point: a ripple drawn here appears at the waist.\n * Ground effects want the .xz of this and their own floor height, which is what\n * the effects that shipped against it already do.\n *\n * The comment here used to claim it was \"between the feet on the floor\", which\n * is where that habit came from. Left as it is regardless of the name: a\n * published link is immutable, so every shared scene pinning an effect that\n * reads this depends on it meaning exactly what it has always meant.\n *\n * Clamped rather than bounds-checked: an effect looping past the count reads the\n * last subject instead of sampling whatever follows the array, which is a wrong\n * ripple rather than an undefined one.\n *\n * THE INDEX IS THE EFFECT'S OWN, like every other cast accessor \u2014 Dry Ice, Water,\n * Holy Light and Bloody Ash all read this one and all of them should place their\n * fog, their ripple and their shaft under the model the scene aimed them at. The\n * mapping is the identity for an effect aimed at nobody in particular, which is\n * every scene published before one could be aimed.\n */\nfn rzSubjectHip(i: i32) -> vec3f { return viewU[11 + clamp(_rzSubjectSlot(i), 0, 3)].xyz; }\n\nfn bgSubjectPos(i: i32) -> vec3f { return rzSubjectHip(i); }\n\n/** Where in the WORLD the scene drew this pixel \u2014 the depth handed to\n * foreground() turned into a place. Without it an effect can only think in\n * distances from the lens, which is no use to anything that belongs somewhere:\n * fog lying on the ground has to know where the ground is.\n *\n * depth measures along the VIEW AXIS, not along the ray, so it is divided by\n * the ray's projection onto camera-forward before being walked out. At the far\n * plane (nothing drawn) this lands a very long way off, which is what a sky\n * should do to anything reading it. */\nfn rzWorldPos(ray: vec3f, depth: f32) -> vec3f {\n let axis = max(dot(normalize(ray), viewU[5].xyz), 1e-4);\n return rzCameraPos() + normalize(ray) * (depth / axis);\n}\n\nfn bgWorldPos(ray: vec3f, depth: f32) -> vec3f { return rzWorldPos(ray, depth); }\n\n/** What an effect returns for one of its lights. */\nstruct RzLight {\n pos: vec3f,\n color: vec3f,\n intensity: f32,\n radius: f32,\n}\n\n\n/** Color grading, applied to the tonemapped SCENE (not the background \u2014 see the\n * call site). The core is ASC CDL, the film-industry interchange standard:\n *\n * out = (in \u00B7 slope + offset) ^ power then saturation (SOP \u2192 SAT)\n *\n * Using the real standard rather than invented controls means a look authored\n * here maps onto any grading tool. slope/offset/power are derived on the CPU\n * from the UI's shadow/midtone/highlight colors (see setColorGrading), so the\n * per-pixel cost is one mul-add, one pow, one lerp. */\nfn grade(c: vec3f) -> vec3f {\n var x = pow(max(c * viewU[9].xyz + viewU[7].xyz, vec3f(0.0)), viewU[8].xyz);\n // Contrast pivots on 0.5 \u2014 display-referred midpoint, since we grade post-Filmic.\n x = (x - vec3f(0.5)) * viewU[7].w + vec3f(0.5);\n // Rec.709 luma, matching the ASC SAT node.\n let luma = dot(x, vec3f(0.2126, 0.7152, 0.0722));\n return max(mix(vec3f(luma), x, viewU[8].w), vec3f(0.0));\n}\n\n";
100
+ export declare const EFFECT_SCENE_API = "\n// \u2500\u2500 The effect API \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n//\n// Named rz*, for the engine. The prefix earns its place twice: user code is\n// concatenated into THIS module, so an unprefixed rzAnchor() would collide with\n// exactly the helper an author would write, and the old bg* prefix stopped being\n// true in 0.41.0 when effects gained a mount over the finished frame.\n//\n// The bg* names below are permanent aliases, not a deprecation with an end date.\n// A published link is immutable, so a scene pinned to a bg* effect has to keep\n// compiling forever. They are one-line and inlined; no new function gets one.\n\n/** Canvas size in pixels \u2014 for aspect correction. */\nfn rzResolution() -> vec2f { return viewU[6].zw; }\n\n/** The camera's world position. */\nfn rzCameraPos() -> vec3f { return viewU[10].xyz; }\n\n/** How many characters the SCENE holds, up to four \u2014 the number the cast api\n * narrows to this effect's own subjects. See subjectMaskApi. */\nfn _rzCastLive() -> i32 { return i32(viewU[10].w); }\n\n/**\n * A world point as the camera sees it: xy the uv it lands on, z its distance\n * along the VIEW AXIS in metres.\n *\n * The exact inverse of the ray this pass builds per pixel, so it is the cheap way\n * to work with anything anchored in the world. Marching a curve or a trail in 3D\n * costs a distance evaluation per sample per pixel; projecting its points once\n * and measuring in 2D costs a subtraction, which is the difference between a\n * ribbon that runs at 4K and one that does not.\n *\n * z is directly comparable to the depth handed to foreground(), so occlusion is\n * a single test: draw where your z is nearer than the scene's. It is returned\n * SIGNED and unclamped \u2014 behind the camera is negative, and worth rejecting\n * before you use the uv, which is meaningless there.\n */\nfn rzProject(p: vec3f) -> vec3f {\n let d = p - viewU[10].xyz;\n let z = dot(d, viewU[5].xyz);\n // Guard only the divide. z itself is returned as it is, so the caller can see\n // the sign; clamping it here would put points behind the lens on the horizon.\n let inv = 1.0 / select(z, 1e-4, z < 1e-4);\n let ndc = vec2f(dot(d, viewU[3].xyz) * inv / viewU[3].w, dot(d, viewU[4].xyz) * inv / viewU[4].w);\n return vec3f(ndc * 0.5 + 0.5, z);\n}\n\nfn rzCamPos() -> vec3f { return rzCameraPos(); }\nfn rzCameraRight() -> vec3f { return viewU[3].xyz; }\nfn rzCameraUp() -> vec3f { return viewU[4].xyz; }\nfn rzCameraForward() -> vec3f { return viewU[5].xyz; }\n\n// The cast \u2014 subjects, anchors, trails \u2014 is CAST_API, shared verbatim with the\n// particle and trail modules. It used to be written out here, a second time, and\n// the two copies had drifted: this one had rzAnchor and that one did not.\n\nconst RZ_SUBJECTS: i32 = 4;\nconst RZ_SAMPLES: i32 = 128;\n/** The anchor ADDRESS SPACE \u2014 how many an effect may declare, not how many it\n * did. RZ_TRAIL_SLOTS is the per-effect number and is not this one; the two\n * being one number was the old trail bug. */\nconst RZ_MAX_ANCHORS: i32 = 48;\nconst RZ_TRAIL_SAMPLES: i32 = 128;\n\nstruct RzSubject {\n /** On the FLOOR, under the body \u2014 where a ring or a magic circle belongs. */\n root: vec3f,\n /** At the hips, the middle of the body \u2014 where an aura belongs. */\n center: vec3f,\n /** Bounding sphere: xyz centre, w radius. Deliberately generous \u2014 cull with it. */\n bounds: vec4f,\n /**\n * How much of this character is still THERE: 1 whole, 0 gone.\n *\n * What setModelDissolve last set on them, which the material pass has already\n * acted on by the time an effect runs \u2014 so an effect drawing what LEAVES a\n * dissolving body (sparks, ash, a ghost) reads the same number the body was\n * taken apart with, rather than keeping a clock of its own and hoping the two\n * agree. 1 on a scene that never dissolves anybody.\n */\n dissolve: f32,\n /**\n * Where she LOOKS: a unit direction from between the eyes, in world space.\n * Exact while her eyes are on something (Engine.setEyeTracking); otherwise\n * the motion's own eye turn undone through the range map \u2014 an eye bone\n * turns a fraction of the way toward what it looks at, and this gives back\n * the whole of it. An effect that should go where the look goes \u2014 a beam,\n * a light \u2014 takes this rather than an eye bone's axis, and it holds in\n * either mode. Zero, with looking false, only for a model without eyes.\n */\n gaze: vec3f,\n looking: bool,\n /** False past the end of the cast, and every field is then zero. */\n valid: bool,\n}\n\nstruct RzAnchor {\n pos: vec3f,\n /** World units per second, from the previous frame. Direction for a trail,\n * magnitude for anything that should react to how hard someone is moving. */\n vel: vec3f,\n /** The bone's forward axis \u2014 which way a foot points, where a head looks. */\n fwd: vec3f,\n /** False when this rig has no such bone. Check it: the alternative is drawing\n * a hand effect at the world origin on every model that spells it differently. */\n valid: bool,\n}\n\n/**\n * How many characters THIS EFFECT is on, up to four.\n *\n * Not how many the scene holds. An effect applies to the whole cast unless the\n * scene says otherwise (Engine.setEffectSubjects), and where it says otherwise\n * this counts only the models named \u2014 so an effect aimed at one dancer sees one\n * subject, at index 0, and an author who wrote a loop over the cast gets the\n * targeting for free.\n *\n * Which is the whole design: the FILTER IS IN THE INDEX SPACE, not in the\n * shaders. There is no mask an effect can read, because an effect asking \"am I\n * allowed on this one\" is an effect that can get the answer wrong, and 18 of the\n * shipped built-ins would have had to be rewritten to ask at all.\n */\nfn rzSubjectCount() -> i32 {\n let live = _rzCastLive();\n let mask = _rzSubjectMask();\n var n = 0;\n for (var i = 0; i < live; i++) {\n if ((mask & (1u << u32(i))) != 0u) { n++; }\n }\n return n;\n}\n\n/** This effect's i-th subject \u2192 the scene's slot, or -1 past its own count.\n * Every accessor below goes through it, which is what keeps one effect's\n * subject 1 from being another's. */\nfn _rzSubjectSlot(i: i32) -> i32 {\n if (i < 0) { return -1; }\n let live = _rzCastLive();\n let mask = _rzSubjectMask();\n var n = 0;\n for (var s = 0; s < live; s++) {\n if ((mask & (1u << u32(s))) == 0u) { continue; }\n if (n == i) { return s; }\n n++;\n }\n return -1;\n}\n\n/** Which model this is, stable across a scene \u2014 for per-subject variation. */\nfn rzSubjectId(i: i32) -> u32 {\n let g = _rzSubjectSlot(i);\n if (g < 0) { return 0u; }\n return u32(_rzCast[g * 4 + 1].w);\n}\n\nfn rzSubject(i: i32) -> RzSubject {\n var s: RzSubject;\n let g = _rzSubjectSlot(i);\n s.valid = g >= 0;\n if (!s.valid) { return s; }\n let b = g * 4;\n s.root = _rzCast[b].xyz;\n s.dissolve = _rzCast[b].w;\n s.center = _rzCast[b + 1].xyz;\n s.bounds = _rzCast[b + 2];\n s.gaze = _rzCast[b + 3].xyz;\n s.looking = _rzCast[b + 3].w > 0.5;\n return s;\n}\n\n/**\n * Where a named bone is, this frame.\n *\n * The slot is the author's own: the Nth #anchor in their file, in the order\n * they wrote them. _rzSlot turns that into the scene's address, which is what\n * keeps two effects that both anchor to a wrist from reading each other's.\n */\nfn rzAnchor(subject: i32, slot: i32) -> RzAnchor {\n var a: RzAnchor;\n a.valid = false;\n let g = _rzSlot(slot);\n let s = _rzSubjectSlot(subject);\n if (s < 0 || g < 0 || g >= RZ_MAX_ANCHORS) { return a; }\n let b = 16 + (g * 4 + s) * 3;\n a.valid = _rzCast[b].w > 0.5;\n a.pos = _rzCast[b].xyz;\n a.vel = _rzCast[b + 1].xyz;\n a.fwd = _rzCast[b + 2].xyz;\n return a;\n}\n\n/**\n * How many samples of a path are recorded \u2014 0 for an anchor that asked for no\n * trail, and for one that has not moved yet.\n *\n * Bounded by the anchor cap, NOT by how many anchors asked for a trail. Those\n * are different index spaces: storage is addressed by anchor slot, so an\n * untrailed #anchor followed by a trailed one put the trail at index 1 with a\n * bound of 1 and rzTrail returned zero \u2014 a ribbon that silently did not draw.\n */\nfn rzTrailCount(subject: i32, slot: i32) -> i32 {\n let g = _rzSlot(slot);\n let s = _rzSubjectSlot(subject);\n if (s < 0 || g < 0 || g >= RZ_MAX_ANCHORS) { return 0; }\n return i32(_rzCast[16 + (g * 4 + s) * 3 + 2].w);\n}\n\n/** Sample i of a path: xyz where it was, w how many seconds ago. i = 0 is now. */\nfn rzTrail(subject: i32, slot: i32, i: i32) -> vec4f {\n let n = rzTrailCount(subject, slot);\n if (i < 0 || i >= n) { return vec4f(0.0); }\n let base = 592 + (_rzSlot(slot) * 4 + _rzSubjectSlot(subject)) * RZ_TRAIL_SAMPLES;\n return _rzCast[base + i];\n}\n\n\n// The hashes, the noise and rzFalloff \u2014 EFFECT_MATH_API, the same text the\n// particle and trail modules get. The field module used to be missing most of\n// it, so a helper an author wrote for one mount failed to compile in another\n// for no reason visible in the file.\n\nfn rzHash11(x: f32) -> f32 {\n var p = fract(x * 0.1031);\n p = p * (p + 33.33);\n return fract(p * (p + p));\n}\nfn rzHash21(p: vec2f) -> f32 {\n var p3 = fract(vec3f(p.x, p.y, p.x) * 0.1031);\n p3 = p3 + dot(p3, p3.yzx + 33.33);\n return fract((p3.x + p3.y) * p3.z);\n}\n/** Three independent randoms from one seed \u2014 the usual need when spawning. */\nfn rzHash13(x: f32) -> vec3f {\n return vec3f(rzHash11(x), rzHash11(x + 17.13), rzHash11(x + 41.71));\n}\n/**\n * Compact-support falloff: 1 at the centre, exactly 0 at r, smooth between.\n *\n * It reaches exactly zero rather than merely getting small, because a glow that\n * never quite ends has to be culled somewhere, and culling it wherever it looks\n * close enough is what put a visible hard edge on the first halo effect.\n */\nfn rzFalloff(d: f32, r: f32) -> f32 {\n let x = clamp(d / max(r, 1e-6), 0.0, 1.0);\n let f = 1.0 - x;\n return f * f * f;\n}\nfn rzHash31(p: vec3f) -> f32 {\n var p3 = fract(p * 0.1031);\n p3 = p3 + dot(p3, p3.zyx + 31.32);\n return fract((p3.x + p3.y) * p3.z);\n}\nfn rzValueNoise(p: vec3f) -> f32 {\n let i = floor(p);\n let f = fract(p);\n let u = f * f * (3.0 - 2.0 * f);\n let n000 = rzHash31(i);\n let n100 = rzHash31(i + vec3f(1.0, 0.0, 0.0));\n let n010 = rzHash31(i + vec3f(0.0, 1.0, 0.0));\n let n110 = rzHash31(i + vec3f(1.0, 1.0, 0.0));\n let n001 = rzHash31(i + vec3f(0.0, 0.0, 1.0));\n let n101 = rzHash31(i + vec3f(1.0, 0.0, 1.0));\n let n011 = rzHash31(i + vec3f(0.0, 1.0, 1.0));\n let n111 = rzHash31(i + vec3f(1.0, 1.0, 1.0));\n let x00 = mix(n000, n100, u.x);\n let x10 = mix(n010, n110, u.x);\n let x01 = mix(n001, n101, u.x);\n let x11 = mix(n011, n111, u.x);\n return mix(mix(x00, x10, u.y), mix(x01, x11, u.y), u.z);\n}\n/** Divergence-free flow \u2014 the field a wisp of smoke follows without a solver. */\nfn rzCurlNoise(p: vec3f) -> vec3f {\n let e = 0.1;\n let dx = vec3f(e, 0.0, 0.0);\n let dy = vec3f(0.0, e, 0.0);\n let dz = vec3f(0.0, 0.0, e);\n let x0 = rzValueNoise(p - dx); let x1 = rzValueNoise(p + dx);\n let y0 = rzValueNoise(p - dy); let y1 = rzValueNoise(p + dy);\n let z0 = rzValueNoise(p - dz); let z1 = rzValueNoise(p + dz);\n return normalize(vec3f((y1 - y0) - (z1 - z0), (z1 - z0) - (x1 - x0), (x1 - x0) - (y1 - y0)) + vec3f(1e-6));\n}\n\nstruct Particle {\n pos: vec3f,\n age: f32,\n vel: vec3f,\n life: f32,\n size: f32,\n rot: f32,\n seed: f32,\n // Aspect along the direction of travel. 1 or less is a square billboard; a\n // raindrop is 10 or 20. Zero-initialised, so an effect that never sets it gets\n // the square it expects.\n stretch: f32,\n}\n\n\n\nfn bgResolution() -> vec2f { return rzResolution(); }\nfn bgCameraPos() -> vec3f { return rzCameraPos(); }\nfn bgSubjectCount() -> i32 { return rzSubjectCount(); }\n\n/**\n * Where a character IS, in world space \u2014 at the hips, not on the floor.\n *\n * An effect that wants to RESPOND to the cast \u2014 a glow that follows someone,\n * dust kicked up where they are \u2014 needs to know where they are, and the ray and\n * the depth cannot tell it: they describe the pixel, not the scene.\n *\n * The value is model.position + \u30BB\u30F3\u30BF\u30FC + \u5168\u3066\u306E\u89AA. \u30BB\u30F3\u30BF\u30FC sits at hip\n * height on every standard MMD rig, so this is a point in the middle of the\n * body. It is NOT the contact point: a ripple drawn here appears at the waist.\n * Ground effects want the .xz of this and their own floor height, which is what\n * the effects that shipped against it already do.\n *\n * The comment here used to claim it was \"between the feet on the floor\", which\n * is where that habit came from. Left as it is regardless of the name: a\n * published link is immutable, so every shared scene pinning an effect that\n * reads this depends on it meaning exactly what it has always meant.\n *\n * Clamped rather than bounds-checked: an effect looping past the count reads the\n * last subject instead of sampling whatever follows the array, which is a wrong\n * ripple rather than an undefined one.\n *\n * THE INDEX IS THE EFFECT'S OWN, like every other cast accessor \u2014 Dry Ice, Water,\n * Holy Light and Bloody Ash all read this one and all of them should place their\n * fog, their ripple and their shaft under the model the scene aimed them at. The\n * mapping is the identity for an effect aimed at nobody in particular, which is\n * every scene published before one could be aimed.\n */\nfn rzSubjectHip(i: i32) -> vec3f { return viewU[11 + clamp(_rzSubjectSlot(i), 0, 3)].xyz; }\n\nfn bgSubjectPos(i: i32) -> vec3f { return rzSubjectHip(i); }\n\n/** Where in the WORLD the scene drew this pixel \u2014 the depth handed to\n * foreground() turned into a place. Without it an effect can only think in\n * distances from the lens, which is no use to anything that belongs somewhere:\n * fog lying on the ground has to know where the ground is.\n *\n * depth measures along the VIEW AXIS, not along the ray, so it is divided by\n * the ray's projection onto camera-forward before being walked out. At the far\n * plane (nothing drawn) this lands a very long way off, which is what a sky\n * should do to anything reading it. */\nfn rzWorldPos(ray: vec3f, depth: f32) -> vec3f {\n let axis = max(dot(normalize(ray), viewU[5].xyz), 1e-4);\n return rzCameraPos() + normalize(ray) * (depth / axis);\n}\n\nfn bgWorldPos(ray: vec3f, depth: f32) -> vec3f { return rzWorldPos(ray, depth); }\n\n/** What an effect returns for one of its lights. */\nstruct RzLight {\n pos: vec3f,\n color: vec3f,\n intensity: f32,\n radius: f32,\n /** Its bulb: the distance inside which it stops getting brighter. 0 is the\n * engine's default; a glow worn on a hand wants one the size of the glow. */\n near: f32,\n}\n\n\n/** Color grading, applied to the tonemapped SCENE (not the background \u2014 see the\n * call site). The core is ASC CDL, the film-industry interchange standard:\n *\n * out = (in \u00B7 slope + offset) ^ power then saturation (SOP \u2192 SAT)\n *\n * Using the real standard rather than invented controls means a look authored\n * here maps onto any grading tool. slope/offset/power are derived on the CPU\n * from the UI's shadow/midtone/highlight colors (see setColorGrading), so the\n * per-pixel cost is one mul-add, one pow, one lerp. */\nfn grade(c: vec3f) -> vec3f {\n var x = pow(max(c * viewU[9].xyz + viewU[7].xyz, vec3f(0.0)), viewU[8].xyz);\n // Contrast pivots on 0.5 \u2014 display-referred midpoint, since we grade post-Filmic.\n x = (x - vec3f(0.5)) * viewU[7].w + vec3f(0.5);\n // Rec.709 luma, matching the ASC SAT node.\n let luma = dot(x, vec3f(0.2126, 0.7152, 0.0722));\n return max(mix(vec3f(luma), x, viewU[8].w), vec3f(0.0));\n}\n\n";
101
101
  export declare function buildCompositeShader(effect?: CompositeEffectSource | null): string;
102
102
  /**
103
103
  * The field pass: the user's background/foreground mounts at half resolution,
@@ -1 +1 @@
1
- {"version":3,"file":"composite.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/composite.ts"],"names":[],"mappings":"AA8BA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;+BAgC+B;AAC/B;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,OAAO,CAAA;CAAE,EAAE,CAIhG;AAID,OAAO,EAAE,cAAc,EAAE,eAAe,EAAE,iBAAiB,EAAE,oBAAoB,EAAE,MAAM,gBAAgB,CAAA;AAEzG,KAAK,qBAAqB,GAAG;IAC3B,gFAAgF;IAChF,IAAI,EAAE,MAAM,CAAA;IACZ,kFAAkF;IAClF,UAAU,EAAE,MAAM,CAAA;IAClB,4DAA4D;IAC5D,aAAa,EAAE,OAAO,CAAA;IACtB,oEAAoE;IACpE,aAAa,EAAE,OAAO,CAAA;IACtB;6DACyD;IACzD,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,gFAAgF;IAChF,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,gEAAgE;IAChE,QAAQ,EAAE,MAAM,CAAA;IAChB;mFAC+E;IAC/E,GAAG,CAAC,EAAE,OAAO,CAAA;IACb;;;yEAGqE;IACrE,UAAU,EAAE,MAAM,CAAA;IAClB;;8EAE0E;IAC1E,KAAK,CAAC,EAAE,MAAM,EAAE,CAAA;CACjB,CAAA;AA0OD;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,gBAAgB,ivfAuI5B,CAAA;AA8MD,wBAAgB,oBAAoB,CAAC,MAAM,CAAC,EAAE,qBAAqB,GAAG,IAAI,GAAG,MAAM,CAclF;AAED;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,qBAAqB,GAAG,MAAM,CA2GtE;AAED,iFAAiF;AACjF,eAAO,MAAM,qBAAqB,QAA6B,CAAA"}
1
+ {"version":3,"file":"composite.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/composite.ts"],"names":[],"mappings":"AA8BA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;+BAgC+B;AAC/B;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,OAAO,CAAA;CAAE,EAAE,CAIhG;AAID,OAAO,EAAE,cAAc,EAAE,eAAe,EAAE,iBAAiB,EAAE,oBAAoB,EAAE,MAAM,gBAAgB,CAAA;AAEzG,KAAK,qBAAqB,GAAG;IAC3B,gFAAgF;IAChF,IAAI,EAAE,MAAM,CAAA;IACZ,kFAAkF;IAClF,UAAU,EAAE,MAAM,CAAA;IAClB,4DAA4D;IAC5D,aAAa,EAAE,OAAO,CAAA;IACtB,oEAAoE;IACpE,aAAa,EAAE,OAAO,CAAA;IACtB;6DACyD;IACzD,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,gFAAgF;IAChF,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,gEAAgE;IAChE,QAAQ,EAAE,MAAM,CAAA;IAChB;mFAC+E;IAC/E,GAAG,CAAC,EAAE,OAAO,CAAA;IACb;;;yEAGqE;IACrE,UAAU,EAAE,MAAM,CAAA;IAClB;;8EAE0E;IAC1E,KAAK,CAAC,EAAE,MAAM,EAAE,CAAA;CACjB,CAAA;AAwRD;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,gBAAgB,g6fAuI5B,CAAA;AA4MD,wBAAgB,oBAAoB,CAAC,MAAM,CAAC,EAAE,qBAAqB,GAAG,IAAI,GAAG,MAAM,CAclF;AAED;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,qBAAqB,GAAG,MAAM,CA2GtE;AAED,iFAAiF;AACjF,eAAO,MAAM,qBAAqB,QAA6B,CAAA"}
@@ -12,7 +12,7 @@ import { castDistanceApi, CAST_FIELD_DIV } from "./cast-distance";
12
12
  import { lyricsApi, lyricsTextApi } from "../lyrics-api";
13
13
  import { midiApi } from "../midi-api";
14
14
  import { gridReadApi } from "./grid";
15
- // Composite: HDR scene + bloom pyramid → Filmic tone map → gamma → swapchain.
15
+ // Composite: HDR scene + bloom → view transform (soft / neutral / aces / none) → gamma → swapchain.
16
16
  // Bloom tint/intensity applied at combine (EEVEE treats them as combine-stage params, not prefilter).
17
17
  //
18
18
  // The shader is a TEMPLATE: buildCompositeShader() emits either the base pass or
@@ -104,14 +104,6 @@ override APPLY_GAMMA: bool = true;
104
104
  // re-premultiply the tonemapped color for output so the premultiplied canvas
105
105
  // alphaMode composites the WebGPU surface over the page background correctly.
106
106
  @group(0) @binding(4) var maskTex: texture_2d<f32>;
107
- // Filmic tone curve baked to a WIDTH×1 r16float LUT (bakeFilmicLut on the CPU side).
108
- // Domain: log2(linear) mapped to [0,13]. Replaces the old array<f32,14> that was indexed
109
- // by a runtime u32 (a Metal-backend smell — dynamic local-array indexing lowers to a
110
- // per-invocation copy/switch) and interpolated piecewise-linearly (C0, so segment slope
111
- // discontinuities showed as Mach bands in smooth skin/shadow gradients). The LUT is a
112
- // monotone-cubic (Fritsch–Carlson) fit through the same 14 anchors — same values, C1
113
- // continuity kills the banding — sampled with hardware linear filtering.
114
- @group(0) @binding(5) var filmicLut: texture_2d<f32>;
115
107
  // viewU[0] = (exposure, invGamma, grain amount, grain seed); viewU[1] = (tint.rgb, intensity)
116
108
  // viewU[2] = (background.rgb, mode) — display-space sRGB, composited UNDER the
117
109
  // scene post-tonemap. BASE-layer mode: 0 transparent (DOM shows),
@@ -143,9 +135,6 @@ override APPLY_GAMMA: bool = true;
143
135
  // track the camera radius. Cleared depth (1.0) inverts to the far
144
136
  // plane, so empty sky reads as maximally defocused background.
145
137
  @group(0) @binding(9) var<uniform> dofU: array<vec4<f32>, 3>;
146
- // Blender's AgX, as the 57³ lookup it ships as rather than a reconstruction of
147
- // it. Sampled in the log-encoded E-Gamut space the cube expects — see agxTransform.
148
- @group(0) @binding(10) var agxLut: texture_3d<f32>;
149
138
  // The scene's own colour grade, as a cube — see _rzStageGrade. sRGB-encoded
150
139
  // storage, so what a sample returns is already linear.
151
140
  @group(0) @binding(12) var stageGradeLut: texture_3d<f32>;
@@ -181,8 +170,6 @@ override APPLY_GAMMA: bool = true;
181
170
  fn rzFieldMerge(top: vec4f, bot: vec4f) -> vec4f {
182
171
  return vec4f(top.rgb + bot.rgb * (1.0 - top.a), top.a + bot.a * (1.0 - top.a));
183
172
  }
184
- // Must match FILMIC_LUT_WIDTH in engine.ts (bakeFilmicLut).
185
- const FILMIC_LUT_W: f32 = 256.0;
186
173
 
187
174
  fn linearDepth(coord: vec2<i32>) -> f32 {
188
175
  let z = textureLoad(depthTex, coord, 0);
@@ -215,79 +202,138 @@ fn sceneSample(coord: vec2<i32>, fullSzI: vec2<i32>, fullSz: vec2f) -> vec4f {
215
202
  return vec4f((sHdr + sBloom) * sAlpha, sAlpha);
216
203
  }
217
204
 
218
- fn filmic(x: f32) -> f32 {
219
- // Reference checkpoints (Blender 3.6 Filmic MHC, sobotka/filmic-blender
220
- // look_medium-high-contrast.spi1d): linear 0.18 → ~0.395, linear 1.0 → ~0.83.
221
- // NOTE: version-pinned to Blender 3.6 — 4.x defaults to AgX, not Filmic.
222
- let t = clamp(log2(max(x, 1e-10)) + 10.0, 0.0, 13.0);
223
- // Map t∈[0,13] to the texel-center of baked sample j = t·(W-1)/13.
224
- let u = (t * (FILMIC_LUT_W - 1.0) / 13.0 + 0.5) / FILMIC_LUT_W;
225
- // textureSampleLevel (explicit LOD, no derivatives) is legal in non-uniform flow.
226
- return textureSampleLevel(filmicLut, bloomSamp, vec2f(u, 0.5), 0.0).r;
227
- }
228
-
229
- /** The sRGB display encoding — Blender's "Standard" view transform, which is what
230
- * NPR work uses: no film curve at all, so the colours a graph computes are the
231
- * colours that land. Not a 2.2 power law; sRGB has a linear toe. */
205
+ /** The sRGB display encoding — "none": no tone curve at all, so the colours a
206
+ * graph computes are the colours that land. Not a 2.2 power law; sRGB has a
207
+ * linear toe. Every transform below ends in it. */
232
208
  fn srgb_encode(x: f32) -> f32 {
233
209
  let c = max(x, 0.0);
234
210
  return select(1.055 * pow(c, 1.0 / 2.4) - 0.055, c * 12.92, c <= 0.0031308);
235
211
  }
236
212
 
237
213
  /**
238
- * AgX, the chain config.ocio states for "AgX Base Rec.1886" on an sRGB display:
239
- *
240
- * scene-linear Rec.709 → 3x3 into FilmLight E-Gamut
241
- * → log2 across 25 stops, [-12.47393, +12.5261] → 0..1
242
- * → the 57³ cube
243
- * → Rec.1886 (pure γ2.4) decoded, then re-encoded as sRGB for the swapchain
244
- *
245
- * The last step is not pedantry: Rec.1886 is a pure power law and sRGB has a
246
- * linear toe, so skipping it lifts the darkest few code values.
247
- *
248
- * Blender interpolates the cube tetrahedrally and a GPU sampler is trilinear.
249
- * The difference is small and confined to saturated gradients; if it ever shows,
250
- * tetrahedral is a dozen lines here.
214
+ * "soft" — Aether Gazer's own curve, from its final pass
215
+ * (Hidden/SimPipeline/Final, decompiled): (1 - e^(-exposure x))^contrast, at the
216
+ * 2.5 and 1.4 its scenes set, into a linear target the swapchain then encodes.
217
+ * The game's ACES branch is off in every scene measured.
251
218
  */
252
- fn agxTransform(c: vec3f) -> vec3f {
253
- let m = mat3x3<f32>(
254
- vec3f(0.55937113, 0.07622071, 0.06552670),
255
- vec3f(0.30478326, 0.78797183, 0.16454675),
256
- vec3f(0.13584556, 0.13580748, 0.76992653),
257
- );
258
- let e = m * max(c, vec3f(0.0));
259
- // The cube's domain: an unlit pixel would take log2 to -inf, so floor it at
260
- // the bottom stop rather than sampling outside the LUT.
261
- let t = clamp((log2(max(e, vec3f(1e-10))) + vec3f(12.47393)) / 25.0, vec3f(0.0), vec3f(1.0));
262
- // Half-texel inset, so the end stops are the cube's own values and not a blend
263
- // with the clamp-to-edge border.
264
- let n = 57.0;
265
- let uvw = t * ((n - 1.0) / n) + (0.5 / n);
266
- let formed = textureSampleLevel(agxLut, bloomSamp, uvw, 0.0).rgb;
267
- let linear = pow(max(formed, vec3f(0.0)), vec3f(2.4));
268
- return vec3f(srgb_encode(linear.r), srgb_encode(linear.g), srgb_encode(linear.b));
219
+ fn softTransform(c: vec3f) -> vec3f {
220
+ let t = pow(max(vec3f(1.0) - exp(-2.5 * max(c, vec3f(0.0))), vec3f(0.0)), vec3f(1.4));
221
+ return vec3f(srgb_encode(t.r), srgb_encode(t.g), srgb_encode(t.b));
222
+ }
223
+
224
+ fn _rzSrgbEncode3(c: vec3f) -> vec3f {
225
+ return vec3f(srgb_encode(c.r), srgb_encode(c.g), srgb_encode(c.b));
269
226
  }
270
227
 
271
228
  /**
272
- * Aether Gazer's own curve, from its final pass (Hidden/SimPipeline/Final,
273
- * decompiled): (1 - e^(-exposure x))^contrast, at the 2.5 and 1.4 its scenes
274
- * set, into a linear target the swapchain then encodes. The game's ACES branch
275
- * is off in every scene measured.
229
+ * "neutral" — Unity URP's Neutral tonemapper as the Khronos PBR Neutral
230
+ * operator, to the published formula: below the start of compression colour
231
+ * passes through (less a small toe offset); above it the peak rolls off toward
232
+ * 1 and desaturates in proportion to how much it lost.
276
233
  */
277
- fn agTransform(c: vec3f) -> vec3f {
278
- let t = pow(max(vec3f(1.0) - exp(-2.5 * max(c, vec3f(0.0))), vec3f(0.0)), vec3f(1.4));
279
- return vec3f(srgb_encode(t.r), srgb_encode(t.g), srgb_encode(t.b));
234
+ fn neutralTransform(color: vec3f) -> vec3f {
235
+ let startCompression = 0.8 - 0.04;
236
+ let desaturation = 0.15;
237
+ var c = max(color, vec3f(0.0));
238
+ let x = min(c.r, min(c.g, c.b));
239
+ let offset = select(0.04, x - 6.25 * x * x, x < 0.08);
240
+ c = c - vec3f(offset);
241
+ let peak = max(c.r, max(c.g, c.b));
242
+ if (peak >= startCompression) {
243
+ let d = 1.0 - startCompression;
244
+ let newPeak = 1.0 - d * d / (peak + d - startCompression);
245
+ c = c * (newPeak / peak);
246
+ let g = 1.0 - 1.0 / (desaturation * (peak - newPeak) + 1.0);
247
+ c = mix(c, vec3f(newPeak), g);
248
+ }
249
+ return _rzSrgbEncode3(clamp(c, vec3f(0.0), vec3f(1.0)));
250
+ }
251
+
252
+ // "aces" — Unity URP's ACES: unity_to_ACES then AcesTonemap, both from the Core
253
+ // RP's Color.hlsl (the branch URP compiles, not full ACES): the RRT's glow and
254
+ // red modifier and global desaturation, the luminance fit of RRT +
255
+ // ODT.Academy.RGBmonitor_100nits_dim, the dim-surround gamma, the ODT
256
+ // desaturation, then AP1 → XYZ → D60→D65 → Rec.709, saturated as URP's
257
+ // ApplyTonemap does. HLSL's row-major matrices are written as rows here.
258
+ fn _rzRows(r0: vec3f, r1: vec3f, r2: vec3f, v: vec3f) -> vec3f {
259
+ return vec3f(dot(r0, v), dot(r1, v), dot(r2, v));
260
+ }
261
+ fn _rzAp1ToXyz(v: vec3f) -> vec3f {
262
+ return _rzRows(vec3f(0.6624541811, 0.1340042065, 0.1561876870),
263
+ vec3f(0.2722287168, 0.6740817658, 0.0536895174),
264
+ vec3f(-0.0055746495, 0.0040607335, 1.0103391003), v);
265
+ }
266
+ fn _rzAp1Luma(v: vec3f) -> f32 {
267
+ return dot(v, vec3f(0.272229, 0.674082, 0.0536895));
268
+ }
269
+ fn acesTransform(color: vec3f) -> vec3f {
270
+ // unity_to_ACES: linear sRGB → AP0.
271
+ var aces = _rzRows(vec3f(0.4397010, 0.3829780, 0.1773350),
272
+ vec3f(0.0897923, 0.8134230, 0.0967616),
273
+ vec3f(0.0175440, 0.1115440, 0.8707040), max(color, vec3f(0.0)));
274
+ // Glow module.
275
+ let mi = min(aces.r, min(aces.g, aces.b));
276
+ let ma = max(aces.r, max(aces.g, aces.b));
277
+ let saturation = (max(ma, 1e-4) - max(mi, 1e-4)) / max(ma, 1e-2);
278
+ let k = max(aces.b * (aces.b - aces.g) + aces.g * (aces.g - aces.r) + aces.r * (aces.r - aces.b), 0.0);
279
+ let ycIn = (aces.b + aces.g + aces.r + 1.75 * sqrt(k)) / 3.0;
280
+ let sx = (saturation - 0.4) / 0.2;
281
+ let st = max(1.0 - abs(sx / 2.0), 0.0);
282
+ let s = (1.0 + select(-1.0, 1.0, sx >= 0.0) * (1.0 - st * st)) / 2.0;
283
+ let glowGain = 0.05 * s;
284
+ let glowMid = 0.08;
285
+ var glow = glowGain * (glowMid / max(ycIn, 1e-6) - 0.5);
286
+ if (ycIn <= 2.0 / 3.0 * glowMid) { glow = glowGain; }
287
+ if (ycIn >= 2.0 * glowMid) { glow = 0.0; }
288
+ aces = aces * (1.0 + glow);
289
+ // Red modifier.
290
+ var hue = 0.0;
291
+ if (!(aces.r == aces.g && aces.g == aces.b)) {
292
+ hue = degrees(atan2(sqrt(3.0) * (aces.g - aces.b), 2.0 * aces.r - aces.g - aces.b));
293
+ }
294
+ if (hue < 0.0) { hue = hue + 360.0; }
295
+ var centered = hue;
296
+ if (centered < -180.0) { centered = centered + 360.0; } else if (centered > 180.0) { centered = centered - 360.0; }
297
+ var hueWeight = smoothstep(0.0, 1.0, 1.0 - abs(2.0 * centered / 135.0));
298
+ hueWeight = hueWeight * hueWeight;
299
+ aces.r = aces.r + hueWeight * saturation * (0.03 - aces.r) * (1.0 - 0.82);
300
+ // AP0 → AP1 (ACEScg), then the RRT's global desaturation.
301
+ var cg = max(_rzRows(vec3f(1.4514393161, -0.2365107469, -0.2149285693),
302
+ vec3f(-0.0765537734, 1.1762296998, -0.0996759264),
303
+ vec3f(0.0083161484, -0.0060324498, 0.9977163014), aces), vec3f(0.0));
304
+ cg = mix(vec3f(_rzAp1Luma(cg)), cg, vec3f(0.96));
305
+ // The luminance fit of RRT + ODT.Academy.RGBmonitor_100nits_dim.
306
+ let post = (cg * (2.785085 * cg + 0.107772)) / (cg * (2.936045 * cg + 0.887122) + 0.806889);
307
+ // Dark → dim surround: Y^0.9811, in xyY.
308
+ var xyz = _rzAp1ToXyz(post);
309
+ let div = max(xyz.x + xyz.y + xyz.z, 1e-4);
310
+ let xyY = vec3f(xyz.x / div, xyz.y / div, pow(clamp(xyz.y, 0.0, 65504.0), 0.9811));
311
+ let m = xyY.z / max(xyY.y, 1e-4);
312
+ xyz = vec3f(xyY.x * m, xyY.z, (1.0 - xyY.x - xyY.y) * m);
313
+ var lin = _rzRows(vec3f(1.6410233797, -0.3248032942, -0.2364246952),
314
+ vec3f(-0.6636628587, 1.6153315917, 0.0167563477),
315
+ vec3f(0.0117218943, -0.0082844420, 0.9883948585), xyz);
316
+ // The ODT's desaturation, then to the display's primaries.
317
+ lin = mix(vec3f(_rzAp1Luma(lin)), lin, vec3f(0.93));
318
+ xyz = _rzRows(vec3f(0.98722400, -0.00611327, 0.0159533),
319
+ vec3f(-0.00759836, 1.00186000, 0.0053302),
320
+ vec3f(0.00307257, -0.00509595, 1.0816800), _rzAp1ToXyz(lin));
321
+ let rec709 = _rzRows(vec3f(3.2409699419, -1.5373831776, -0.4986107603),
322
+ vec3f(-0.9692436363, 1.8759675015, 0.0415550574),
323
+ vec3f(0.0556300797, -0.2039769589, 1.0569715142), xyz);
324
+ return _rzSrgbEncode3(clamp(rec709, vec3f(0.0), vec3f(1.0)));
280
325
  }
281
326
 
282
- /** Which display transform, chosen per frame at viewU[6].y (0 filmic, 1 standard, 2 agx, 3 aether-gazer).
283
- * A uniform branch rather than a pipeline variant: switching is rare, and both
284
- * arms are cheap enough that specialising the pipeline would buy nothing. */
327
+ /** Which display transform, chosen per frame at viewU[6].y (0 soft, 1 none,
328
+ * 2 neutral, 3 aces). A uniform branch rather than a pipeline variant: switching
329
+ * is rare, and every arm is cheap enough that specialising the pipeline would
330
+ * buy nothing. */
285
331
  fn viewTransform(c: vec3f) -> vec3f {
286
332
  let mode = viewU[6].y;
287
- if (mode > 2.5) { return agTransform(c); }
288
- if (mode > 1.5) { return agxTransform(c); }
289
- if (mode > 0.5) { return vec3f(srgb_encode(c.r), srgb_encode(c.g), srgb_encode(c.b)); }
290
- return vec3f(filmic(c.r), filmic(c.g), filmic(c.b));
333
+ if (mode > 2.5) { return acesTransform(c); }
334
+ if (mode > 1.5) { return neutralTransform(c); }
335
+ if (mode > 0.5) { return _rzSrgbEncode3(c); }
336
+ return softTransform(c);
291
337
  }
292
338
 
293
339
  fn _rzSrgbDecode(x: f32) -> f32 {
@@ -554,11 +600,9 @@ const COMPOSITE_BODY = /* wgsl */ `
554
600
  // ── Film grain, on the SCENE ONLY ─────────────────────────────────────────
555
601
  //
556
602
  // Applied here, before the background is composited under, so it rides on what
557
- // the engine drew and nothing else. That placement is the whole point when the
558
- // background is footage: the plate came off a real sensor and already carries
559
- // its own grain, and a second helping over the top would grade the photograph
560
- // rather than match it. A clean CG figure on a grainy plate is one of the
561
- // loudest tells there is — the noise gives it away long before the geometry.
603
+ // the engine drew and nothing else: a backdrop photo or video already carries
604
+ // its own grain, and a second helping over the top would grade the picture
605
+ // rather than match it.
562
606
  //
563
607
  // Multiplicative and weighted toward the mid-tones, which is how film behaves:
564
608
  // little grain in the blacks, and the highlights clip it off.
@@ -141,7 +141,7 @@ struct MirrorVP { viewProj: mat4x4f, params: vec4f, };
141
141
  struct MirrorClip { plane: vec4f, on: f32, _p0: f32, _p1: f32, _p2: f32, };
142
142
  @group(0) @binding(14) var<uniform> clip: MirrorClip;
143
143
  ${WORLD_AMBIENT_WGSL}
144
- ${lightsApi(0, 6, "1u")}
144
+ ${lightsApi(0, 6, "1u", { binding: 15, sampler: "linearSampler" })}
145
145
 
146
146
  struct VO { @builtin(position) position: vec4f, @location(0) worldPos: vec3f, @location(1) normal: vec3f, };
147
147
  @vertex fn vs(@location(0) position: vec3f, @location(1) normal: vec3f, @location(2) uv: vec2f) -> VO {
@@ -1,6 +1,24 @@
1
1
  /** Bytes in MirrorMat below: mat4x4f + three 16-byte rows. */
2
2
  export declare const MIRROR_MAT_BYTES: number;
3
3
  export declare function mirrorShaderWgsl(): string;
4
+ /**
5
+ * Downsample for the reflection's COLOUR chain, one pass per mip level: the
6
+ * Jimenez/COD 13-tap dual box — five weighted 2x2 averages, which rejects the
7
+ * nyquist ringing a plain box would leave in a blurred reflection.
8
+ */
9
+ export declare const MIRROR_DOWNSAMPLE_WGSL = "\n@vertex fn vs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {\n let x = f32((vi & 1u) << 2u) - 1.0;\n let y = f32((vi & 2u) << 1u) - 1.0;\n return vec4f(x, y, 0.0, 1.0);\n}\n\n@group(0) @binding(0) var srcTex: texture_2d<f32>;\n@group(0) @binding(1) var srcSamp: sampler;\n\nfn samp(uv: vec2f, off: vec2f) -> vec3f {\n return textureSampleLevel(srcTex, srcSamp, uv + off, 0.0).rgb;\n}\n\n@fragment fn fs(@builtin(position) p: vec4f) -> @location(0) vec4f {\n let srcDims = vec2f(textureDimensions(srcTex));\n let t = 1.0 / srcDims;\n // fragCoord.xy reports pixel centers (e.g. 0.5,0.5 for first pixel) \u2014 divide by dst dims directly.\n let dstDims = srcDims * 0.5;\n let uv = p.xy / max(dstDims, vec2f(1.0));\n let A = samp(uv, t * vec2f(-2.0, -2.0));\n let B = samp(uv, t * vec2f( 0.0, -2.0));\n let C = samp(uv, t * vec2f( 2.0, -2.0));\n let D = samp(uv, t * vec2f(-1.0, -1.0));\n let E = samp(uv, t * vec2f( 1.0, -1.0));\n let F = samp(uv, t * vec2f(-2.0, 0.0));\n let G = samp(uv, t * vec2f( 0.0, 0.0));\n let H = samp(uv, t * vec2f( 2.0, 0.0));\n let I = samp(uv, t * vec2f(-1.0, 1.0));\n let J = samp(uv, t * vec2f( 1.0, 1.0));\n let K = samp(uv, t * vec2f(-2.0, 2.0));\n let L = samp(uv, t * vec2f( 0.0, 2.0));\n let M = samp(uv, t * vec2f( 2.0, 2.0));\n var o = (D + E + I + J) * (0.5 / 4.0);\n o = o + (A + B + G + F) * (0.125 / 4.0);\n o = o + (B + C + H + G) * (0.125 / 4.0);\n o = o + (F + G + L + K) * (0.125 / 4.0);\n o = o + (G + H + M + L) * (0.125 / 4.0);\n return vec4f(o, 1.0);\n}\n";
10
+ /**
11
+ * Downsample for the reflection's COVERAGE chain.
12
+ *
13
+ * Colour and coverage are a premultiplied PAIR, and a blur has to move them
14
+ * together. Blurring colour alone drags it toward zero wherever it spreads into
15
+ * the empty part of the reflection, while a coverage still sampled sharp says
16
+ * "fully covered" — so the reflection simply got darker as the dial came up.
17
+ *
18
+ * One bilinear tap at the destination texel centre averages the source's 2x2
19
+ * exactly, which is the box filter this wants and the cheapest way to write it.
20
+ */
21
+ export declare const MIRROR_MASK_DOWNSAMPLE_WGSL = "\n@group(0) @binding(0) var src: texture_2d<f32>;\n@group(0) @binding(1) var samp: sampler;\n\nstruct VSOut { @builtin(position) pos: vec4f, @location(0) uv: vec2f, };\n\n@vertex fn vs(@builtin(vertex_index) i: u32) -> VSOut {\n var out: VSOut;\n let x = f32(i32(i / 2u) * 4 - 1);\n let y = f32(i32(i % 2u) * 4 - 1);\n out.pos = vec4f(x, y, 0.0, 1.0);\n out.uv = vec2f(x * 0.5 + 0.5, 0.5 - y * 0.5);\n return out;\n}\n\n@fragment fn fs(in: VSOut) -> @location(0) vec4f {\n return textureSampleLevel(src, samp, in.uv, 0.0);\n}\n";
4
22
  /**
5
23
  * The mirror in the SHADOW pass — the frame throwing shade on the floor.
6
24
  *
@@ -16,17 +34,5 @@ export declare function mirrorShaderWgsl(): string;
16
34
  * offset: the cascade matrices sit 64 bytes apart in one array, and a dynamic
17
35
  * uniform offset has to be 256-aligned.
18
36
  */
19
- /**
20
- * Downsample for the reflection's COVERAGE chain.
21
- *
22
- * Colour and coverage are a premultiplied PAIR, and a blur has to move them
23
- * together. Blurring colour alone drags it toward zero wherever it spreads into
24
- * the empty part of the reflection, while a coverage still sampled sharp says
25
- * "fully covered" — so the reflection simply got darker as the dial came up.
26
- *
27
- * One bilinear tap at the destination texel centre averages the source's 2x2
28
- * exactly, which is the box filter this wants and the cheapest way to write it.
29
- */
30
- export declare const MIRROR_MASK_DOWNSAMPLE_WGSL = "\n@group(0) @binding(0) var src: texture_2d<f32>;\n@group(0) @binding(1) var samp: sampler;\n\nstruct VSOut { @builtin(position) pos: vec4f, @location(0) uv: vec2f, };\n\n@vertex fn vs(@builtin(vertex_index) i: u32) -> VSOut {\n var out: VSOut;\n let x = f32(i32(i / 2u) * 4 - 1);\n let y = f32(i32(i % 2u) * 4 - 1);\n out.pos = vec4f(x, y, 0.0, 1.0);\n out.uv = vec2f(x * 0.5 + 0.5, 0.5 - y * 0.5);\n return out;\n}\n\n@fragment fn fs(in: VSOut) -> @location(0) vec4f {\n return textureSampleLevel(src, samp, in.uv, 0.0);\n}\n";
31
37
  export declare function mirrorShadowWgsl(cascades: number): string;
32
38
  //# sourceMappingURL=mirror.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"mirror.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/mirror.ts"],"names":[],"mappings":"AAqBA,8DAA8D;AAC9D,eAAO,MAAM,gBAAgB,QAAc,CAAA;AAE3C,wBAAgB,gBAAgB,IAAI,MAAM,CAiMzC;AAED;;;;;;;;;;;;;;GAcG;AACH;;;;;;;;;;GAUG;AACH,eAAO,MAAM,2BAA2B,2hBAkBvC,CAAA;AAED,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAuBzD"}
1
+ {"version":3,"file":"mirror.d.ts","sourceRoot":"","sources":["../../../src/shaders/passes/mirror.ts"],"names":[],"mappings":"AAqBA,8DAA8D;AAC9D,eAAO,MAAM,gBAAgB,QAAc,CAAA;AAE3C,wBAAgB,gBAAgB,IAAI,MAAM,CAiMzC;AAED;;;;GAIG;AACH,eAAO,MAAM,sBAAsB,2hDAwClC,CAAA;AAED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,2BAA2B,2hBAkBvC,CAAA;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAuBzD"}