@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
@@ -1,43 +1,32 @@
1
1
  #pragma variant_axis STORAGE_BUFFER_AVAILABLE
2
2
  #pragma variant_axis SKINNING_DISABLED
3
+ #pragma variant_axis GPU_DRIVEN_SCENE_INDEX_AVAILABLE
4
+ #pragma variant_axis GPU_DRIVEN_SCENE_INDEX_EXPLICIT
5
+ #pragma variant_axis ALPHA_MASK
3
6
  #pragma material_slot surface
4
7
  #define_import_path forgeax::default-shadow-caster
5
- #import forgeax_material::slot::surface::{evaluate_surface}
8
+ #import forgeax_material::slot::surface::{evaluate_surface, evaluate_standard_surface}
6
9
  #import forgeax_material::surface_v1::{SurfaceInput, SurfaceData}
7
10
  #import forgeax_material::parameters::{material}
8
11
 
9
- // @forgeax/engine-shader shadow_caster.wgsl
10
- // feat-20260520-directional-light-shadow-mapping M1c / w9 (D-9 / AC-09):
11
- // depth pass for directional shadow map. The fragment stage evaluates the
12
- // selected Standard Surface so opacity/alpha-clip stays identical to Forward
13
- // and Deferred (depth32float is still the only render target).
14
- //
15
- // feat-20260613-csm-cascaded-shadow-maps M5 / w28: per-cascade
16
- // lightViewProj selection. Each cascade pass writes a different
17
- // `shadowCasterCascade.index` (0..3) before encoder submit; the vertex
18
- // shader reads it to pick `view.lightViewProj_A..D`. The atlas tile UV
19
- // inset is already baked into each lightViewProj host-side
20
- // (render-system-extract.ts), so the per-cascade viewport on the depth
21
- // pass clips rasterization to the correct atlas tile while the matrix
22
- // itself maps NDC straight into atlas-space [0,1]^2.
23
- //
24
- // Reuses:
25
- // @group(0) binding(0) view : View -- common.wgsl
26
- // @group(0) binding(5) shadowCasterCascade -- common.wgsl
27
- // @group(2) binding(0) meshes : array<Mesh> -- common.wgsl
28
- // @group(3) binding(0) instances : array<InstanceData> -- common.wgsl
29
- //
30
- // SKINNING_DISABLED=true consumes the ordinary 12F mesh layout; the explicit
31
- // false variant consumes JOINTS_0 / WEIGHTS_0 from the same 18F layout as pbr-skin and
32
- // projects the animated world-space palette result into the shadow view.
12
+ #import forgeax_view::common::{View, Mesh, InstanceData, ShadowCasterCascade, view, shadowCasterCascade, meshes, instances, sampleMaterialTexture}
33
13
 
34
- #import forgeax_view::common::{View, Mesh, InstanceData, ShadowCasterCascade, view, shadowCasterCascade, meshes, instances}
14
+ // The depth pass shares the Standard Surface contract with Forward/Deferred.
15
+ // Alpha-mask and GPU scene-index variants add only producer-owned bindings;
16
+ // ordinary casters keep the same four-slot pipeline layout.
35
17
 
36
18
  struct VsInput {
37
19
  @location(0) position : vec3<f32>,
38
20
  @location(1) normal : vec3<f32>,
39
21
  @location(2) uv : vec2<f32>,
40
22
  @location(3) tangent : vec4<f32>,
23
+ @location(6) uv1 : vec2<f32>,
24
+ @location(7) uv2 : vec2<f32>,
25
+ @location(8) uv3 : vec2<f32>,
26
+ @location(9) uv4 : vec2<f32>,
27
+ @location(10) uv5 : vec2<f32>,
28
+ @location(11) uv6 : vec2<f32>,
29
+ @location(12) uv7 : vec2<f32>,
41
30
  #if SKINNING_DISABLED == false
42
31
  @location(4) skinIndex : vec4<u32>,
43
32
  @location(5) skinWeight : vec4<f32>,
@@ -51,9 +40,22 @@ struct VsOut {
51
40
  @location(2) normalWS : vec3<f32>,
52
41
  @location(3) tangentWS : vec4<f32>,
53
42
  @location(4) surfaceUv : vec2<f32>,
54
- @location(5) vertexColor : vec4<f32>,
43
+ @location(5) uv1 : vec2<f32>,
44
+ @location(6) uv2 : vec2<f32>,
45
+ @location(7) uv3 : vec2<f32>,
46
+ @location(8) uv4 : vec2<f32>,
47
+ @location(9) uv5 : vec2<f32>,
48
+ @location(10) uv6And7 : vec4<f32>,
49
+ #if GPU_DRIVEN_SCENE_INDEX_AVAILABLE == true
50
+ @location(11) @interpolate(flat) materialIndex : u32,
51
+ #endif
55
52
  };
56
53
 
54
+ #if GPU_DRIVEN_SCENE_INDEX_AVAILABLE == true
55
+ @group(1) @binding(46) var<storage, read> sceneMaterials : array<MaterialParameters>;
56
+ @group(3) @binding(1) var<storage, read> visibleItems : array<vec2<u32>>;
57
+ #endif
58
+
57
59
  #if SKINNING_DISABLED == false
58
60
  #if STORAGE_BUFFER_AVAILABLE == true
59
61
  @group(2) @binding(1) var<storage, read> palette : array<mat4x4<f32>>;
@@ -71,67 +73,102 @@ fn _cascadeLightViewProj(layer : u32) -> mat4x4<f32> {
71
73
  }
72
74
  }
73
75
 
74
- @vertex
75
- fn vs_main(in : VsInput, @builtin(instance_index) idx : u32) -> VsOut {
76
+ fn shadowVertex(in : VsInput, idx : u32) -> VsOut {
77
+ var meshIndex = 0u;
78
+ var instanceIndex = idx;
79
+ var materialIndex = 0u;
80
+ var paletteBase = 0u;
81
+ #if GPU_DRIVEN_SCENE_INDEX_AVAILABLE == true
82
+ #if GPU_DRIVEN_SCENE_INDEX_EXPLICIT == false
83
+ let visible = visibleItems[idx];
84
+ meshIndex = visible.x;
85
+ instanceIndex = 0u;
86
+ paletteBase = visible.y;
87
+ materialIndex = visible.y;
88
+ #endif
89
+ #endif
76
90
  #if SKINNING_DISABLED == false
77
- let skinMatrix = palette[in.skinIndex.x] * in.skinWeight.x +
78
- palette[in.skinIndex.y] * in.skinWeight.y +
79
- palette[in.skinIndex.z] * in.skinWeight.z +
80
- palette[in.skinIndex.w] * in.skinWeight.w;
81
- // Palette entries are jointWorld * inverseBind and therefore already
82
- // produce world-space positions. Applying meshes[0].worldFromLocal again
83
- // would double-transform a parented skinned entity.
91
+ #if STORAGE_BUFFER_AVAILABLE == true
92
+ // GPU skin rows carry the material row in the Mesh temporal metadata lane.
93
+ materialIndex = bitcast<u32>(meshes[meshIndex].temporal.w);
94
+ #endif
95
+ let skinMatrix = palette[paletteBase + in.skinIndex.x] * in.skinWeight.x +
96
+ palette[paletteBase + in.skinIndex.y] * in.skinWeight.y +
97
+ palette[paletteBase + in.skinIndex.z] * in.skinWeight.z +
98
+ palette[paletteBase + in.skinIndex.w] * in.skinWeight.w;
84
99
  let worldPos = skinMatrix * vec4<f32>(in.position, 1.0);
85
100
  let worldNormal = normalize((skinMatrix * vec4<f32>(in.normal, 0.0)).xyz);
86
101
  let worldTangent = normalize((skinMatrix * vec4<f32>(in.tangent.xyz, 0.0)).xyz);
87
102
  #else
88
- let instanceLocal = instances[idx].localFromInstance;
89
- let worldMatrix = meshes[0].worldFromLocal * instanceLocal;
103
+ let worldMatrix = meshes[meshIndex].worldFromLocal * instances[instanceIndex].localFromInstance;
90
104
  let worldPos = worldMatrix * vec4<f32>(in.position, 1.0);
91
105
  let worldNormal = normalize((worldMatrix * vec4<f32>(in.normal, 0.0)).xyz);
92
106
  let worldTangent = normalize((worldMatrix * vec4<f32>(in.tangent.xyz, 0.0)).xyz);
93
107
  #endif
94
- // feat-20260625-spot-light-shadow-mapping M2 / w10 (D-1): spot shadow passes
95
- // set `isSpot = 1u` and write their perspective matrix into
96
- // `spotLightViewProj`; directional cascade passes keep `isSpot = 0u` and read
97
- // `view.lightViewProj_A..D` via `index`. Routing on the discriminant keeps
98
- // the spot matrix out of the directional View UBO (no same-frame contention).
108
+
109
+ var out : VsOut;
99
110
  if (shadowCasterCascade.isSpot == 1u) {
100
- var out : VsOut;
101
111
  out.clip = shadowCasterCascade.spotLightViewProj * worldPos;
102
- out.positionOS = in.position;
103
- out.positionWS = worldPos.xyz;
104
- out.normalWS = worldNormal;
105
- out.tangentWS = vec4<f32>(worldTangent, in.tangent.w);
106
- out.surfaceUv = in.uv;
107
- out.vertexColor = vec4<f32>(1.0);
108
- return out;
112
+ } else {
113
+ out.clip = _cascadeLightViewProj(shadowCasterCascade.index) * worldPos;
109
114
  }
110
- let lvp = _cascadeLightViewProj(shadowCasterCascade.index);
111
- var out : VsOut;
112
- out.clip = lvp * worldPos;
113
115
  out.positionOS = in.position;
114
116
  out.positionWS = worldPos.xyz;
115
117
  out.normalWS = worldNormal;
116
118
  out.tangentWS = vec4<f32>(worldTangent, in.tangent.w);
117
119
  out.surfaceUv = in.uv;
118
- out.vertexColor = vec4<f32>(1.0);
120
+ out.uv1 = in.uv1;
121
+ out.uv2 = in.uv2;
122
+ out.uv3 = in.uv3;
123
+ out.uv4 = in.uv4;
124
+ out.uv5 = in.uv5;
125
+ out.uv6And7 = vec4<f32>(in.uv6, in.uv7);
126
+ #if GPU_DRIVEN_SCENE_INDEX_AVAILABLE == true
127
+ out.materialIndex = materialIndex;
128
+ #endif
119
129
  return out;
120
130
  }
121
131
 
132
+ @vertex
133
+ fn vs_main(in : VsInput, @builtin(instance_index) idx : u32) -> VsOut {
134
+ return shadowVertex(in, idx);
135
+ }
136
+
137
+ // Scene-index shadow batches use a receipt-owned entry point so the indirect
138
+ // lane cannot silently reinterpret the visible stream as instance indices.
139
+ @vertex
140
+ fn vs_scene_index(in : VsInput, @builtin(instance_index) idx : u32) -> VsOut {
141
+ return shadowVertex(in, idx);
142
+ }
143
+
122
144
  fn evaluateShadowSurface(in : VsOut, frontFacing : bool) -> SurfaceData {
123
145
  let viewDirectionWS = normalize(view.cameraPos - in.positionWS);
124
- return evaluate_surface(SurfaceInput(
146
+ let input = SurfaceInput(
125
147
  in.positionOS,
126
148
  in.positionWS,
127
149
  in.normalWS,
128
150
  in.tangentWS,
129
151
  viewDirectionWS,
130
152
  in.surfaceUv,
131
- in.surfaceUv,
132
- in.vertexColor,
153
+ in.uv1,
154
+ in.uv2,
155
+ in.uv3,
156
+ in.uv4,
157
+ in.uv5,
158
+ in.uv6And7.xy,
159
+ in.uv6And7.zw,
160
+ vec4<f32>(1.0),
133
161
  frontFacing,
134
- ));
162
+ );
163
+ #if GPU_DRIVEN_SCENE_INDEX_AVAILABLE == true
164
+ #if ALPHA_MASK == true
165
+ return evaluate_standard_surface(input, sceneMaterials[in.materialIndex]);
166
+ #else
167
+ return evaluate_surface(input);
168
+ #endif
169
+ #else
170
+ return evaluate_surface(input);
171
+ #endif
135
172
  }
136
173
 
137
174
  fn alphaTestShadowSurface(surface : SurfaceData) {
@@ -140,13 +177,14 @@ fn alphaTestShadowSurface(surface : SurfaceData) {
140
177
  }
141
178
  }
142
179
 
143
- // The depth-only path evaluates the selected Surface so alpha-clip ownership
144
- // remains shared with Forward and Deferred without mutating vertex data.
180
+ // The depth-only pass has no color target; alpha clipping still runs through
181
+ // the selected Standard Surface before depth is committed.
145
182
  @fragment
146
183
  fn fs_shadow(in : VsOut, @builtin(front_facing) frontFacing : bool) {
147
184
  alphaTestShadowSurface(evaluateShadowSurface(in, frontFacing));
148
185
  }
149
186
 
187
+ // Keep fs_main available for material consumers that explicitly request it.
150
188
  @fragment
151
189
  fn fs_main(in : VsOut, @builtin(front_facing) frontFacing : bool) {
152
190
  alphaTestShadowSurface(evaluateShadowSurface(in, frontFacing));
@@ -90,7 +90,7 @@ struct VsIn {
90
90
  // a valid source for NDC cluster lookup; keep the perspective divide here and
91
91
  // interpolate the result alongside worldPos.
92
92
  struct VsOut {
93
- @builtin(position) clip : vec4<f32>,
93
+ @builtin(position) @invariant clip : vec4<f32>,
94
94
  @location(0) uv_atlas : vec2<f32>,
95
95
  @location(1) worldPos : vec3<f32>,
96
96
  @location(2) ndc : vec3<f32>,
@@ -199,7 +199,7 @@ fn spriteLitShadeAccum(
199
199
  let viewDir = normalize(view.cameraPos - worldPos);
200
200
  lit = lit + evaluateStandardClusterLights(
201
201
  ndc, viewZ, worldPos, vec3<f32>(0.0, 0.0, 1.0),
202
- viewDir, albedo, 0.0, 1.0, vec3<f32>(0.04), true,
202
+ viewDir, albedo, 0.0, 1.0, vec3<f32>(0.04), true, true,
203
203
  );
204
204
  #endif
205
205
  return lit;
@@ -236,7 +236,7 @@ fn fs_main_hdr(in : VsOut) -> @location(0) vec4<f32> {
236
236
  }
237
237
 
238
238
  struct TemporalVsOut {
239
- @builtin(position) clip : vec4<f32>,
239
+ @builtin(position) @invariant clip : vec4<f32>,
240
240
  @location(0) uvAtlas : vec2<f32>,
241
241
  @location(1) @interpolate(perspective) currentClip : vec4<f32>,
242
242
  @location(2) @interpolate(perspective) previousClip : vec4<f32>,
@@ -254,7 +254,7 @@ fn vs_temporal(in : VsIn, @builtin(instance_index) idx : u32, @builtin(vertex_in
254
254
  #endif
255
255
  var out : TemporalVsOut;
256
256
  out.currentClip = view.temporalCurrentViewProj * currentWorld;
257
- out.clip = out.currentClip;
257
+ out.clip = view.worldViewProj * currentWorld;
258
258
  out.previousClip = view.temporalPreviousViewProj * previousWorld;
259
259
  out.uvAtlas = vertex.uvAtlas;
260
260
  return out;
package/src/sprite.wgsl CHANGED
@@ -130,7 +130,7 @@ struct VsIn {
130
130
  };
131
131
 
132
132
  struct VsOut {
133
- @builtin(position) clip : vec4<f32>,
133
+ @builtin(position) @invariant clip : vec4<f32>,
134
134
  // Atlas-space UV: already folded through region origin + size by the
135
135
  // vertex stage so the fragment can sample the bound material texture at
136
136
  // `in.uv_atlas` without any host-side region
@@ -310,7 +310,7 @@ fn fs_main_hdr(in : VsOut) -> @location(0) vec4<f32> {
310
310
  }
311
311
 
312
312
  struct TemporalVsOut {
313
- @builtin(position) clip : vec4<f32>,
313
+ @builtin(position) @invariant clip : vec4<f32>,
314
314
  @location(0) uvAtlas : vec2<f32>,
315
315
  @location(1) @interpolate(perspective) currentClip : vec4<f32>,
316
316
  @location(2) @interpolate(perspective) previousClip : vec4<f32>,
@@ -328,7 +328,7 @@ fn vs_temporal(in : VsIn, @builtin(instance_index) idx : u32, @builtin(vertex_in
328
328
  #endif
329
329
  var out : TemporalVsOut;
330
330
  out.currentClip = view.temporalCurrentViewProj * currentWorld;
331
- out.clip = out.currentClip;
331
+ out.clip = view.worldViewProj * currentWorld;
332
332
  out.previousClip = view.temporalPreviousViewProj * previousWorld;
333
333
  out.uvAtlas = vertex.uvAtlas;
334
334
  return out;
@@ -0,0 +1,129 @@
1
+ #define_import_path forgeax_ssr::compose
2
+
3
+ struct ComposeVertex {
4
+ @builtin(position) position : vec4<f32>,
5
+ };
6
+
7
+ @group(0) @binding(0) var radiance : texture_2d<f32>;
8
+ @group(0) @binding(1) var fallback : texture_2d<f32>;
9
+ @group(0) @binding(2) var response : texture_2d<f32>;
10
+ @group(0) @binding(3) var normalRoughness : texture_2d<f32>;
11
+ @group(0) @binding(4) var radianceSampler : sampler;
12
+ #import forgeax_view::common::View
13
+ @group(0) @binding(5) var<uniform> view : View;
14
+
15
+ struct SsrReceiverSample {
16
+ radiance : vec4<f32>,
17
+ coverage : f32,
18
+ };
19
+
20
+ @vertex
21
+ fn vs_ssr_compose(@builtin(vertex_index) index : u32) -> ComposeVertex {
22
+ let x = select(-1.0, 3.0, index == 1u);
23
+ let y = select(-1.0, 3.0, index == 2u);
24
+ return ComposeVertex(vec4<f32>(x, y, 0.0, 1.0));
25
+ }
26
+
27
+ // Reconstruct the half-resolution lattice without borrowing another face's
28
+ // ray. Renormalize compatible taps instead of rejecting the entire pixel
29
+ // solely because its nearest trace sample crossed a subpixel tile edge.
30
+ fn ssrReceiverSample(uv : vec2<f32>, normal : vec3<f32>) -> SsrReceiverSample {
31
+ let size = vec2<i32>(textureDimensions(radiance, 0));
32
+ let coordinate = uv * vec2<f32>(size) - vec2<f32>(0.5);
33
+ let first = vec2<i32>(floor(coordinate));
34
+ let fraction = fract(coordinate);
35
+ var sum = vec4<f32>(0.0);
36
+ var totalWeight = 0.0;
37
+ let pixel00 = clamp(first + vec2<i32>(0, 0), vec2<i32>(0), size - vec2<i32>(1));
38
+ let tracedNormal00 = textureLoad(normalRoughness, pixel00 * 2, 0).xyz * 2.0 - vec3<f32>(1.0);
39
+ let weight00 = select(1.0 - fraction.x, fraction.x, false) * select(1.0 - fraction.y, fraction.y, false)
40
+ * ssrReceiverWeight(normal, tracedNormal00);
41
+ // The presentation pyramid is premultiplied by confidence. Preserve
42
+ // that representation during receiver reconstruction and unpremultiply
43
+ // only once after the roughness filter has selected its LOD.
44
+ let sample00 = textureLoad(radiance, pixel00, 0);
45
+ sum += sample00 * weight00;
46
+ totalWeight += weight00;
47
+
48
+ let pixel10 = clamp(first + vec2<i32>(1, 0), vec2<i32>(0), size - vec2<i32>(1));
49
+ let tracedNormal10 = textureLoad(normalRoughness, pixel10 * 2, 0).xyz * 2.0 - vec3<f32>(1.0);
50
+ let weight10 = select(1.0 - fraction.x, fraction.x, true) * select(1.0 - fraction.y, fraction.y, false)
51
+ * ssrReceiverWeight(normal, tracedNormal10);
52
+ let sample10 = textureLoad(radiance, pixel10, 0);
53
+ sum += sample10 * weight10;
54
+ totalWeight += weight10;
55
+
56
+ let pixel01 = clamp(first + vec2<i32>(0, 1), vec2<i32>(0), size - vec2<i32>(1));
57
+ let tracedNormal01 = textureLoad(normalRoughness, pixel01 * 2, 0).xyz * 2.0 - vec3<f32>(1.0);
58
+ let weight01 = select(1.0 - fraction.x, fraction.x, false) * select(1.0 - fraction.y, fraction.y, true)
59
+ * ssrReceiverWeight(normal, tracedNormal01);
60
+ let sample01 = textureLoad(radiance, pixel01, 0);
61
+ sum += sample01 * weight01;
62
+ totalWeight += weight01;
63
+
64
+ let pixel11 = clamp(first + vec2<i32>(1, 1), vec2<i32>(0), size - vec2<i32>(1));
65
+ let tracedNormal11 = textureLoad(normalRoughness, pixel11 * 2, 0).xyz * 2.0 - vec3<f32>(1.0);
66
+ let weight11 = select(1.0 - fraction.x, fraction.x, true) * select(1.0 - fraction.y, fraction.y, true)
67
+ * ssrReceiverWeight(normal, tracedNormal11);
68
+ let sample11 = textureLoad(radiance, pixel11, 0);
69
+ sum += sample11 * weight11;
70
+ totalWeight += weight11;
71
+ return SsrReceiverSample(sum / max(totalWeight, 1e-6), totalWeight);
72
+ }
73
+
74
+ fn ssrReflectionLod(roughness : f32) -> f32 {
75
+ return roughness * roughness * f32(textureNumLevels(radiance) - 1u);
76
+ }
77
+
78
+ fn sampleRoughSsr(uv : vec2<f32>, roughness : f32) -> vec4<f32> {
79
+ let lod = ssrReflectionLod(roughness);
80
+ // The presentation pyramid is premultiplied by confidence, so hardware
81
+ // bilinear/trilinear filtering preserves coverage without the four manual
82
+ // loads previously needed for every low-LOD sample.
83
+ return textureSampleLevel(radiance, radianceSampler, uv, lod);
84
+ }
85
+
86
+ fn ssrReceiverWeight(current : vec3<f32>, traced : vec3<f32>) -> f32 {
87
+ let agreement = dot(current, traced) * inverseSqrt(max(dot(current, current) * dot(traced, traced), 1e-8));
88
+ return smoothstep(0.9, 0.99, agreement);
89
+ }
90
+
91
+ @fragment
92
+ fn fs_ssr_compose(in : ComposeVertex) -> @location(0) vec4<f32> {
93
+ let pixel = vec2<i32>(in.position.xy);
94
+ // Trace samples full-resolution pixel 2*p, not the center of a 2x2 box.
95
+ let uv = (in.position.xy + vec2<f32>(0.5)) / vec2<f32>(textureDimensions(fallback, 0));
96
+ let receiver = textureLoad(normalRoughness, pixel, 0);
97
+ let environment = textureLoad(fallback, pixel, 0);
98
+ let roughnessLimit = clamp(view.ssrParams.z, 0.0, 1.0);
99
+ if (!(view.ssrParams.w > 0.5 && view.ssrParams.w < 3.402823e+38) ||
100
+ !(receiver.a >= 0.0 && receiver.a < roughnessLimit) || environment.a <= 0.0) {
101
+ return vec4<f32>(0.0);
102
+ }
103
+ let reconstructed = ssrReceiverSample(uv, receiver.xyz * 2.0 - vec3<f32>(1.0));
104
+ if (reconstructed.coverage <= 1e-6) { return vec4<f32>(0.0); }
105
+ let base = reconstructed.radiance;
106
+ let roughness = clamp(receiver.a, 0.04, 1.0);
107
+ let lod = ssrReflectionLod(roughness);
108
+ var filtered = base;
109
+ if (lod < 1.0) {
110
+ // Preserve the normal-compatible receiver reconstruction at the sharp
111
+ // end while using one hardware-filtered read for the first roughness mip.
112
+ // This keeps the transition continuous at lod == 1 and avoids the four
113
+ // explicit footprint loads used by the old mip helper.
114
+ if (textureNumLevels(radiance) > 1u) {
115
+ filtered = mix(base, textureSampleLevel(radiance, radianceSampler, uv, 1.0), lod);
116
+ }
117
+ } else {
118
+ filtered = sampleRoughSsr(uv, roughness);
119
+ }
120
+ let sample = vec4<f32>(filtered.rgb / max(filtered.a, 1e-6), filtered.a);
121
+ let materialResponse = textureLoad(response, pixel, 0).rgb;
122
+ // Receiver admission is independent of the central mirror ray. A rough
123
+ // lobe can contain a visible wall even when that one ray misses its edge.
124
+ // Preserve the current material's cutoff instead of borrowing a neighbor's
125
+ // roughness policy from the filtered reflection pyramid.
126
+ let receiverConfidence = 1.0 - smoothstep(roughnessLimit * 0.8, roughnessLimit, receiver.a);
127
+ let confidence = clamp(min(receiverConfidence, sample.a), 0.0, 1.0) * environment.a;
128
+ return vec4<f32>(confidence * (sample.rgb * materialResponse - environment.rgb), 0.0);
129
+ }
@@ -0,0 +1,53 @@
1
+ #define_import_path forgeax_ssr::hiz_reduce
2
+
3
+ // Hi-Z reduction consumes the previous r32float mip. It is deliberately a
4
+ // separate module from the depth seed: WebGPU depth sample types cannot bind
5
+ // an r32float view, while the seed must retain the depth-only binding.
6
+ const SSR_HIZ_EMPTY_DEPTH : f32 = 3.402823e+38;
7
+
8
+ @group(0) @binding(0) var sourceHiZ : texture_2d<f32>;
9
+ @group(0) @binding(1) var hizOutput : texture_storage_2d<r32float, write>;
10
+
11
+ fn isFinite(value : f32) -> bool {
12
+ return value == value && abs(value) < 3.402823e+38;
13
+ }
14
+
15
+ fn normalizeSsrHiZDepth(value : f32) -> f32 {
16
+ return select(SSR_HIZ_EMPTY_DEPTH, value, isFinite(value) && value > 0.0);
17
+ }
18
+
19
+ // Physical WebGPU mip dimensions are floor-halved. Partition the source by
20
+ // integer normalized boundaries and ceil the end boundary so adjacent
21
+ // footprints overlap at odd boundaries; a 3-wide source reduced to one texel
22
+ // must inspect all three source texels rather than a clamped 2x2 prefix.
23
+ fn reduceSsrHiZFootprint(
24
+ destinationCoordinate : vec2<u32>,
25
+ sourceSize : vec2<u32>,
26
+ destinationSize : vec2<u32>,
27
+ ) -> f32 {
28
+ let sourceStart = destinationCoordinate * sourceSize / destinationSize;
29
+ let sourceEnd = min(
30
+ ((destinationCoordinate + vec2<u32>(1u)) * sourceSize + destinationSize -
31
+ vec2<u32>(1u)) / destinationSize,
32
+ sourceSize,
33
+ );
34
+ var minimum = SSR_HIZ_EMPTY_DEPTH;
35
+ for (var y = sourceStart.y; y < sourceEnd.y; y += 1u) {
36
+ for (var x = sourceStart.x; x < sourceEnd.x; x += 1u) {
37
+ let depth = normalizeSsrHiZDepth(textureLoad(sourceHiZ, vec2<i32>(vec2<u32>(x, y)), 0).r);
38
+ minimum = min(minimum, depth);
39
+ }
40
+ }
41
+ return minimum;
42
+ }
43
+
44
+ @compute @workgroup_size(8, 8, 1)
45
+ fn ssr_hiz_reduce(@builtin(global_invocation_id) globalId : vec3<u32>) {
46
+ let sourceSize = textureDimensions(sourceHiZ, 0);
47
+ let destinationSize = textureDimensions(hizOutput);
48
+ if (globalId.x >= destinationSize.x || globalId.y >= destinationSize.y) {
49
+ return;
50
+ }
51
+ let reduced = reduceSsrHiZFootprint(globalId.xy, sourceSize, destinationSize);
52
+ textureStore(hizOutput, vec2<i32>(globalId.xy), vec4<f32>(reduced, 0.0, 0.0, 1.0));
53
+ }
@@ -0,0 +1,66 @@
1
+ #define_import_path forgeax_ssr::hiz
2
+
3
+ // The SSR Hi-Z producer uses one r32float view for each graph mip.
4
+ const SSR_HIZ_FORMAT : u32 = 1u;
5
+ const SSR_HIZ_EMPTY_DEPTH : f32 = 3.402823e+38;
6
+
7
+ @group(0) @binding(0) var sourceDepth : texture_depth_2d;
8
+ @group(0) @binding(1) var hizOutput : texture_storage_2d<r32float, write>;
9
+ #import forgeax_view::common::View
10
+ @group(0) @binding(2) var<uniform> view : View;
11
+
12
+ fn isFinite(value : f32) -> bool {
13
+ return value == value && abs(value) < 3.402823e+38;
14
+ }
15
+
16
+ // WebGPU depth attachments expose perspective depth, not a linear distance.
17
+ // Hi-Z stores the positive view distance consumed by the bounded SSR ray
18
+ // marcher. The shared View UBO carries the active projection range, so this
19
+ // conversion does not create a second camera or projection owner.
20
+ fn linearizeSsrDepth(value : f32) -> f32 {
21
+ if (!isFinite(value) || value <= 0.0 || value >= 1.0) {
22
+ return SSR_HIZ_EMPTY_DEPTH;
23
+ }
24
+ let near = view.temporalProjection.x;
25
+ let far = view.temporalProjection.y;
26
+ if (!isFinite(near) || !isFinite(far) || near <= 0.0 || far <= near) {
27
+ return SSR_HIZ_EMPTY_DEPTH;
28
+ }
29
+ let orthographic = view.temporalProjection.z > 0.5;
30
+ let distance = select(
31
+ (near * far) / max(far - value * (far - near), 1e-5),
32
+ near + value * (far - near),
33
+ orthographic,
34
+ );
35
+ return select(SSR_HIZ_EMPTY_DEPTH, distance, isFinite(distance) && distance > 0.0);
36
+ }
37
+
38
+ @compute @workgroup_size(8, 8, 1)
39
+ fn ssr_hiz_seed(@builtin(global_invocation_id) globalId : vec3<u32>) {
40
+ let sourceSize = textureDimensions(sourceDepth, 0);
41
+ let outputSize = textureDimensions(hizOutput);
42
+ if (any(globalId.xy >= outputSize)) {
43
+ return;
44
+ }
45
+ // Match the reduction coverage contract: floor the start and ceil the end
46
+ // so an odd source boundary is conservatively present in both neighbors.
47
+ let first = globalId.xy * sourceSize / outputSize;
48
+ let end = min(
49
+ ((globalId.xy + vec2<u32>(1u)) * sourceSize + outputSize - vec2<u32>(1u)) /
50
+ outputSize,
51
+ sourceSize,
52
+ );
53
+ var nearestDepth = 1.0;
54
+ for (var y = first.y; y < end.y; y++) {
55
+ for (var x = first.x; x < end.x; x++) {
56
+ let sample = textureLoad(sourceDepth, vec2<i32>(vec2<u32>(x, y)), 0);
57
+ if (isFinite(sample) && sample > 0.0 && sample < 1.0) {
58
+ nearestDepth = min(nearestDepth, sample);
59
+ }
60
+ }
61
+ }
62
+ // Both supported depth projections are monotonic, so linearize once after
63
+ // selecting the nearest valid source sample.
64
+ let depth = linearizeSsrDepth(nearestDepth);
65
+ textureStore(hizOutput, vec2<i32>(globalId.xy), vec4<f32>(depth, 0.0, 0.0, 1.0));
66
+ }