@forgeax/engine-shader 0.1.27 → 0.1.29

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 (73) hide show
  1. package/NOTICE +35 -0
  2. package/README.md +39 -0
  3. package/dist/ShaderRegistry.d.ts +8 -0
  4. package/dist/ShaderRegistry.d.ts.map +1 -1
  5. package/dist/index.d.ts +6 -2
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.mjs +312 -71
  8. package/dist/index.mjs.map +1 -1
  9. package/dist/material/artifact-types.d.ts +12 -2
  10. package/dist/material/artifact-types.d.ts.map +1 -1
  11. package/dist/material-schemas.d.ts +5 -0
  12. package/dist/material-schemas.d.ts.map +1 -1
  13. package/package.json +5 -4
  14. package/src/ShaderRegistry.ts +10 -0
  15. package/src/__tests__/auto-exposure-graph.unit.test.ts +59 -0
  16. package/src/__tests__/bloom-fxaa-tonemap.unit.test.ts +30 -15
  17. package/src/__tests__/builtin-texture-sampling-contract.test.ts +1 -1
  18. package/src/__tests__/default-standard-pbr-alpha.unit.test.ts +2 -1
  19. package/src/__tests__/default-standard-pbr-transmission.unit.test.ts +167 -6
  20. package/src/__tests__/direct-light-layout.unit.test.ts +1 -1
  21. package/src/__tests__/lighting-punctual.unit.test.ts +29 -1
  22. package/src/__tests__/ltc-provenance.unit.test.ts +13 -2
  23. package/src/__tests__/material-builtins.unit.test.ts +1 -0
  24. package/src/__tests__/material-contract.unit.test.ts +42 -9
  25. package/src/__tests__/material-derived-builtins.integration.test.ts +18 -2
  26. package/src/__tests__/probe-lighting-composition.unit.test.ts +1 -1
  27. package/src/__tests__/reflection-probe-sampling.unit.test.ts +23 -2
  28. package/src/__tests__/scene-temporal.unit.test.ts +10 -0
  29. package/src/__tests__/shader.unit.test.ts +1 -0
  30. package/src/__tests__/sprite-variants.unit.test.ts +1 -0
  31. package/src/__tests__/ssr-artifact.unit.test.ts +141 -0
  32. package/src/__tests__/ssr-bgl.integration.test.ts +185 -0
  33. package/src/__tests__/standard-output-domain.unit.test.ts +36 -0
  34. package/src/__tests__/standard-pbr-artifact-receipt.unit.test.ts +93 -0
  35. package/src/__tests__/standard-surface-pass-evaluation.integration.test.ts +16 -4
  36. package/src/__tests__/surface-v1.unit.test.ts +6 -0
  37. package/src/__tests__/taa-resolve.unit.test.ts +20 -9
  38. package/src/__tests__/transmission-thickness-scale.unit.test.ts +6 -1
  39. package/src/__tests__/transparent-pbr.unit.test.ts +1 -1
  40. package/src/__tests__/vertex-color-variant.unit.test.ts +4 -1
  41. package/src/atmosphere-background.wgsl +3 -3
  42. package/src/auto-exposure-meter.wgsl +179 -0
  43. package/src/color-lut.wgsl +17 -0
  44. package/src/common.wgsl +7 -5
  45. package/src/default-standard-pbr-skin.wgsl +142 -40
  46. package/src/default-standard-pbr.wgsl +198 -82
  47. package/src/default_standard_surface.wgsl +56 -19
  48. package/src/fxaa.wgsl +9 -5
  49. package/src/ibl-sampling.wgsl +30 -6
  50. package/src/index.ts +189 -0
  51. package/src/lighting-punctual.wgsl +5 -6
  52. package/src/material/artifact-types.ts +109 -63
  53. package/src/material-schemas.ts +84 -8
  54. package/src/output-encoding.wgsl +10 -0
  55. package/src/pbr-temporal.wgsl +9 -1
  56. package/src/scene-temporal.wgsl +2 -0
  57. package/src/shadow-pcf.wgsl +6 -8
  58. package/src/shadow-surface.wgsl +16 -0
  59. package/src/shadow_caster.wgsl +99 -61
  60. package/src/sprite-lit.wgsl +4 -4
  61. package/src/sprite.wgsl +3 -3
  62. package/src/ssr-compose.wgsl +129 -0
  63. package/src/ssr-hiz-reduce.wgsl +53 -0
  64. package/src/ssr-hiz.wgsl +66 -0
  65. package/src/ssr-temporal.wgsl +305 -0
  66. package/src/ssr-trace.wgsl +784 -0
  67. package/src/standard-cluster.wgsl +6 -4
  68. package/src/standard-surface.wgsl +696 -0
  69. package/src/surface_v1.wgsl +6 -0
  70. package/src/taa-resolve.wgsl +222 -28
  71. package/src/tbn.wgsl +1 -1
  72. package/src/tonemap.wgsl +37 -18
  73. package/src/unlit.wgsl +3 -3
@@ -0,0 +1,784 @@
1
+ #define_import_path forgeax_ssr::trace
2
+
3
+ // The spatial trace is bounded by the public M1 contract.
4
+ const SSR_TRACE_MAX_COARSE_STEPS : u32 = 48u;
5
+ const SSR_TRACE_MAX_REFINE_STEPS : u32 = 5u;
6
+
7
+ @group(0) @binding(0) var sceneDepth : texture_depth_2d;
8
+ @group(0) @binding(1) var sceneNormal : texture_2d<f32>;
9
+ @group(0) @binding(2) var sceneColor : texture_2d<f32>;
10
+ @group(0) @binding(3) var hizPyramid : texture_2d<f32>;
11
+ @group(0) @binding(4) var traceOutput : texture_storage_2d<rgba16float, write>;
12
+
13
+ // This is the existing Standard View UBO, not a second camera state source.
14
+ // The shader only consumes world/inverse projection and the validated SSR
15
+ // parameter tail published by the record stage.
16
+ #import forgeax_view::common::View
17
+ @group(0) @binding(5) var<uniform> view : View;
18
+ // RGB belongs to lighting; alpha is its exact Standard material coverage.
19
+ @group(0) @binding(6) var reflectionFallback : texture_2d<f32>;
20
+ @group(0) @binding(7) var sceneTemporal : texture_2d<f32>;
21
+ // Motion at a radiance source invalidates reflection history even when the
22
+ // receiver is stationary. This scalar is reactivity, not reflected velocity.
23
+ @group(0) @binding(8) var hitReactivityOutput : texture_storage_2d<r32float, write>;
24
+
25
+ fn isFinite(value : f32) -> bool {
26
+ return value == value && abs(value) < 3.402823e+38;
27
+ }
28
+
29
+ fn finiteConfidence(value : f32) -> f32 {
30
+ return select(0.0, clamp(value, 0.0, 1.0), isFinite(value));
31
+ }
32
+
33
+ fn traceConfidence(
34
+ hit : f32,
35
+ thickness : f32,
36
+ facing : f32,
37
+ edge : f32,
38
+ roughness : f32,
39
+ temporal : f32,
40
+ ) -> f32 {
41
+ let factors = vec3<f32>(hit, thickness, facing);
42
+ let spatial = vec3<f32>(edge, roughness, temporal);
43
+ if (!isFinite(factors.x) || !isFinite(factors.y) || !isFinite(factors.z) ||
44
+ !isFinite(spatial.x) || !isFinite(spatial.y) || !isFinite(spatial.z)) {
45
+ return 0.0;
46
+ }
47
+ return finiteConfidence(factors.x) * finiteConfidence(factors.y) *
48
+ finiteConfidence(factors.z) * finiteConfidence(spatial.x) *
49
+ finiteConfidence(spatial.y) * finiteConfidence(spatial.z);
50
+ }
51
+
52
+ fn traceEdge(uv : vec2<f32>) -> f32 {
53
+ let distanceToEdge = min(min(uv.x, 1.0 - uv.x), min(uv.y, 1.0 - uv.y));
54
+ return clamp(distanceToEdge * 8.0, 0.0, 1.0);
55
+ }
56
+
57
+ fn reconstructWorldPosition(uv : vec2<f32>, depth : f32) -> vec3<f32> {
58
+ let clip = vec4<f32>(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, depth, 1.0);
59
+ let world = view.inverseViewProj * clip;
60
+ if (!isFinite(world.w) || abs(world.w) <= 1e-5) {
61
+ return vec3<f32>(0.0);
62
+ }
63
+ return world.xyz / world.w;
64
+ }
65
+
66
+ fn projectWorldPosition(worldPosition : vec3<f32>) -> vec2<f32> {
67
+ let clip = view.worldViewProj * vec4<f32>(worldPosition, 1.0);
68
+ if (!isFinite(clip.w) || clip.w <= 1e-5) {
69
+ return vec2<f32>(-1.0);
70
+ }
71
+ let ndc = clip.xy / clip.w;
72
+ return vec2<f32>(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
73
+ }
74
+
75
+ fn projectWorldViewDistance(worldPosition : vec3<f32>) -> f32 {
76
+ let clip = view.worldViewProj * vec4<f32>(worldPosition, 1.0);
77
+ if (!isFinite(clip.w) || clip.w <= 1e-5) {
78
+ return 0.0;
79
+ }
80
+ // Hi-Z stores positive linear view distance. Perspective clip.w is that
81
+ // distance; orthographic clip.w is constant and needs the projection range.
82
+ // Comparing clip.z / clip.w with Hi-Z mixes normalized depth and world units
83
+ // and rejects every visible ray in ordinary scenes more than one unit away.
84
+ return select(
85
+ clip.w,
86
+ view.temporalProjection.x + (clip.z / clip.w) *
87
+ (view.temporalProjection.y - view.temporalProjection.x),
88
+ view.temporalProjection.z > 0.5,
89
+ );
90
+ }
91
+
92
+ fn maxSsrHiZMip(fullSize : vec2<u32>) -> u32 {
93
+ let extent = max(fullSize.x, fullSize.y);
94
+ if (extent <= 1u) {
95
+ return 0u;
96
+ }
97
+ return u32(floor(log2(f32(extent))));
98
+ }
99
+
100
+ // Lookup uses the physical mip's normalized coordinate. The producer's
101
+ // conservative ceil-end footprint makes the neighboring lookup cells share
102
+ // an odd source boundary instead of dropping it between floor-sized cells.
103
+ fn ssrHiZCoordinate(uv : vec2<f32>, destinationSize : vec2<u32>) -> vec2<u32> {
104
+ let destinationLast = destinationSize - vec2<u32>(1u);
105
+ let destinationCoordinate = min(
106
+ vec2<u32>(clamp(uv * vec2<f32>(destinationSize), vec2<f32>(0.0), vec2<f32>(destinationLast))),
107
+ destinationLast,
108
+ );
109
+ return destinationCoordinate;
110
+ }
111
+
112
+ fn ssrHiZCoverageStart(index : u32, sourceSize : u32, destinationSize : u32) -> u32 {
113
+ return index * sourceSize / destinationSize;
114
+ }
115
+
116
+ fn ssrHiZCoverageEnd(index : u32, sourceSize : u32, destinationSize : u32) -> u32 {
117
+ return min(
118
+ ((index + 1u) * sourceSize + destinationSize - 1u) / destinationSize,
119
+ sourceSize,
120
+ );
121
+ }
122
+
123
+ fn sampleSsrHiZ(uv : vec2<f32>, mip : u32) -> f32 {
124
+ if (mip == 0u) {
125
+ let size = textureDimensions(sceneDepth, 0);
126
+ let pixel = clamp(vec2<i32>(uv * vec2<f32>(size)), vec2<i32>(0), vec2<i32>(size) - vec2<i32>(1));
127
+ let depth = textureLoad(sceneDepth, pixel, 0);
128
+ if (depth <= 0.0 || depth >= 1.0) { return 3.402823e+38; }
129
+ let centerUv = (vec2<f32>(pixel) + vec2<f32>(0.5)) / vec2<f32>(size);
130
+ return projectWorldViewDistance(reconstructWorldPosition(centerUv, depth));
131
+ }
132
+ let physicalMip = min(mip - 1u, textureNumLevels(hizPyramid) - 1u);
133
+ let size = textureDimensions(hizPyramid, physicalMip);
134
+ let coordinate = ssrHiZCoordinate(uv, size);
135
+ let depth = textureLoad(hizPyramid, vec2<i32>(coordinate), i32(physicalMip)).r;
136
+ return select(3.402823e+38, depth, isFinite(depth) && depth > 0.0);
137
+ }
138
+
139
+ struct SsrTraceHit {
140
+ hit : f32,
141
+ uv : vec2<f32>,
142
+ thickness : f32,
143
+ reactivity : f32,
144
+ };
145
+
146
+ struct SsrDepthCandidate {
147
+ valid : bool,
148
+ pixel : vec2<u32>,
149
+ uv : vec2<f32>,
150
+ depth : f32,
151
+ fraction : f32,
152
+ };
153
+
154
+ fn ssrInvalidDepthCandidate() -> SsrDepthCandidate {
155
+ return SsrDepthCandidate(false, vec2<u32>(0u), vec2<f32>(0.0), 1.0, 0.0);
156
+ }
157
+
158
+ fn ssrRayFraction(uv : vec2<f32>, startUv : vec2<f32>, deltaUv : vec2<f32>,
159
+ fallback : f32, endFraction : f32) -> f32 {
160
+ let useX = abs(deltaUv.x) >= abs(deltaUv.y) && abs(deltaUv.x) > 1e-8;
161
+ let useY = !useX && abs(deltaUv.y) > 1e-8;
162
+ let xFraction = select(fallback, (uv.x - startUv.x) / deltaUv.x, useX);
163
+ let yFraction = select(fallback, (uv.y - startUv.y) / deltaUv.y, useY);
164
+ let fraction = select(xFraction, yFraction, useY);
165
+ return clamp(select(fallback, fraction, isFinite(fraction)), 0.0, endFraction);
166
+ }
167
+
168
+ fn ssrRescueCandidateInRay(origin : vec3<f32>, direction : vec3<f32>,
169
+ rayLength : f32, surface : vec3<f32>, normal : vec3<f32>,
170
+ fullSize : vec2<u32>, pixel : vec2<u32>) -> bool {
171
+ // A rescue may bypass the generic bisection, so it must still prove that
172
+ // the candidate's tangent plane intersects the finite reflected segment.
173
+ // The projected pixel check rejects a neighboring silhouette plane whose
174
+ // depth happens to be within the broad thickness radius.
175
+ let denominator = dot(direction, normal);
176
+ let planeDistance = dot(surface - origin, normal) / denominator;
177
+ if (!isFinite(denominator) || abs(denominator) <= 1e-5 ||
178
+ !isFinite(planeDistance) || planeDistance <= 0.0 || planeDistance > rayLength) {
179
+ return false;
180
+ }
181
+ let projected = projectWorldPosition(origin + direction * planeDistance);
182
+ if (any(projected < vec2<f32>(0.0)) || any(projected >= vec2<f32>(1.0))) { return false; }
183
+ let projectedPixel = min(vec2<u32>(projected * vec2<f32>(fullSize)), fullSize - vec2<u32>(1u));
184
+ return all(projectedPixel == pixel);
185
+ }
186
+
187
+ // When the coarse march lands on an empty full-resolution texel, descend the
188
+ // already-produced Hi-Z minimum hierarchy to recover the child footprint that
189
+ // contains the nearest surface. This keeps the public 48-step budget while
190
+ // retaining thin projected features between coarse samples.
191
+ fn locateSsrHiZCandidate(uv : vec2<f32>, coarseMip : u32, fullSize : vec2<u32>,
192
+ startUv : vec2<f32>, deltaUv : vec2<f32>, fallbackFraction : f32,
193
+ endFraction : f32) -> SsrDepthCandidate {
194
+ if (coarseMip == 0u || textureNumLevels(hizPyramid) == 0u) {
195
+ return ssrInvalidDepthCandidate();
196
+ }
197
+ let physicalMip = coarseMip - 1u;
198
+ let size = textureDimensions(hizPyramid, i32(physicalMip));
199
+ var coordinate = ssrHiZCoordinate(uv, size);
200
+ let depth = textureLoad(hizPyramid, vec2<i32>(coordinate), i32(physicalMip)).r;
201
+ if (!isFinite(depth) || depth <= 0.0) { return ssrInvalidDepthCandidate(); }
202
+
203
+ // Each reduction level stores the minimum of its complete integer-normalized
204
+ // child footprint. Select the child that owns that minimum rather than
205
+ // assuming the parent's center is the hit location. The final mip-0
206
+ // candidate is still validated against the real depth/normal/coverage
207
+ // buffers below.
208
+ var parentSize = size;
209
+ for (var descend = coarseMip; descend > 1u; descend -= 1u) {
210
+ let nextPhysicalMip = descend - 2u;
211
+ let nextSize = textureDimensions(hizPyramid, i32(nextPhysicalMip));
212
+ let childStart = vec2<u32>(
213
+ ssrHiZCoverageStart(coordinate.x, nextSize.x, parentSize.x),
214
+ ssrHiZCoverageStart(coordinate.y, nextSize.y, parentSize.y),
215
+ );
216
+ let childEnd = vec2<u32>(
217
+ ssrHiZCoverageEnd(coordinate.x, nextSize.x, parentSize.x),
218
+ ssrHiZCoverageEnd(coordinate.y, nextSize.y, parentSize.y),
219
+ );
220
+ var childCoordinate = vec2<u32>(0u);
221
+ var childDepth = 3.402823e+38;
222
+ var childFound = false;
223
+ for (var childY = childStart.y; childY < childEnd.y; childY += 1u) {
224
+ for (var childX = childStart.x; childX < childEnd.x; childX += 1u) {
225
+ let candidate = min(vec2<u32>(childX, childY), nextSize - vec2<u32>(1u));
226
+ let candidateDepth = textureLoad(
227
+ hizPyramid,
228
+ vec2<i32>(candidate),
229
+ i32(nextPhysicalMip),
230
+ ).r;
231
+ if (isFinite(candidateDepth) && candidateDepth > 0.0 && candidateDepth < childDepth) {
232
+ childCoordinate = candidate;
233
+ childDepth = candidateDepth;
234
+ childFound = true;
235
+ }
236
+ }
237
+ }
238
+ if (!childFound) { return ssrInvalidDepthCandidate(); }
239
+ coordinate = childCoordinate;
240
+ parentSize = nextSize;
241
+ }
242
+
243
+ // Hi-Z mip 0 is seeded from the integer source footprint used by
244
+ // `ssr_hiz_seed`: floor(cell * fullSize / hizSize) through the next cell
245
+ // boundary. Even dimensions are 2x2, while odd dimensions use the same
246
+ // conservative ceil-end overlap as every reduction level. Its minimum depth
247
+ // is not necessarily the footprint center, so select the actual nearest
248
+ // valid source texel before validating coverage/normal.
249
+ let hizSize = textureDimensions(hizPyramid, 0);
250
+ let sourceStart = vec2<u32>(
251
+ ssrHiZCoverageStart(coordinate.x, fullSize.x, hizSize.x),
252
+ ssrHiZCoverageStart(coordinate.y, fullSize.y, hizSize.y),
253
+ );
254
+ let sourceEnd = vec2<u32>(
255
+ ssrHiZCoverageEnd(coordinate.x, fullSize.x, hizSize.x),
256
+ ssrHiZCoverageEnd(coordinate.y, fullSize.y, hizSize.y),
257
+ );
258
+ var candidatePixel = sourceStart;
259
+ var candidateRawDepth = 3.402823e+38;
260
+ for (var sourceY = sourceStart.y; sourceY < sourceEnd.y; sourceY += 1u) {
261
+ for (var sourceX = sourceStart.x; sourceX < sourceEnd.x; sourceX += 1u) {
262
+ let sourcePixel = min(
263
+ vec2<u32>(sourceX, sourceY),
264
+ fullSize - vec2<u32>(1u),
265
+ );
266
+ let sourceDepth = textureLoad(sceneDepth, vec2<i32>(sourcePixel), 0);
267
+ // Perspective and orthographic depth are monotonic with view distance,
268
+ // so comparing the raw depth values preserves the Hi-Z nearest owner.
269
+ if (isFinite(sourceDepth) && sourceDepth > 0.0 && sourceDepth < 1.0 &&
270
+ sourceDepth < candidateRawDepth) {
271
+ candidatePixel = sourcePixel;
272
+ candidateRawDepth = sourceDepth;
273
+ }
274
+ }
275
+ }
276
+ if (!isFinite(candidateRawDepth) || candidateRawDepth <= 0.0 || candidateRawDepth >= 1.0) {
277
+ return ssrInvalidDepthCandidate();
278
+ }
279
+ let candidateDepth = textureLoad(sceneDepth, vec2<i32>(candidatePixel), 0);
280
+ let candidateCenterUv = (vec2<f32>(candidatePixel) + vec2<f32>(0.5)) / vec2<f32>(fullSize);
281
+ return SsrDepthCandidate(
282
+ true,
283
+ candidatePixel,
284
+ candidateCenterUv,
285
+ candidateDepth,
286
+ ssrRayFraction(candidateCenterUv, startUv, deltaUv, fallbackFraction, endFraction),
287
+ );
288
+ }
289
+
290
+ // A structural/direct trace may be supplied without a Hi-Z hierarchy. Keep
291
+ // that path bounded too, but inspect a short projected neighborhood so a thin
292
+ // depth texel between two coarse samples is not silently lost.
293
+ fn locateSsrLocalCandidate(uv : vec2<f32>, pixel : vec2<u32>, fullSize : vec2<u32>,
294
+ startUv : vec2<f32>, deltaUv : vec2<f32>, fallbackFraction : f32,
295
+ endFraction : f32) -> SsrDepthCandidate {
296
+ let span = max(abs(deltaUv.x) * f32(fullSize.x), abs(deltaUv.y) * f32(fullSize.y));
297
+ if (!isFinite(span) || span <= 1e-5) { return ssrInvalidDepthCandidate(); }
298
+ var bestOffset = 9;
299
+ var bestDepth = 3.402823e+38;
300
+ var bestPixel = pixel;
301
+ for (var offset = -8; offset <= 8; offset += 1) {
302
+ let sampleUv = uv + deltaUv * (f32(offset) / span);
303
+ if (any(sampleUv < vec2<f32>(0.0)) || any(sampleUv >= vec2<f32>(1.0))) { continue; }
304
+ let samplePixel = min(
305
+ vec2<u32>(sampleUv * vec2<f32>(fullSize)),
306
+ fullSize - vec2<u32>(1u),
307
+ );
308
+ let sampleDepth = textureLoad(sceneDepth, vec2<i32>(samplePixel), 0);
309
+ let distanceFromCenter = abs(offset);
310
+ if (sampleDepth > 0.0 && sampleDepth < 1.0 &&
311
+ (distanceFromCenter < bestOffset ||
312
+ (distanceFromCenter == bestOffset && sampleDepth < bestDepth))) {
313
+ bestOffset = distanceFromCenter;
314
+ bestDepth = sampleDepth;
315
+ bestPixel = samplePixel;
316
+ }
317
+ }
318
+ if (bestOffset > 8) { return ssrInvalidDepthCandidate(); }
319
+ let candidateUv = (vec2<f32>(bestPixel) + vec2<f32>(0.5)) / vec2<f32>(fullSize);
320
+ return SsrDepthCandidate(
321
+ true,
322
+ bestPixel,
323
+ candidateUv,
324
+ bestDepth,
325
+ ssrRayFraction(candidateUv, startUv, deltaUv, fallbackFraction, endFraction),
326
+ );
327
+ }
328
+
329
+ fn ssrSourceReactivity(pixel : vec2<i32>) -> f32 {
330
+ let temporal = textureLoad(sceneTemporal, pixel, 0);
331
+ let speed = length(temporal.xy);
332
+ if (!isFinite(speed) || !isFinite(temporal.z) || temporal.z < 0.0 || !isFinite(temporal.w)) {
333
+ return 1.0;
334
+ }
335
+ return clamp(max(speed * 64.0, temporal.w), 0.0, 1.0);
336
+ }
337
+
338
+ fn ssrShadingNormalVaries(pixel : vec2<u32>, normal : vec3<f32>) -> bool {
339
+ let size = vec2<i32>(textureDimensions(sceneDepth, 0));
340
+ // Only a locally varying, similarly oriented field makes the tangent plane
341
+ // uncertain. A flat face ending at sky must retain its exact silhouette;
342
+ // thickness alone is not permission to extend that face into empty space.
343
+ for (var axis = 0u; axis < 2u; axis++) {
344
+ for (var sign = -1; sign <= 1; sign += 2) {
345
+ var offset = vec2<i32>(0);
346
+ offset[axis] = sign;
347
+ let tap = vec2<i32>(pixel) + offset;
348
+ if (any(tap < vec2<i32>(0)) || any(tap >= size)) { continue; }
349
+ if (textureLoad(sceneDepth, tap, 0) >= 1.0 ||
350
+ textureLoad(reflectionFallback, tap, 0).a <= 0.5) { continue; }
351
+ let neighbor = textureLoad(sceneNormal, tap, 0).xyz * 2.0 - vec3<f32>(1.0);
352
+ let delta = neighbor - normal;
353
+ let agreement = dot(neighbor, normal) * inverseSqrt(max(dot(neighbor, neighbor) * dot(normal, normal), 1e-8));
354
+ if (agreement > 0.9 && dot(delta, delta) > 1e-6) { return true; }
355
+ }
356
+ }
357
+ return false;
358
+ }
359
+
360
+ fn ssrGeometricNormal(pixel : vec2<u32>, normal : vec3<f32>) -> vec3<f32> {
361
+ // Recover a depth tangent from the nearest same-facing neighbor per axis.
362
+ // Missing support on either axis retains the original normal; it does not
363
+ // authorize a depth-only hit through a silhouette or uncovered surface.
364
+ let size = textureDimensions(sceneDepth, 0);
365
+ let center = reconstructWorldPosition((vec2<f32>(pixel) + 0.5) / vec2<f32>(size),
366
+ textureLoad(sceneDepth, vec2<i32>(pixel), 0));
367
+ var derivatives : array<vec3<f32>, 2>;
368
+ for (var axis = 0u; axis < 2u; axis++) {
369
+ var shortest = 3.402823e+38;
370
+ for (var sign = -1; sign <= 1; sign += 2) {
371
+ var offset = vec2<i32>(0);
372
+ offset[axis] = sign;
373
+ let tap = vec2<i32>(pixel) + offset;
374
+ if (any(tap < vec2<i32>(0)) || any(tap >= vec2<i32>(size))) { continue; }
375
+ let depth = textureLoad(sceneDepth, tap, 0);
376
+ let tapNormal = textureLoad(sceneNormal, tap, 0).xyz * 2.0 - 1.0;
377
+ if (depth >= 1.0 || dot(normal, tapNormal) < 0.9) { continue; }
378
+ let point = reconstructWorldPosition((vec2<f32>(tap) + 0.5) / vec2<f32>(size), depth);
379
+ let delta = (point - center) * f32(sign);
380
+ let distance = dot(delta, delta);
381
+ if (distance < shortest) { shortest = distance; derivatives[axis] = delta; }
382
+ }
383
+ }
384
+ let product = cross(derivatives[0], derivatives[1]);
385
+ let lengthSquared = dot(product, product);
386
+ if (lengthSquared <= 1e-16) { return normal; }
387
+ let geometric = product * inverseSqrt(lengthSquared);
388
+ return select(-geometric, geometric, dot(geometric, normal) >= 0.0);
389
+ }
390
+
391
+ fn ssrRefineHit(origin : vec3<f32>, direction : vec3<f32>, maxDistance : f32,
392
+ thickness : f32, fullSize : vec2<u32>, pixel : vec2<u32>, depth : f32,
393
+ normal : vec3<f32>, result : SsrTraceHit, geometric : bool) -> SsrTraceHit {
394
+ // A depth texel describes its center, not the subpixel march coordinate.
395
+ // Refine against that sample's tangent plane, then validate the projected
396
+ // intersection against the actual depth buffer. Otherwise a stair-stepped
397
+ // depth crossing displaces high-frequency reflected texture coordinates.
398
+ let centerUv = (vec2<f32>(pixel) + vec2<f32>(0.5)) / vec2<f32>(fullSize);
399
+ let surface = reconstructWorldPosition(centerUv, depth);
400
+ var planeNormal = normal;
401
+ if (geometric) { planeNormal = ssrGeometricNormal(pixel, normal); }
402
+ let planeDistance = dot(surface - origin, planeNormal) / dot(direction, planeNormal);
403
+ if (!isFinite(planeDistance) || planeDistance <= 0.0 || planeDistance > maxDistance) { return result; }
404
+ let intersection = origin + direction * planeDistance;
405
+ let hitUv = projectWorldPosition(intersection);
406
+ if (any(hitUv < vec2<f32>(0.0)) || any(hitUv >= vec2<f32>(1.0))) { return result; }
407
+ let hitPixel = min(vec2<u32>(hitUv * vec2<f32>(fullSize)), fullSize - vec2<u32>(1u));
408
+ let hitDepth = textureLoad(sceneDepth, vec2<i32>(hitPixel), 0);
409
+ let hitNormal = textureLoad(sceneNormal, vec2<i32>(hitPixel), 0).xyz * 2.0 - vec3<f32>(1.0);
410
+ if (hitDepth >= 1.0 || dot(hitNormal, -direction) <= 0.0 ||
411
+ textureLoad(reflectionFallback, vec2<i32>(hitPixel), 0).a <= 0.5) { return result; }
412
+ // Depth belongs to the texel center. Compare the intersection with that
413
+ // sample's plane, not a constant-depth point fabricated at hitUv. The latter
414
+ // lowers confidence on an exact oblique-plane hit as jitter moves inside
415
+ // the texel, periodically exposing fallback beneath a static reflection.
416
+ let hitCenterUv = (vec2<f32>(hitPixel) + vec2<f32>(0.5)) / vec2<f32>(fullSize);
417
+ let hitSurface = reconstructWorldPosition(hitCenterUv, hitDepth);
418
+ let neighbor = reconstructWorldPosition(hitCenterUv + vec2<f32>(1.0 / f32(fullSize.x), 0.0), hitDepth);
419
+ let radius = max(thickness, distance(hitSurface, neighbor) * 3.0);
420
+ var validationNormal = hitNormal;
421
+ if (geometric) { validationNormal = ssrGeometricNormal(hitPixel, hitNormal); }
422
+ let separation = abs(dot(hitSurface - intersection, normalize(validationNormal)));
423
+ if (!isFinite(separation) || separation > radius) { return result; }
424
+ return SsrTraceHit(1.0, hitUv, clamp(1.0 - separation / radius, 0.0, 1.0), result.reactivity);
425
+ }
426
+
427
+ // Algorithm reference: Three.js SSRShader / SSRNode (MIT), inspected 2026-09-08:
428
+ // https://github.com/mrdoob/three.js/blob/4457aa3c5de4a05bbae4e0bee2d99d994c38a2bb/examples/jsm/tsl/display/SSRNode.js
429
+ // Projected-space stepping, reciprocal-depth interpolation, ray-distance
430
+ // thickness, and a separate crossing refinement avoid world-step holes.
431
+ // Mirror rays spend steps in proportion to their projected pixel span, as in
432
+ // Three's non-stochastic path. A small fixed budget stretches samples over
433
+ // thin silhouettes and can jump directly from in-front-of-wall to sky.
434
+ // The 1024/5 bound, Hi-Z, material coverage, and BRDF owner remain explicit.
435
+ fn traceScreenRay(
436
+ origin : vec3<f32>,
437
+ direction : vec3<f32>,
438
+ maxDistance : f32,
439
+ thickness : f32,
440
+ fullSize : vec2<u32>,
441
+ hizDepth : f32,
442
+ hizMaxMip : u32,
443
+ ) -> SsrTraceHit {
444
+ var result = SsrTraceHit(0.0, vec2<f32>(0.0), 0.0, 0.0);
445
+ if (!isFinite(maxDistance) || maxDistance <= 0.0 ||
446
+ !isFinite(thickness) || thickness <= 0.0 || maxDistance < thickness ||
447
+ !isFinite(hizDepth) || hizDepth <= 0.0) { return result; }
448
+ let startDepth = projectWorldViewDistance(origin);
449
+ let depthDirection = dot(vec3<f32>(view.worldViewProj[0].w,
450
+ view.worldViewProj[1].w, view.worldViewProj[2].w), direction);
451
+ var rayLength = maxDistance;
452
+ if (depthDirection < -1e-5) {
453
+ rayLength = min(rayLength, (startDepth - view.temporalProjection.x * 1.01) / -depthDirection);
454
+ }
455
+ if (rayLength <= 0.0) { return result; }
456
+ let end = origin + direction * rayLength;
457
+ let endDepth = projectWorldViewDistance(end);
458
+ let startUv = projectWorldPosition(origin);
459
+ let deltaUv = projectWorldPosition(end) - startUv;
460
+ // Clip the projected segment to the viewport before spending the fixed budget.
461
+ var endFraction = 1.0;
462
+ if (deltaUv.x > 1e-6) { endFraction = min(endFraction, (1.0 - startUv.x) / deltaUv.x); }
463
+ if (deltaUv.x < -1e-6) { endFraction = min(endFraction, -startUv.x / deltaUv.x); }
464
+ if (deltaUv.y > 1e-6) { endFraction = min(endFraction, (1.0 - startUv.y) / deltaUv.y); }
465
+ if (deltaUv.y < -1e-6) { endFraction = min(endFraction, -startUv.y / deltaUv.y); }
466
+ let span = abs(deltaUv * endFraction * vec2<f32>(fullSize));
467
+ let count = min(SSR_TRACE_MAX_COARSE_STEPS, max(1u, u32(ceil(max(span.x, span.y)))));
468
+ let coarseMip = min(hizMaxMip, u32(floor(log2(max(1.0, max(span.x, span.y) / f32(count))))));
469
+ let inverseStart = 1.0 / max(startDepth, 1e-5);
470
+ let inverseEnd = 1.0 / max(endDepth, 1e-5);
471
+ var lower = 0.0;
472
+ var upper = 0.0;
473
+ var found = false;
474
+ var rescued = false;
475
+ var rescuedUv = vec2<f32>(0.0);
476
+ var rescuedThickness = 0.0;
477
+ for (var step = 1u; step <= SSR_TRACE_MAX_COARSE_STEPS; step++) {
478
+ if (step > count) { break; }
479
+ let fraction = f32(step) / f32(count) * endFraction;
480
+ let uv = startUv + deltaUv * fraction;
481
+ let pixel = min(vec2<u32>(clamp(uv, vec2<f32>(0.0), vec2<f32>(1.0)) *
482
+ vec2<f32>(fullSize)), fullSize - vec2<u32>(1u));
483
+ let rayDepth = 1.0 / mix(inverseStart, inverseEnd, fraction);
484
+ // The ray can cross the surface after this sample but before leaving its
485
+ // depth texel. Testing only rayDepth loses that crossing when the next
486
+ // sample is sky. Extend the bracket to this texel's exit, not its neighbor;
487
+ // the small pixel-space inset keeps the upper endpoint on the same texel.
488
+ let moving = abs(deltaUv) > vec2<f32>(1e-8);
489
+ let exitUv = (vec2<f32>(pixel) + select(vec2<f32>(0.0), vec2<f32>(1.0),
490
+ deltaUv > vec2<f32>(0.0))) / vec2<f32>(fullSize);
491
+ let exitFractions = select(vec2<f32>(endFraction),
492
+ (exitUv - startUv) / select(vec2<f32>(1.0), deltaUv, moving), moving);
493
+ let pixelSpan = max(abs(deltaUv.x) * f32(fullSize.x), abs(deltaUv.y) * f32(fullSize.y));
494
+ let exitFraction = max(fraction,
495
+ min(endFraction, min(exitFractions.x, exitFractions.y)) - 1e-4 / max(pixelSpan, 1.0));
496
+ let exitDepth = 1.0 / mix(inverseStart, inverseEnd, exitFraction);
497
+ let intervalDepth = max(rayDepth, exitDepth);
498
+ // Hi-Z contains point depths, not the minimum of an oblique surface's
499
+ // entire texel footprint. Keep the authored thickness in this broad-phase
500
+ // bound; the in-texel tangent intersection below still decides new hits.
501
+ // A zero logical mip means no reduced hierarchy is available. Its mip-0
502
+ // sample is the current full-resolution texel, so an empty texel must not
503
+ // discard the bounded neighborhood rescue below.
504
+ if (coarseMip > 0u && sampleSsrHiZ(uv, coarseMip) > intervalDepth + thickness) { continue; }
505
+ var samplePixel = pixel;
506
+ var sampleUv = uv;
507
+ var sampleDepth = textureLoad(sceneDepth, vec2<i32>(pixel), 0);
508
+ var sampleFraction = fraction;
509
+ var candidate = ssrInvalidDepthCandidate();
510
+ if (sampleDepth <= 0.0 || sampleDepth >= 1.0) {
511
+ candidate = locateSsrHiZCandidate(
512
+ uv,
513
+ coarseMip,
514
+ fullSize,
515
+ startUv,
516
+ deltaUv,
517
+ fraction,
518
+ endFraction,
519
+ );
520
+ // A local neighborhood is only a rescue for a genuinely undersampled
521
+ // projected step. On short rays the normal full-resolution march
522
+ // already visits each texel; scanning a wide neighborhood there would
523
+ // turn a nearby wall into a false hit on an otherwise empty sky ray.
524
+ let projectedStepPixels = max(span.x, span.y) / f32(max(count, 1u));
525
+ if (!candidate.valid && coarseMip == 0u && projectedStepPixels > 2.0) {
526
+ candidate = locateSsrLocalCandidate(
527
+ uv,
528
+ pixel,
529
+ fullSize,
530
+ startUv,
531
+ deltaUv,
532
+ fraction,
533
+ endFraction,
534
+ );
535
+ }
536
+ if (candidate.valid) {
537
+ samplePixel = candidate.pixel;
538
+ sampleUv = candidate.uv;
539
+ sampleDepth = candidate.depth;
540
+ sampleFraction = candidate.fraction;
541
+ }
542
+ }
543
+ if (sampleDepth >= 1.0 || textureLoad(reflectionFallback, vec2<i32>(samplePixel), 0).a <= 0.5) { continue; }
544
+ let sampleRayDepth = 1.0 / mix(inverseStart, inverseEnd, sampleFraction);
545
+ let surface = reconstructWorldPosition(sampleUv, sampleDepth);
546
+ if (intervalDepth + thickness < projectWorldViewDistance(surface)) { continue; }
547
+ // A moving occluder can invalidate yesterday's hit even when its back
548
+ // face or depth separation rejects today's radiance. Preserve that
549
+ // evidence on misses; sky and samples beyond the ray interval contribute
550
+ // nothing. This does not admit the occluder as a reflection source.
551
+ result.reactivity = max(result.reactivity, ssrSourceReactivity(vec2<i32>(samplePixel)));
552
+ let normal = textureLoad(sceneNormal, vec2<i32>(samplePixel), 0).xyz * 2.0 - vec3<f32>(1.0);
553
+ if (dot(normal, -direction) <= 0.0) { continue; }
554
+ if (candidate.valid && !ssrRescueCandidateInRay(
555
+ origin,
556
+ direction,
557
+ rayLength,
558
+ surface,
559
+ normal,
560
+ fullSize,
561
+ samplePixel,
562
+ )) { continue; }
563
+ if (sampleRayDepth < projectWorldViewDistance(surface)) {
564
+ // Extending the depth interval must not extend the sampled surface.
565
+ // At a silhouette the neighboring texel may belong to another plane.
566
+ if (!candidate.valid && !ssrRescueCandidateInRay(
567
+ origin,
568
+ direction,
569
+ rayLength,
570
+ surface,
571
+ normal,
572
+ fullSize,
573
+ samplePixel,
574
+ )) { continue; }
575
+ }
576
+ let neighbor = reconstructWorldPosition(sampleUv + vec2<f32>(1.0 / f32(fullSize.x), 0.0), sampleDepth);
577
+ let radius = max(thickness, distance(surface, neighbor) * 3.0);
578
+ let separation = length(cross(surface - origin, direction));
579
+ if (!isFinite(separation) || separation > radius) { continue; }
580
+ if (candidate.valid) {
581
+ rescued = true;
582
+ rescuedUv = sampleUv;
583
+ rescuedThickness = clamp(1.0 - separation / radius, 0.0, 1.0);
584
+ }
585
+ let bracketLower = min(f32(step - 1u) / f32(count) * endFraction, sampleFraction);
586
+ let bracketUpper = max(exitFraction, sampleFraction);
587
+ lower = select(sampleFraction, bracketLower,
588
+ sampleRayDepth >= projectWorldViewDistance(surface));
589
+ upper = select(bracketUpper, sampleFraction, sampleRayDepth >= projectWorldViewDistance(surface));
590
+ found = true;
591
+ break;
592
+ }
593
+ if (!found) { return result; }
594
+ // Depth crossing refinement is intentionally outside the march loop.
595
+ for (var refine = 0u; refine < SSR_TRACE_MAX_REFINE_STEPS; refine++) {
596
+ let middle = (lower + upper) * 0.5;
597
+ let uv = startUv + deltaUv * middle;
598
+ let pixel = min(vec2<u32>(clamp(uv, vec2<f32>(0.0), vec2<f32>(1.0)) *
599
+ vec2<f32>(fullSize)), fullSize - vec2<u32>(1u));
600
+ let surfaceDepth = sampleSsrHiZ(uv, 0u);
601
+ let rayDepth = 1.0 / mix(inverseStart, inverseEnd, middle);
602
+ // A depth crossing on a rejected surface is not the accepted bracket.
603
+ // Near contact points, depth-only bisection can walk backward onto the
604
+ // receiver even though the coarse step correctly found the facing wall.
605
+ let normal = textureLoad(sceneNormal, vec2<i32>(pixel), 0).xyz * 2.0 - vec3<f32>(1.0);
606
+ let covered = textureLoad(reflectionFallback, vec2<i32>(pixel), 0).a > 0.5;
607
+ if (rayDepth >= surfaceDepth && covered && dot(normal, -direction) > 0.0) {
608
+ upper = middle;
609
+ } else {
610
+ lower = middle;
611
+ }
612
+ }
613
+ let uv = startUv + deltaUv * upper;
614
+ let pixel = min(vec2<u32>(clamp(uv, vec2<f32>(0.0), vec2<f32>(1.0)) *
615
+ vec2<f32>(fullSize)), fullSize - vec2<u32>(1u));
616
+ let depth = textureLoad(sceneDepth, vec2<i32>(pixel), 0);
617
+ let normal = textureLoad(sceneNormal, vec2<i32>(pixel), 0).xyz * 2.0 - vec3<f32>(1.0);
618
+ let covered = textureLoad(reflectionFallback, vec2<i32>(pixel), 0).a > 0.5;
619
+ // The generic bisection can walk from a thin, valid rescue texel into the
620
+ // adjacent sky texel. The rescue was already validated against the actual
621
+ // depth/normal/coverage/thickness gates in the march, so preserve it before
622
+ // the final endpoint rejection discards the whole trace.
623
+ if (rescued && (depth >= 1.0 || dot(normal, -direction) <= 0.0 || !covered)) {
624
+ return SsrTraceHit(1.0, rescuedUv, rescuedThickness, result.reactivity);
625
+ }
626
+ if (depth >= 1.0 || dot(normal, -direction) <= 0.0 || !covered) { return result; }
627
+ // Preserve successful refinement and exact flat-face silhouettes. Smooth
628
+ // shading can point a failed correction away from the depth surface; retry
629
+ // with depth geometry, under the same source, facing and thickness checks.
630
+ let refined = ssrRefineHit(origin, direction, maxDistance, thickness, fullSize,
631
+ pixel, depth, normal, result, false);
632
+ if (refined.hit > 0.0 || !ssrShadingNormalVaries(pixel, normal)) {
633
+ if (refined.hit <= 0.0 && rescued) {
634
+ // Hi-Z/local rescue already validated the candidate against the full
635
+ // depth, normal, coverage, ray-distance, and thickness gates. Preserve
636
+ // that exact texel instead of letting the generic bisection walk back
637
+ // onto the empty coarse sample.
638
+ return SsrTraceHit(1.0, rescuedUv, rescuedThickness, result.reactivity);
639
+ }
640
+ return refined;
641
+ }
642
+ if (rescued) {
643
+ return SsrTraceHit(1.0, rescuedUv, rescuedThickness, result.reactivity);
644
+ }
645
+ return ssrRefineHit(origin, direction, maxDistance, thickness, fullSize,
646
+ pixel, depth, normal, result, true);
647
+ }
648
+
649
+ fn ssrHitTapAdmitted(pixel : vec2<i32>, rayDirection : vec3<f32>) -> bool {
650
+ let normal = textureLoad(sceneNormal, pixel, 0).xyz * 2.0 - vec3<f32>(1.0);
651
+ return textureLoad(reflectionFallback, pixel, 0).a > 0.5 && dot(normal, -rayDirection) > 0.0;
652
+ }
653
+
654
+ struct SsrHitSample {
655
+ color : vec4<f32>,
656
+ reactivity : f32,
657
+ };
658
+
659
+ fn sampleSsrHitSample(uv : vec2<f32>, rayDirection : vec3<f32>) -> SsrHitSample {
660
+ // Like Three's colorNode.sample(hitUv), retain the refined subpixel hit.
661
+ // Explicit bilinear loads preserve the existing sampler-free compute ABI.
662
+ // Color and source reactivity use the same four admitted texels, so keep one
663
+ // footprint walk instead of reloading normal/coverage for a second
664
+ // independent reactivity pass. Preserve the old zero-weight guard for the
665
+ // reactivity max; a zero-weight moving neighbor must not invalidate history.
666
+ // Roughness filtering still belongs to the reflection-only mip pyramid.
667
+ let size = vec2<i32>(textureDimensions(sceneColor, 0));
668
+ let position = uv * vec2<f32>(size) - vec2<f32>(0.5);
669
+ let first = vec2<i32>(floor(position));
670
+ let fraction = fract(position);
671
+ let last = size - vec2<i32>(1);
672
+ var sum = vec4<f32>(0.0);
673
+ var reactivity = 0.0;
674
+ for (var y = 0; y < 2; y++) {
675
+ for (var x = 0; x < 2; x++) {
676
+ let pixel = clamp(first + vec2<i32>(x, y), vec2<i32>(0), last);
677
+ // Match the trace's Standard source-coverage admission for every tap.
678
+ // Sky/background color is not hit radiance. Preserve its missing area
679
+ // as confidence so composition supplies the receiver's own fallback.
680
+ if (!ssrHitTapAdmitted(pixel, rayDirection)) {
681
+ continue;
682
+ }
683
+ let weight = select(1.0 - fraction.x, fraction.x, x == 1) * select(1.0 - fraction.y, fraction.y, y == 1);
684
+ let color = textureLoad(sceneColor, pixel, 0).rgb;
685
+ sum += vec4<f32>(color * weight, weight);
686
+ if (weight > 0.0) {
687
+ reactivity = max(reactivity, ssrSourceReactivity(pixel));
688
+ }
689
+ }
690
+ }
691
+ return SsrHitSample(vec4<f32>(sum.rgb / max(sum.a, 1e-6), sum.a), reactivity);
692
+ }
693
+
694
+ @compute @workgroup_size(8, 8, 1)
695
+ fn ssr_trace(@builtin(global_invocation_id) globalId : vec3<u32>) {
696
+ let fullSize = textureDimensions(sceneColor, 0);
697
+ let traceSize = max(fullSize / vec2<u32>(2u), vec2<u32>(1u));
698
+ if (globalId.x >= traceSize.x || globalId.y >= traceSize.y) {
699
+ return;
700
+ }
701
+ textureStore(hitReactivityOutput, vec2<i32>(globalId.xy), vec4<f32>(0.0));
702
+ let fullPixel = min(globalId.xy * vec2<u32>(2u), fullSize - vec2<u32>(1u));
703
+ let physicalMaxMip = textureNumLevels(hizPyramid) - 1u;
704
+ let hizMaxMip = min(maxSsrHiZMip(fullSize), physicalMaxMip + 1u);
705
+ let hizSize = textureDimensions(hizPyramid, physicalMaxMip);
706
+ let hizPixel = min(globalId.xy, hizSize - vec2<u32>(1u));
707
+ let depth = textureLoad(sceneDepth, vec2<i32>(fullPixel), 0);
708
+ let normalData = textureLoad(sceneNormal, vec2<i32>(fullPixel), 0);
709
+ let normalUnnormalized = normalData.xyz * 2.0 - vec3<f32>(1.0);
710
+ let normalLength = length(normalUnnormalized);
711
+ if (!isFinite(depth) || depth >= 1.0 || !isFinite(normalLength) || normalLength <= 1e-5 ||
712
+ textureLoad(reflectionFallback, vec2<i32>(fullPixel), 0).a <= 0.5 ||
713
+ !isFinite(view.ssrParams.w) || view.ssrParams.w <= 0.5 ||
714
+ !isFinite(normalData.a) || normalData.a >= view.ssrParams.z) {
715
+ textureStore(traceOutput, vec2<i32>(globalId.xy), vec4<f32>(0.0));
716
+ return;
717
+ }
718
+ let normal = select(
719
+ vec3<f32>(0.0, 0.0, 1.0),
720
+ normalUnnormalized / normalLength,
721
+ isFinite(normalLength) && normalLength > 1e-5,
722
+ );
723
+ let hizDepth = textureLoad(hizPyramid, vec2<i32>(hizPixel), i32(physicalMaxMip)).r;
724
+ let uv = (vec2<f32>(fullPixel) + vec2<f32>(0.5)) / vec2<f32>(fullSize);
725
+ // The record-side sentinel is deliberately binary: a finite zero means the
726
+ // authored effect is disabled, while any non-finite value must fail closed.
727
+ // Do not turn a disabled-but-valid View tail into a one-frame SSR request.
728
+ let enabled = select(0.0, 1.0, isFinite(view.ssrParams.w) && view.ssrParams.w > 0.5);
729
+ let maxDistance = enabled * view.ssrParams.x;
730
+ let thickness = enabled * view.ssrParams.y;
731
+ let roughnessLimit = enabled * clamp(view.ssrParams.z, 0.0, 1.0);
732
+ let worldPosition = reconstructWorldPosition(uv, depth);
733
+ let viewDirectionRaw = view.cameraPos - worldPosition;
734
+ let viewDirectionLength = length(viewDirectionRaw);
735
+ let viewDirection = select(
736
+ vec3<f32>(0.0, 0.0, 1.0),
737
+ viewDirectionRaw / viewDirectionLength,
738
+ isFinite(viewDirectionLength) && viewDirectionLength > 1e-5,
739
+ );
740
+ let facing = clamp(dot(normal, viewDirection) * 8.0, 0.0, 1.0);
741
+ let reflectionDirection = normalize(reflect(-viewDirection, normal));
742
+ // Keep the reflected line anchored to the shaded receiver. A normal offset
743
+ // moves the projected hit across fine source texels. The march starts beyond
744
+ // the origin and rejects back-facing sources, including this receiver plane.
745
+ let hitResult = traceScreenRay(
746
+ worldPosition,
747
+ reflectionDirection,
748
+ maxDistance,
749
+ thickness,
750
+ fullSize,
751
+ hizDepth,
752
+ hizMaxMip,
753
+ );
754
+ let edge = traceEdge(hitResult.uv);
755
+ textureStore(hitReactivityOutput, vec2<i32>(globalId.xy), vec4<f32>(hitResult.reactivity));
756
+ let roughness = finiteConfidence(
757
+ select(
758
+ 0.0,
759
+ 1.0 - smoothstep(roughnessLimit * 0.8, max(roughnessLimit, 1e-5), clamp(normalData.a, 0.0, 1.0)),
760
+ roughnessLimit > 0.0,
761
+ ),
762
+ );
763
+ let temporal = 1.0;
764
+ let confidence = traceConfidence(
765
+ hitResult.hit,
766
+ hitResult.thickness,
767
+ facing,
768
+ edge,
769
+ roughness,
770
+ temporal,
771
+ );
772
+ if (confidence <= 0.0) {
773
+ textureStore(traceOutput, vec2<i32>(globalId.xy), vec4<f32>(0.0));
774
+ return;
775
+ }
776
+ let hitSample = sampleSsrHitSample(hitResult.uv, reflectionDirection);
777
+ let hitColor = hitSample.color;
778
+ if (!all(vec3<bool>(isFinite(hitColor.r), isFinite(hitColor.g), isFinite(hitColor.b)))) {
779
+ textureStore(traceOutput, vec2<i32>(globalId.xy), vec4<f32>(0.0));
780
+ return;
781
+ }
782
+ textureStore(traceOutput, vec2<i32>(globalId.xy), vec4<f32>(hitColor.rgb, confidence * hitColor.a));
783
+ textureStore(hitReactivityOutput, vec2<i32>(globalId.xy), vec4<f32>(max(hitResult.reactivity, hitSample.reactivity)));
784
+ }