@forgeax/engine-shader 0.1.20 → 0.1.21

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 (38) hide show
  1. package/README.md +10 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/ShaderRegistry.d.ts.map +1 -1
  4. package/dist/index.d.ts +2 -0
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.mjs +52 -1
  7. package/dist/index.mjs.map +1 -1
  8. package/package.json +4 -4
  9. package/src/ShaderRegistry.ts +65 -0
  10. package/src/__tests__/bloom-fxaa-tonemap.unit.test.ts +30 -4
  11. package/src/__tests__/default-standard-pbr-alpha.unit.test.ts +7 -0
  12. package/src/__tests__/default-standard-pbr-transmission.unit.test.ts +69 -24
  13. package/src/__tests__/lighting-point-spot-volume.unit.test.ts +8 -0
  14. package/src/__tests__/lighting-punctual.unit.test.ts +32 -6
  15. package/src/__tests__/material-derived-builtins.integration.test.ts +27 -0
  16. package/src/__tests__/reflection-probe-sampling.unit.test.ts +23 -0
  17. package/src/__tests__/scene-temporal.unit.test.ts +20 -0
  18. package/src/__tests__/shader.unit.test.ts +34 -13
  19. package/src/__tests__/shadow-pcss.unit.test.ts +131 -0
  20. package/src/__tests__/sprite-lit-shader.test.ts +24 -11
  21. package/src/__tests__/sprite-variants.unit.test.ts +1 -0
  22. package/src/__tests__/transmission-thickness-scale.unit.test.ts +7 -1
  23. package/src/__tests__/transparent-pbr.unit.test.ts +7 -0
  24. package/src/common.wgsl +42 -12
  25. package/src/default-standard-pbr-skin.wgsl +30 -71
  26. package/src/default-standard-pbr.wgsl +92 -163
  27. package/src/fxaa.wgsl +27 -6
  28. package/src/ibl-sampling.wgsl +54 -0
  29. package/src/index.ts +6 -0
  30. package/src/lighting-directional.wgsl +157 -18
  31. package/src/lighting-punctual.wgsl +51 -21
  32. package/src/scene-temporal.wgsl +14 -8
  33. package/src/shadow-pcf.wgsl +44 -1
  34. package/src/sprite-lit.wgsl +36 -52
  35. package/src/{hdrp-cluster-forward.wgsl → standard-cluster.wgsl} +73 -60
  36. package/src/tonemap.wgsl +8 -3
  37. package/src/volume/volume-inject.wgsl +14 -2
  38. package/src/volume/volume-integrate.wgsl +1 -6
package/src/fxaa.wgsl CHANGED
@@ -7,8 +7,8 @@
7
7
  // FXAA 3.11 fragment shader -- faithful port of the canonical Simon
8
8
  // Rodriguez reference (same lineage as Bevy's WGSL port), adapted to
9
9
  // forgeax naga_oil conventions. Hardcoded "High" quality preset: 12-step
10
- // edge search with compile-time step multipliers (no UBO, no #ifdef quality
11
- // branches).
10
+ // edge search with compile-time step multipliers (the only UBO field is the
11
+ // final-output dither switch; there are no quality #ifdef branches).
12
12
  //
13
13
  // Vertex stage: imports the SSOT fullscreen_triangle() from
14
14
  // forgeax_view::common (single large triangle, research Finding 1).
@@ -35,10 +35,13 @@
35
35
  // Bindings (group 0):
36
36
  // @binding(0) screenTexture : texture_2d<f32> -- sampled display-encoded input
37
37
  // @binding(1) samp : sampler -- linear filterable sampler
38
+ // @binding(2) params : FxaaParams -- final-output policy
38
39
  // display-encoded: FXAA samples and writes display-encoded values; the Output
39
- // Transform owns the sole linear-to-sRGB conversion before this pass.
40
+ // Transform owns the sole linear-to-sRGB conversion before this pass. FXAA is
41
+ // the final Standard surface writer, so the shared bounded dither is applied
42
+ // here, immediately before the surface's UNORM8 conversion.
40
43
 
41
- #import forgeax_view::common::FullscreenOutput
44
+ #import forgeax_view::common::{FullscreenOutput, ditherUnorm8}
42
45
  #import forgeax_view::common::fullscreen_triangle
43
46
 
44
47
  const EDGE_THRESHOLD_MIN: f32 = 0.0312;
@@ -49,6 +52,18 @@ const ITERATIONS: i32 = 12;
49
52
  @group(0) @binding(0) var screenTexture: texture_2d<f32>;
50
53
  @group(0) @binding(1) var samp: sampler;
51
54
 
55
+ struct FxaaParams {
56
+ ditherEnabled: f32,
57
+ // Keep the uniform block at one 16-byte slot. WebGL2/GLES uniform-buffer
58
+ // validation uses the std140-aligned block size, while the host uploads a
59
+ // four-f32 (16-byte) payload.
60
+ _pad0: f32,
61
+ _pad1: f32,
62
+ _pad2: f32,
63
+ };
64
+
65
+ @group(0) @binding(2) var<uniform> params: FxaaParams;
66
+
52
67
  fn rgb2luma(rgb: vec3<f32>) -> f32 {
53
68
  return sqrt(dot(rgb, vec3<f32>(0.299, 0.587, 0.114)));
54
69
  }
@@ -105,7 +120,10 @@ fn fs_main(in: FullscreenOutput) -> @location(0) vec4<f32> {
105
120
 
106
121
  // Not on an edge (or in a near-uniform region): pass the centre through.
107
122
  if lumaRange < max(EDGE_THRESHOLD_MIN, lumaMax * EDGE_THRESHOLD_MAX) {
108
- return vec4<f32>(centerColor, 1.0);
123
+ return vec4<f32>(
124
+ select(centerColor, ditherUnorm8(centerColor, in.position.xy), params.ditherEnabled > 0.5),
125
+ 1.0,
126
+ );
109
127
  }
110
128
 
111
129
  // Lumas of the 4 corners.
@@ -254,5 +272,8 @@ fn fs_main(in: FullscreenOutput) -> @location(0) vec4<f32> {
254
272
  }
255
273
 
256
274
  let finalColor = sampleColor(finalUv);
257
- return vec4<f32>(finalColor, 1.0);
275
+ return vec4<f32>(
276
+ select(finalColor, ditherUnorm8(finalColor, in.position.xy), params.ditherEnabled > 0.5),
277
+ 1.0,
278
+ );
258
279
  }
@@ -16,6 +16,8 @@
16
16
  // - sampleIblDiffuse(N, irradianceMap, irradianceSampler)
17
17
  // - sampleIblSpecular(N, V, roughness, F0, prefilterMap, prefilterSampler,
18
18
  // brdfLut, brdfLutSampler)
19
+ // - box_project(worldPosition, direction, boxCenter, boxExtents)
20
+ // - sampleReflectionProbeSpecular(..., probeMap, probeSampler, ...)
19
21
 
20
22
  #import forgeax_pbr::ibl_shared::{PI, fresnelSchlickRoughness, inverseRotateEnvironment}
21
23
 
@@ -56,3 +58,55 @@ fn sampleIblSpecular(
56
58
  let F = fresnelSchlickRoughness(NdotV, F0, roughness);
57
59
  return prefilteredColor * (F * envBRDF.r + envBRDF.g);
58
60
  }
61
+
62
+ // Project a reflection ray from a point inside a probe box onto the box
63
+ // boundary. The helper is binding-free so the scene table and the material
64
+ // resource projection remain owned by the host record stage.
65
+ fn box_project(
66
+ worldPosition: vec3<f32>,
67
+ direction: vec3<f32>,
68
+ boxCenter: vec3<f32>,
69
+ boxExtents: vec3<f32>,
70
+ ) -> vec3<f32> {
71
+ let safeExtents = max(boxExtents, vec3<f32>(0.0001));
72
+ let safeDirection = select(
73
+ vec3<f32>(0.0001),
74
+ direction,
75
+ abs(direction) >= vec3<f32>(0.0001),
76
+ );
77
+ let localPosition = worldPosition - boxCenter;
78
+ let edgeSign = select(vec3<f32>(-1.0), vec3<f32>(1.0), direction >= vec3<f32>(0.0));
79
+ let edge = edgeSign * safeExtents;
80
+ let distances = (edge - localPosition) / safeDirection;
81
+ let travel = min(distances.x, min(distances.y, distances.z));
82
+ return normalize(localPosition + direction * max(travel, 0.0));
83
+ }
84
+
85
+ // Split-sum probe sample. The probe texture is supplied by the caller so a
86
+ // missing scene candidate can keep using sampleIblSpecular with the Skylight
87
+ // resources already present in the Standard material layout.
88
+ fn sampleReflectionProbeSpecular(
89
+ normal: vec3<f32>,
90
+ view: vec3<f32>,
91
+ roughness: f32,
92
+ F0: vec3<f32>,
93
+ worldPosition: vec3<f32>,
94
+ boxCenter: vec3<f32>,
95
+ boxExtents: vec3<f32>,
96
+ rotation: vec4<f32>,
97
+ probeMap: texture_cube<f32>,
98
+ probeSampler: sampler,
99
+ brdfLut: texture_2d<f32>,
100
+ brdfLutSampler: sampler,
101
+ ) -> vec3<f32> {
102
+ let NdotV = max(dot(normal, view), 0.001);
103
+ let reflection = reflect(-view, normal);
104
+ let projected = box_project(worldPosition, reflection, boxCenter, boxExtents);
105
+ let rotated = inverseRotateEnvironment(projected, rotation);
106
+ let probeDirection = vec3<f32>(rotated.x, -rotated.y, rotated.z);
107
+ let mip = roughness * 4.0;
108
+ let prefilteredColor = textureSampleLevel(probeMap, probeSampler, probeDirection, mip).rgb;
109
+ let envBRDF = textureSample(brdfLut, brdfLutSampler, vec2<f32>(NdotV, roughness)).rg;
110
+ let F = fresnelSchlickRoughness(NdotV, F0, roughness);
111
+ return prefilteredColor * (F * envBRDF.r + envBRDF.g);
112
+ }
package/src/index.ts CHANGED
@@ -108,6 +108,12 @@ export const ENGINE_MATERIAL_MODULES = [
108
108
  'forgeax_material::sprite-lit',
109
109
  ] as const;
110
110
 
111
+ /** Names of the zero-binding Standard reflection-probe shader helpers. */
112
+ export const REFLECTION_PROBE_SHADER_HELPERS = Object.freeze([
113
+ 'box_project',
114
+ 'sampleReflectionProbeSpecular',
115
+ ] as const);
116
+
111
117
  export function isEngineMaterialModule(module: string): boolean {
112
118
  return (ENGINE_MATERIAL_MODULES as readonly string[]).includes(module);
113
119
  }
@@ -48,14 +48,38 @@
48
48
  #import forgeax_view::common::{view, shadowMap, shadowSampler}
49
49
  #import forgeax_pbr::brdf::{f_schlick, v_smith, d_ggx}
50
50
  #import forgeax_pbr::shadow_pcf::{sample_shadow_2d}
51
+ #ifdef DIRECTIONAL_PCSS_AVAILABLE
52
+ #import forgeax_pbr::shadow_pcf::{shadow_biased_receiver_depth, shadow_clamp_texel_to_tile, shadow_load_raw_depth, shadow_sample_compare}
53
+ #endif
51
54
 
52
55
  // feat-20260621-learn-render-5-3-production-shadow-demos M0 / AC-14:
53
56
  // compile-time upper bound on the PCF half-extent so the WGSL tap loops keep
54
57
  // a constant trip count (no dynamic loop bounds / shader variants). half=2
55
- // covers pcfKernelSize in {1,3,5} -> {1,9,25} taps; view.pcfKernelSize selects
56
- // the runtime radius via a per-iteration clip (plan-strategy D-1).
58
+ // covers the PCF1/3/5 profiles -> {1,9,25} taps; the profile carrier selects
59
+ // the runtime radius while keeping the receiver variant-free.
57
60
  const MAX_PCF_HALF : u32 = 2u;
58
61
 
62
+ #ifdef DIRECTIONAL_PCSS_AVAILABLE
63
+ const PCSS_MEDIUM_RAW_TAPS : u32 = 8u;
64
+ const PCSS_MEDIUM_COMPARE_TAPS : u32 = 16u;
65
+ const PCSS_HIGH_RAW_TAPS : u32 = 16u;
66
+ const PCSS_HIGH_COMPARE_TAPS : u32 = 32u;
67
+
68
+ // Cascade-local disk points are fixed, bounded, and shared by raw and compare
69
+ // phases. A rotation derived from the integer receiver texel and cascade keeps
70
+ // the pattern stable without a temporal input.
71
+ const PCSS_DISK_OFFSETS : array<vec2<f32>, 32> = array<vec2<f32>, 32>(
72
+ vec2<f32>(-0.326, -0.945), vec2<f32>(0.236, -0.873), vec2<f32>(0.891, -0.404), vec2<f32>(-0.761, -0.581),
73
+ vec2<f32>(0.612, 0.146), vec2<f32>(-0.148, 0.514), vec2<f32>(-0.527, -0.109), vec2<f32>(0.074, 0.911),
74
+ vec2<f32>(-0.944, 0.238), vec2<f32>(0.444, -0.736), vec2<f32>(0.707, 0.641), vec2<f32>(-0.184, -0.342),
75
+ vec2<f32>(0.318, 0.382), vec2<f32>(-0.638, 0.526), vec2<f32>(0.955, -0.083), vec2<f32>(-0.401, 0.816),
76
+ vec2<f32>(-0.083, -0.632), vec2<f32>(0.539, -0.262), vec2<f32>(-0.735, -0.168), vec2<f32>(0.162, 0.719),
77
+ vec2<f32>(-0.841, 0.003), vec2<f32>(0.791, 0.332), vec2<f32>(-0.291, -0.791), vec2<f32>(0.021, -0.224),
78
+ vec2<f32>(0.386, 0.799), vec2<f32>(-0.558, 0.134), vec2<f32>(0.638, -0.555), vec2<f32>(-0.189, 0.957),
79
+ vec2<f32>(-0.977, -0.117), vec2<f32>(0.271, 0.589), vec2<f32>(0.819, -0.719), vec2<f32>(-0.472, -0.409),
80
+ );
81
+ #endif
82
+
59
83
  // Source: Three.js r184 src/nodes/functions/BSDF/DFGLUT.js. The full 16x16
60
84
  // RG16F table is decoded to exact f32 constants here; the lookup below mirrors
61
85
  // the source DataTexture's linear filtering and clamp-to-edge sampling without
@@ -226,8 +250,128 @@ fn _atlasTileOrigin(layer : u32, count : u32) -> vec2<f32> {
226
250
  return vec2<f32>(tile) / vec2<f32>(grid);
227
251
  }
228
252
 
253
+ #ifdef DIRECTIONAL_PCSS_AVAILABLE
254
+ fn _pcssDiskRotation(texel : vec2<i32>, layer : u32) -> f32 {
255
+ let hash = u32(texel.x) * 1664525u + u32(texel.y) * 1013904223u + (layer + 1u) * 374761393u;
256
+ return f32(hash % 6283u) * 0.001;
257
+ }
258
+
259
+ fn _pcssDiskPoint(index : u32, angle : f32, radius : f32) -> vec2<f32> {
260
+ let point = PCSS_DISK_OFFSETS[index];
261
+ let cs = cos(angle);
262
+ let sn = sin(angle);
263
+ return vec2<f32>(point.x * cs - point.y * sn, point.x * sn + point.y * cs) * radius;
264
+ }
265
+
266
+ fn _pcssProjectedSample(
267
+ shadowMapSize : vec2<u32>,
268
+ tileOrigin : vec2<u32>,
269
+ tileSize : vec2<u32>,
270
+ baseTexel : vec2<i32>,
271
+ offset : vec2<f32>,
272
+ ) -> vec2<f32> {
273
+ let texel = shadow_clamp_texel_to_tile(
274
+ baseTexel + vec2<i32>(round(offset)),
275
+ vec2<i32>(tileOrigin),
276
+ vec2<i32>(tileSize),
277
+ 1,
278
+ );
279
+ return (vec2<f32>(texel) + vec2<f32>(0.5)) / vec2<f32>(shadowMapSize);
280
+ }
281
+
282
+ fn _samplePcssForCascade(
283
+ worldPos : vec3<f32>,
284
+ layer : u32,
285
+ count : u32,
286
+ normal : vec3<f32>,
287
+ l : vec3<f32>,
288
+ profile : u32,
289
+ ) -> f32 {
290
+ let lightClip = _cascadeLightViewProj(layer) * vec4<f32>(worldPos, 1.0);
291
+ if (!(lightClip.w > 0.0 || lightClip.w < 0.0)) {
292
+ return 1.0;
293
+ }
294
+ let projCoords = lightClip.xyz / lightClip.w;
295
+ let tileUv = vec2<f32>(projCoords.x * 0.5 + 0.5, -projCoords.y * 0.5 + 0.5);
296
+ if (!(tileUv.x >= 0.0 && tileUv.x <= 1.0 && tileUv.y >= 0.0 && tileUv.y <= 1.0 && projCoords.z >= 0.0 && projCoords.z <= 1.0)) {
297
+ return 1.0;
298
+ }
299
+ let nDotL = dot(normal, l);
300
+ if (!(nDotL >= -1.0 && nDotL <= 1.0)) {
301
+ return 1.0;
302
+ }
303
+ let shadowMapSize = textureDimensions(shadowMap, 0);
304
+ let grid = _atlasTileGrid(count);
305
+ let tileSize = shadowMapSize / grid;
306
+ let tileOrigin = vec2<u32>(layer % grid.x, layer / grid.x) * tileSize;
307
+ let baseTexel = vec2<i32>(tileOrigin) + vec2<i32>(floor(tileUv * vec2<f32>(tileSize)));
308
+ let angle = _pcssDiskRotation(baseTexel, layer);
309
+ let biasedDepth = shadow_biased_receiver_depth(
310
+ projCoords.z,
311
+ view.normalBias,
312
+ view.depthBias,
313
+ nDotL,
314
+ );
315
+
316
+ let rawRadius = select(2.0, 3.0, profile == 5u);
317
+ var blockerDepth = 0.0;
318
+ var blockerCount = 0u;
319
+ if (profile == 4u) {
320
+ for (var i = 0u; i < PCSS_MEDIUM_RAW_TAPS; i = i + 1u) {
321
+ let rawUv = _pcssProjectedSample(shadowMapSize, tileOrigin, tileSize, baseTexel, _pcssDiskPoint(i, angle, rawRadius));
322
+ let rawTexel = vec2<i32>(rawUv * vec2<f32>(shadowMapSize) - vec2<f32>(0.5));
323
+ let rawDepth = shadow_load_raw_depth(shadowMap, rawTexel);
324
+ if (rawDepth >= 0.0 && rawDepth < biasedDepth) {
325
+ blockerDepth = blockerDepth + rawDepth;
326
+ blockerCount = blockerCount + 1u;
327
+ }
328
+ }
329
+ } else {
330
+ for (var i = 0u; i < PCSS_HIGH_RAW_TAPS; i = i + 1u) {
331
+ let rawUv = _pcssProjectedSample(shadowMapSize, tileOrigin, tileSize, baseTexel, _pcssDiskPoint(i, angle, rawRadius));
332
+ let rawTexel = vec2<i32>(rawUv * vec2<f32>(shadowMapSize) - vec2<f32>(0.5));
333
+ let rawDepth = shadow_load_raw_depth(shadowMap, rawTexel);
334
+ if (rawDepth >= 0.0 && rawDepth < biasedDepth) {
335
+ blockerDepth = blockerDepth + rawDepth;
336
+ blockerCount = blockerCount + 1u;
337
+ }
338
+ }
339
+ }
340
+ if (blockerCount == 0u) {
341
+ return 1.0;
342
+ }
343
+
344
+ let averageBlockerDepth = blockerDepth / f32(blockerCount);
345
+ let lightDepthWorldSpan = max(view.splitPlanes[layer].z, 0.0);
346
+ let worldUnitsPerTexel = max(view.splitPlanes[layer].y, 0.0);
347
+ if (!(lightDepthWorldSpan > 0.0 && worldUnitsPerTexel > 0.0 && view.directionalShadowFilter.y >= 0.0 && view.directionalShadowFilter.z >= 0.0)) {
348
+ return 1.0;
349
+ }
350
+ let worldDistance = max(0.0, biasedDepth - averageBlockerDepth) * lightDepthWorldSpan;
351
+ let penumbraTexels = clamp(
352
+ worldDistance * tan(view.directionalShadowFilter.y) / worldUnitsPerTexel,
353
+ 0.0,
354
+ view.directionalShadowFilter.z,
355
+ );
356
+ let compareRadius = max(0.5, penumbraTexels);
357
+ var litSum = 0.0;
358
+ if (profile == 4u) {
359
+ for (var i = 0u; i < PCSS_MEDIUM_COMPARE_TAPS; i = i + 1u) {
360
+ let compareUv = _pcssProjectedSample(shadowMapSize, tileOrigin, tileSize, baseTexel, _pcssDiskPoint(i, angle, compareRadius));
361
+ litSum = litSum + shadow_sample_compare(shadowMap, shadowSampler, compareUv, biasedDepth);
362
+ }
363
+ return litSum / f32(PCSS_MEDIUM_COMPARE_TAPS);
364
+ }
365
+ for (var i = 0u; i < PCSS_HIGH_COMPARE_TAPS; i = i + 1u) {
366
+ let compareUv = _pcssProjectedSample(shadowMapSize, tileOrigin, tileSize, baseTexel, _pcssDiskPoint(i, angle, compareRadius));
367
+ litSum = litSum + shadow_sample_compare(shadowMap, shadowSampler, compareUv, biasedDepth);
368
+ }
369
+ return litSum / f32(PCSS_HIGH_COMPARE_TAPS);
370
+ }
371
+ #endif
372
+
229
373
  // Sample the shadow atlas with the LO 3.1.3 slope-scaled bias + dynamic PCF
230
- // kernel (driven by view.pcfKernelSize, MAX_PCF_HALF=2),
374
+ // kernel (driven by the directional filter profile, MAX_PCF_HALF=2),
231
375
  // against the lightViewProj for the chosen cascade. The shader maps NDC
232
376
  // xy to that cascade's atlas tile in fragment space (matrix carries
233
377
  // clip-space; tile placement happens here so shadow_caster.gl_Position
@@ -275,21 +419,16 @@ fn _sampleShadowForCascade(
275
419
  // [tileOrigin, tileOrigin+tileScale) (one texel inset) so taps stay in-tile.
276
420
  let tileLo = tileOrigin + texel;
277
421
  let tileHi = tileOrigin + tileScale - texel;
278
- // Variable-width PCF kernel driven by view.pcfKernelSize (feat-20260621
279
- // 5.3-production-shadow-demos AC-14 merged with the DirectionalLightShadow
280
- // merge). Constant trip count to MAX_PCF_HALF with a per-iteration clip to the
281
- // runtime radius keeps the shader variant-free (no dynamic loop bound, legal
282
- // for textureSampleCompareLevel uniform control flow). Host clamps
283
- // view.pcfKernelSize to {1,3,5}; divisor = actual tap count, so pcfKernelSize=3
284
- // -> half=1 -> 9 taps / 9.0 (result-identical to the prior hard-coded 3x3);
285
- // pcfKernelSize=1 -> half=0 -> single centre tap (hard edge); pcfKernelSize=5
286
- // -> half=2 -> 25-tap soft penumbra.
287
- let requestedKernel = clamp(u32(round(view.pcfKernelSize)), 1u, 2u * MAX_PCF_HALF + 1u);
288
- // Preserve the previous radius mapping for malformed even values:
289
- // 1 -> 1x1, 2/3/4 -> 3x3, 5 -> 5x5. The authored component accepts only
290
- // {1,3,5}, but keeping this normalization makes the refactor output-stable
291
- // for raw UBO callers too.
292
- let kernel = select(select(5u, 3u, requestedKernel <= 4u), 1u, requestedKernel == 1u);
422
+ // PCF1/3/5 profiles 1/2/3 retain their existing receiver. PCSS profiles 4/5
423
+ // use the bounded three-stage receiver below without a material or variant
424
+ // axis; the carrier y/z lanes provide angular radius and penumbra limit.
425
+ let filterProfile = clamp(u32(round(view.directionalShadowFilter.x)), 1u, 5u);
426
+ #ifdef DIRECTIONAL_PCSS_AVAILABLE
427
+ if (filterProfile >= 4u) {
428
+ return _samplePcssForCascade(worldPos, layer, count, normal, l, filterProfile);
429
+ }
430
+ #endif
431
+ let kernel = select(select(3u, 5u, filterProfile == 3u), 1u, filterProfile == 1u);
293
432
  if (kernel == 1u) {
294
433
  let lit = textureSampleCompareLevel(shadowMap, shadowSampler, clamp(uv, tileLo, tileHi), adjustedDepth);
295
434
  return lit;
@@ -32,7 +32,7 @@
32
32
  // invRangeSquared, ...) -> vec3<f32>
33
33
 
34
34
  #import forgeax_pbr::brdf::{f_schlick, v_smith, d_ggx}
35
- #import forgeax_pbr::lighting_attenuation::{evalDistanceAttenuation, evalSpotAttenuation}
35
+ #import forgeax_pbr::lighting_attenuation::{evalDistanceAttenuation, evalSpotAttenuation, projectSpotUv}
36
36
  // feat-20260625-spot-light-shadow-mapping M3 / w15 (plan-strategy D-3 + D-5):
37
37
  // spot shadow sampling reuses the shared 2D 9-tap PCF core (sample_shadow_2d)
38
38
  // and the always-on `spotShadowMap` (binding 8) + `shadowSampler` (binding 4).
@@ -78,16 +78,6 @@ fn evalVolumeSpot(
78
78
  );
79
79
  }
80
80
 
81
- // One projector UV helper is shared by surface and volume. The accepted
82
- // projectorRevision travels in the host tuple; it is deliberately not a
83
- // second shader registry or a device-side identity.
84
- fn projectSpotUv(lightViewProj : mat4x4<f32>, worldPos : vec3<f32>) -> vec2<f32> {
85
- let clip = lightViewProj * vec4<f32>(worldPos, 1.0);
86
- let invW = select(1.0 / clip.w, 0.0, abs(clip.w) < 1e-6);
87
- let projectorUv = vec2<f32>(clip.x * invW * 0.5 + 0.5, clip.y * invW * -0.5 + 0.5);
88
- return projectorUv;
89
- }
90
-
91
81
  // Shared punctual BRDF body returning (diffuse + specular) *
92
82
  // colorTimesIntensity * nDotL * attenuation. Cone factor is applied by the
93
83
  // caller (evalSpot only).
@@ -138,6 +128,50 @@ fn evalPoint(
138
128
  );
139
129
  }
140
130
 
131
+ // Flat 2D punctual evaluators share the range/cone math with the compatibility
132
+ // path and the clustered path, but intentionally skip the 3D BRDF normal term.
133
+ // Sprite-lit treats every quad as an omnidirectional receiver; keeping this
134
+ // owner here prevents URP and Cluster from drifting when the light lies in the
135
+ // sprite plane (the common 2D flashlight setup).
136
+ fn evalFlatRangeAttenuation(dSquared : f32, invRangeSquared : f32) -> f32 {
137
+ let factor = max(min(1.0 - (dSquared * invRangeSquared) * (dSquared * invRangeSquared), 1.0), 0.0);
138
+ return factor / dSquared;
139
+ }
140
+
141
+ fn evalPointFlat(
142
+ lightPos : vec3<f32>,
143
+ colorTimesIntensity : vec3<f32>,
144
+ invRangeSquared : f32,
145
+ worldPos : vec3<f32>,
146
+ baseColor : vec3<f32>,
147
+ ) -> vec3<f32> {
148
+ // The flat 2D path shares the same finite-range attenuation owner as the
149
+ // clustered and direct Standard paths; its only intentional difference is
150
+ // that a sprite is an omnidirectional receiver.
151
+ let toLight = lightPos - worldPos;
152
+ let dSquared = max(dot(toLight, toLight), 1e-4);
153
+ return baseColor * colorTimesIntensity * evalFlatRangeAttenuation(dSquared, invRangeSquared);
154
+ }
155
+
156
+ fn evalSpotFlat(
157
+ lightPos : vec3<f32>,
158
+ lightDir : vec3<f32>,
159
+ colorTimesIntensity : vec3<f32>,
160
+ cosInner : f32,
161
+ cosOuter : f32,
162
+ invRangeSquared : f32,
163
+ worldPos : vec3<f32>,
164
+ baseColor : vec3<f32>,
165
+ ) -> vec3<f32> {
166
+ let toLight = lightPos - worldPos;
167
+ let dSquared = max(dot(toLight, toLight), 1e-4);
168
+ let l = toLight / sqrt(dSquared);
169
+ let cone = smoothstep(cosOuter, cosInner, dot(l, -lightDir));
170
+ return evalPointFlat(
171
+ lightPos, colorTimesIntensity, invRangeSquared, worldPos, baseColor,
172
+ ) * cone;
173
+ }
174
+
141
175
  #ifdef POINT_SHADOW_AVAILABLE
142
176
  // Shadow-modulated omnidirectional point light: same BRDF body * shadow factor.
143
177
  //
@@ -175,13 +209,13 @@ fn evalPointShadowed(
175
209
  lightPos, colorTimesIntensity, invRangeSquared,
176
210
  worldPos, normal, viewDir, baseColor, metallic, alphaSq, F0,
177
211
  );
178
- // Fragment-to-light direction; cubemap sample uses the local-space
179
- // direction (research L0.5: Bevy convention). For a right-handed world,
180
- // the cubemap convention flips Z so the +Z face look direction matches.
212
+ // Keep the fragment-to-light vector for BRDF and nDotL. Shadow matrices are
213
+ // authored light-to-fragment (`lookAt(lightPos, fragment)`), so the cube
214
+ // lookup must use the opposite direction. For a right-handed world, the
215
+ // cubemap convention flips Z so the +Z face look direction matches.
181
216
  let toLight = lightPos - worldPos;
182
- // Cubemap sample direction is from-fragment-to-light (Bevy + LearnOpenGL),
183
- // negated to fragment-from-light when reconstructing the depth ref.
184
- let lightLocal = vec3<f32>(toLight.x, toLight.y, -toLight.z);
217
+ let fromLight = worldPos - lightPos;
218
+ let lightLocal = vec3<f32>(fromLight.x, fromLight.y, -fromLight.z);
185
219
  // Reconstruct [0,1] NDC depth from world-space distance: largest-axis
186
220
  // projection (research L0.5). Match the per-face perspective near / far
187
221
  // configured by buildPointShadowMatrices (PointLightShadow.nearPlane /
@@ -287,10 +321,6 @@ fn evalSpotShadowed(
287
321
  let invW = select(1.0 / splane.w, 0.0, abs(splane.w) < 1e-6);
288
322
  let clipUv = projectSpotUv(lightViewProj, worldPos);
289
323
  let depthRef = splane.z * invW;
290
- // The accepted projectorRevision selects the same texture tuple for
291
- // surface and volume; a bound projector uses textureSampleLevel here.
292
- let projectorRevision = 0.0;
293
-
294
324
  // OOB / NaN gate: outside the light frustum (or NaN from a degenerate matrix)
295
325
  // returns fully lit. Mirrors the directional `>= 0 && <= 1` NaN-safe form.
296
326
  if (!(clipUv.x >= 0.0 && clipUv.x <= 1.0 && clipUv.y >= 0.0 && clipUv.y <= 1.0 && depthRef <= 1.0)) {
@@ -27,17 +27,23 @@ fn sceneTemporalUv(clip : vec4<f32>) -> vec2<f32> {
27
27
  return vec2<f32>(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
28
28
  }
29
29
 
30
- fn sceneTemporalViewDepth(clip : vec4<f32>, temporalProjection : vec4<f32>) -> f32 {
31
- let perspectiveDepth = max(clip.w, 0.0);
30
+ // Signed view-space Z shared by temporal reconstruction and Standard Cluster
31
+ // lookup. Both CPU and WGSL binners consume the negative camera-space depth:
32
+ // perspective uses -clip.w, while orthographic depth is reconstructed from
33
+ // the projection's near/far payload rather than Euclidean world distance.
34
+ fn sceneViewZ(clip : vec4<f32>, temporalProjection : vec4<f32>) -> f32 {
32
35
  let ndcDepth = clip.z / max(abs(clip.w), 1e-6);
33
- let orthographicDepth = temporalProjection.x +
34
- ndcDepth * (temporalProjection.y - temporalProjection.x);
35
- let viewDepth = select(
36
- perspectiveDepth,
37
- max(orthographicDepth, 0.0),
36
+ let orthographicViewZ = -(temporalProjection.x +
37
+ ndcDepth * (temporalProjection.y - temporalProjection.x));
38
+ return select(
39
+ -clip.w,
40
+ orthographicViewZ,
38
41
  temporalProjection.z >= 0.5,
39
42
  );
40
- return log2(1.0 + viewDepth);
43
+ }
44
+
45
+ fn sceneTemporalViewDepth(clip : vec4<f32>, temporalProjection : vec4<f32>) -> f32 {
46
+ return log2(1.0 + max(-sceneViewZ(clip, temporalProjection), 0.0));
41
47
  }
42
48
 
43
49
  fn packSceneTemporalV1(
@@ -20,6 +20,49 @@
20
20
  //
21
21
  // Return: 1.0 = fully lit, 0.0 = fully shadowed.
22
22
 
23
+ // === Directional PCSS sampling primitives ====================================
24
+ //
25
+ // These functions deliberately accept tile bounds from their caller. They own
26
+ // only integer addressing, raw depth, comparison sampling, and the shared
27
+ // receiver-bias equation; cascade selection, profile selection, blocker
28
+ // averaging, penumbra sizing, and cascade blending remain Directional policy.
29
+
30
+ fn shadow_clamp_texel_to_tile(
31
+ texel : vec2<i32>,
32
+ tileOrigin : vec2<i32>,
33
+ tileSize : vec2<i32>,
34
+ inset : i32,
35
+ ) -> vec2<i32> {
36
+ let tileMin = tileOrigin + vec2<i32>(inset);
37
+ let tileMax = tileOrigin + max(vec2<i32>(inset), tileSize - vec2<i32>(1 + inset));
38
+ return min(tileMax, max(tileMin, texel));
39
+ }
40
+
41
+ fn shadow_load_raw_depth(
42
+ shadowMap : texture_depth_2d,
43
+ texel : vec2<i32>,
44
+ ) -> f32 {
45
+ return textureLoad(shadowMap, texel, 0);
46
+ }
47
+
48
+ fn shadow_sample_compare(
49
+ shadowMap : texture_depth_2d,
50
+ shadowSampler: sampler_comparison,
51
+ uv : vec2<f32>,
52
+ depthRef : f32,
53
+ ) -> f32 {
54
+ return textureSampleCompareLevel(shadowMap, shadowSampler, uv, depthRef);
55
+ }
56
+
57
+ fn shadow_biased_receiver_depth(
58
+ receiverDepth : f32,
59
+ normalBias : f32,
60
+ depthBias : f32,
61
+ nDotL : f32,
62
+ ) -> f32 {
63
+ return receiverDepth - max(normalBias * (1.0 - nDotL), depthBias);
64
+ }
65
+
23
66
  // 3x3 integer offset table for PCF kernel (9 taps).
24
67
  // Extracted from lighting-directional.wgsl inline loop (T-M2-4);
25
68
  // `sample_shadow_2d` walks all 9 taps; `sample_shadow_cube_hw2x2` does NOT
@@ -99,7 +142,7 @@ fn sample_shadow_2d_kernel(
99
142
  // `shadowAtlas` is @group(0) @binding(5) texture_depth_cube_array.
100
143
  // `shadowSampler` is @group(0) @binding(4) sampler_comparison (shared with
101
144
  // directional shadows — same sampler type, no dimension distinction).
102
- // `lightLocal` is the fragment-to-light direction vector in the cubemap's
145
+ // `lightLocal` is the light-to-fragment direction vector in the cubemap's
103
146
  // coordinate system (Bevy convention: left-handed cubemap; caller applies
104
147
  // flip_z = vec3(1,1,-1) to convert right-hand world to left-hand cubemap).
105
148
  // `layer` is the cube_array layer index (i32; 0..3 for 4 shadow-casting