@forgeax/engine-shader 0.1.19 → 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 (51) 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 +71 -22
  13. package/src/__tests__/fog-oracle.unit.test.ts +110 -114
  14. package/src/__tests__/fog-orthographic-ray.unit.test.ts +12 -37
  15. package/src/__tests__/fog-producer-matrix.integration.test.ts +16 -51
  16. package/src/__tests__/lighting-point-spot-volume.unit.test.ts +56 -0
  17. package/src/__tests__/lighting-punctual.unit.test.ts +32 -6
  18. package/src/__tests__/lighting-spot-volume-attenuation.unit.test.ts +26 -0
  19. package/src/__tests__/lighting-spot-volume.unit.test.ts +25 -0
  20. package/src/__tests__/material-derived-builtins.integration.test.ts +27 -0
  21. package/src/__tests__/reflection-probe-sampling.unit.test.ts +23 -0
  22. package/src/__tests__/scene-temporal.unit.test.ts +20 -0
  23. package/src/__tests__/shader.unit.test.ts +35 -13
  24. package/src/__tests__/shadow-pcss.unit.test.ts +131 -0
  25. package/src/__tests__/sprite-lit-shader.test.ts +24 -11
  26. package/src/__tests__/sprite-variants.unit.test.ts +2 -0
  27. package/src/__tests__/transmission-thickness-scale.unit.test.ts +7 -1
  28. package/src/__tests__/transparent-pbr.unit.test.ts +7 -0
  29. package/src/common.wgsl +72 -73
  30. package/src/default-standard-pbr-skin.wgsl +31 -89
  31. package/src/default-standard-pbr.wgsl +94 -146
  32. package/src/fxaa.wgsl +27 -6
  33. package/src/hdrp-deferred-lighting.wgsl +3 -27
  34. package/src/ibl-sampling.wgsl +54 -0
  35. package/src/index.ts +6 -0
  36. package/src/lighting-attenuation.wgsl +58 -0
  37. package/src/lighting-directional.wgsl +188 -21
  38. package/src/lighting-punctual.wgsl +94 -19
  39. package/src/msdf-text.wgsl +4 -23
  40. package/src/scene-temporal.wgsl +14 -8
  41. package/src/shadow-pcf.wgsl +75 -11
  42. package/src/skybox.wgsl +2 -7
  43. package/src/sprite-lit.wgsl +38 -73
  44. package/src/sprite.wgsl +3 -22
  45. package/src/{hdrp-cluster-forward.wgsl → standard-cluster.wgsl} +74 -23
  46. package/src/tonemap.wgsl +8 -3
  47. package/src/unlit.wgsl +2 -24
  48. package/src/volume/volume-composite.wgsl +113 -0
  49. package/src/volume/volume-inject.wgsl +261 -0
  50. package/src/volume/volume-integrate.wgsl +337 -0
  51. package/src/volume/volume-temporal.wgsl +70 -0
@@ -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
@@ -238,6 +382,7 @@ fn _sampleShadowForCascade(
238
382
  count : u32,
239
383
  normal : vec3<f32>,
240
384
  l : vec3<f32>,
385
+ useNormalBias : bool,
241
386
  ) -> f32 {
242
387
  let lvp = _cascadeLightViewProj(layer);
243
388
  let lightClip = lvp * vec4<f32>(worldPos, 1.0);
@@ -253,7 +398,7 @@ fn _sampleShadowForCascade(
253
398
  // shadow fields carried in the View UBO -- normalBias scales the
254
399
  // (1 - N.L) slope term, depthBias is the constant floor. Replaces the prior
255
400
  // hardcoded max(0.05*(1-N.L), 0.005).
256
- let bias = max(view.normalBias * (1.0 - dot(normal, l)), view.depthBias);
401
+ let bias = select(view.depthBias, max(view.normalBias * (1.0 - dot(normal, l)), view.depthBias), useNormalBias);
257
402
  let adjustedDepth = currentDepth - bias;
258
403
  // NaN-safe bounds: relational < and > do not reject NaN (NaN < 0 is
259
404
  // false), so a zero / degenerate lightViewProj matrix that produces
@@ -274,21 +419,16 @@ fn _sampleShadowForCascade(
274
419
  // [tileOrigin, tileOrigin+tileScale) (one texel inset) so taps stay in-tile.
275
420
  let tileLo = tileOrigin + texel;
276
421
  let tileHi = tileOrigin + tileScale - texel;
277
- // Variable-width PCF kernel driven by view.pcfKernelSize (feat-20260621
278
- // 5.3-production-shadow-demos AC-14 merged with the DirectionalLightShadow
279
- // merge). Constant trip count to MAX_PCF_HALF with a per-iteration clip to the
280
- // runtime radius keeps the shader variant-free (no dynamic loop bound, legal
281
- // for textureSampleCompareLevel uniform control flow). Host clamps
282
- // view.pcfKernelSize to {1,3,5}; divisor = actual tap count, so pcfKernelSize=3
283
- // -> half=1 -> 9 taps / 9.0 (result-identical to the prior hard-coded 3x3);
284
- // pcfKernelSize=1 -> half=0 -> single centre tap (hard edge); pcfKernelSize=5
285
- // -> half=2 -> 25-tap soft penumbra.
286
- let requestedKernel = clamp(u32(round(view.pcfKernelSize)), 1u, 2u * MAX_PCF_HALF + 1u);
287
- // Preserve the previous radius mapping for malformed even values:
288
- // 1 -> 1x1, 2/3/4 -> 3x3, 5 -> 5x5. The authored component accepts only
289
- // {1,3,5}, but keeping this normalization makes the refactor output-stable
290
- // for raw UBO callers too.
291
- 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);
292
432
  if (kernel == 1u) {
293
433
  let lit = textureSampleCompareLevel(shadowMap, shadowSampler, clamp(uv, tileLo, tileHi), adjustedDepth);
294
434
  return lit;
@@ -414,7 +554,7 @@ fn evalDirectionalShadowFactor(
414
554
  return 1.0;
415
555
  }
416
556
  let layer = _pickCascadeLayer(viewDepth, count);
417
- let shadowCurr = _sampleShadowForCascade(worldPos, layer, count, normal, l);
557
+ let shadowCurr = _sampleShadowForCascade(worldPos, layer, count, normal, l, true);
418
558
 
419
559
  var shadow = shadowCurr;
420
560
  if (view.cascadeBlend > 0.0 && layer + 1u < count) {
@@ -424,7 +564,7 @@ fn evalDirectionalShadowFactor(
424
564
  let dist = spCurr - viewDepth;
425
565
  let t = clamp(1.0 - dist / blendWidth, 0.0, 1.0);
426
566
  if (t > 0.0) {
427
- let shadowNext = _sampleShadowForCascade(worldPos, layer + 1u, count, normal, l);
567
+ let shadowNext = _sampleShadowForCascade(worldPos, layer + 1u, count, normal, l, true);
428
568
  shadow = mix(shadowCurr, shadowNext, t);
429
569
  }
430
570
  }
@@ -432,6 +572,33 @@ fn evalDirectionalShadowFactor(
432
572
  return shadow;
433
573
  }
434
574
 
575
+ // Volume receivers are samples in a medium, not surfaces. They therefore use
576
+ // the same cascade selection, atlas placement, PCF, and shadow-distance SSOT
577
+ // but only the bounded constant receiver bias; a ray direction is never
578
+ // treated as a geometric normal for normalBias.
579
+ fn evalDirectionalVolumeShadowFactor(
580
+ worldPos : vec3<f32>,
581
+ viewDepth : f32,
582
+ ) -> f32 {
583
+ if (view.cascadeCount < 1.0 || viewDepth > view.splitPlanes[u32(max(view.cascadeCount, 1.0)) - 1u].x) {
584
+ return 1.0;
585
+ }
586
+ let count = u32(max(view.cascadeCount, 1.0));
587
+ let layer = _pickCascadeLayer(viewDepth, count);
588
+ let lightDirection = normalize(-view.lightDir);
589
+ let current = _sampleShadowForCascade(worldPos, layer, count, vec3<f32>(0.0), lightDirection, false);
590
+ if (view.cascadeBlend <= 0.0 || layer + 1u >= count) {
591
+ return current;
592
+ }
593
+ let blendWidth = view.splitPlanes[layer].x * view.cascadeBlend;
594
+ if (blendWidth <= 0.0) {
595
+ return current;
596
+ }
597
+ let blend = clamp(1.0 - (view.splitPlanes[layer].x - viewDepth) / blendWidth, 0.0, 1.0);
598
+ let next = _sampleShadowForCascade(worldPos, layer + 1u, count, vec3<f32>(0.0), lightDirection, false);
599
+ return mix(current, next, blend);
600
+ }
601
+
435
602
  // `evalDirectional` evaluates the GGX direct-lighting term for the single
436
603
  // directional light carried in `view.lightDir / view.lightColor`. CSM
437
604
  // pathway: pick cascade layer from viewZ + splitPlanes, sample the atlas
@@ -32,12 +32,13 @@
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, projectSpotUv}
35
36
  // feat-20260625-spot-light-shadow-mapping M3 / w15 (plan-strategy D-3 + D-5):
36
37
  // spot shadow sampling reuses the shared 2D 9-tap PCF core (sample_shadow_2d)
37
38
  // and the always-on `spotShadowMap` (binding 8) + `shadowSampler` (binding 4).
38
39
  // `shadowSampler` is imported UNCONDITIONALLY here (spot is always-on, D-5):
39
40
  // the point-shadow #ifdef block below must NOT re-import it (double import).
40
- #import forgeax_pbr::shadow_pcf::{sample_shadow_2d}
41
+ #import forgeax_pbr::shadow_pcf::{sample_shadow_2d_kernel}
41
42
  #import forgeax_view::common::{spotShadowMap, shadowSampler}
42
43
  #ifdef POINT_SHADOW_AVAILABLE
43
44
  #import forgeax_pbr::shadow_pcf::{sample_shadow_cube_hw2x2}
@@ -48,6 +49,35 @@
48
49
  #import forgeax_view::common::{shadowAtlas}
49
50
  #endif
50
51
 
52
+ // Volume lighting uses the same host-derived range and cone facts as surface
53
+ // lighting. These wrappers intentionally return radiance factors only; the
54
+ // volume integrator owns density, phase, transmittance, and accumulation.
55
+ fn evalVolumePoint(
56
+ lightPos : vec3<f32>,
57
+ colorTimesIntensity : vec3<f32>,
58
+ invRangeSquared : f32,
59
+ worldPos : vec3<f32>,
60
+ ) -> vec3<f32> {
61
+ let toLight = lightPos - worldPos;
62
+ let dSquared = max(dot(toLight, toLight), 1e-4);
63
+ let safeDistance = max(dSquared, 1e-4);
64
+ return colorTimesIntensity * evalDistanceAttenuation(safeDistance, invRangeSquared);
65
+ }
66
+
67
+ fn evalVolumeSpot(
68
+ lightPos : vec3<f32>,
69
+ lightDir : vec3<f32>,
70
+ colorTimesIntensity : vec3<f32>,
71
+ cosInner : f32,
72
+ cosOuter : f32,
73
+ invRangeSquared : f32,
74
+ worldPos : vec3<f32>,
75
+ ) -> vec3<f32> {
76
+ return colorTimesIntensity * evalSpotAttenuation(
77
+ lightPos, lightDir, worldPos, cosInner, cosOuter, invRangeSquared,
78
+ );
79
+ }
80
+
51
81
  // Shared punctual BRDF body returning (diffuse + specular) *
52
82
  // colorTimesIntensity * nDotL * attenuation. Cone factor is applied by the
53
83
  // caller (evalSpot only).
@@ -75,8 +105,7 @@ fn evalPunctualBody(
75
105
  let specular = d_ggx(nDotH, alphaSq) * v_smith(nDotV, nDotL, alphaSq) * f;
76
106
  let kd = (vec3<f32>(1.0) - f) * (1.0 - metallic);
77
107
  let diffuse = kd * baseColor / 3.14159265;
78
- let factor = max(min(1.0 - (dSquared * invRangeSquared) * (dSquared * invRangeSquared), 1.0), 0.0);
79
- let attenuation = factor * factor / dSquared;
108
+ let attenuation = evalDistanceAttenuation(dSquared, invRangeSquared);
80
109
  return (diffuse + specular) * colorTimesIntensity * nDotL * attenuation;
81
110
  }
82
111
 
@@ -99,6 +128,50 @@ fn evalPoint(
99
128
  );
100
129
  }
101
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
+
102
175
  #ifdef POINT_SHADOW_AVAILABLE
103
176
  // Shadow-modulated omnidirectional point light: same BRDF body * shadow factor.
104
177
  //
@@ -136,13 +209,13 @@ fn evalPointShadowed(
136
209
  lightPos, colorTimesIntensity, invRangeSquared,
137
210
  worldPos, normal, viewDir, baseColor, metallic, alphaSq, F0,
138
211
  );
139
- // Fragment-to-light direction; cubemap sample uses the local-space
140
- // direction (research L0.5: Bevy convention). For a right-handed world,
141
- // 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.
142
216
  let toLight = lightPos - worldPos;
143
- // Cubemap sample direction is from-fragment-to-light (Bevy + LearnOpenGL),
144
- // negated to fragment-from-light when reconstructing the depth ref.
145
- 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);
146
219
  // Reconstruct [0,1] NDC depth from world-space distance: largest-axis
147
220
  // projection (research L0.5). Match the per-face perspective near / far
148
221
  // configured by buildPointShadowMatrices (PointLightShadow.nearPlane /
@@ -185,10 +258,9 @@ fn evalSpot(
185
258
  lightPos, colorTimesIntensity, invRangeSquared,
186
259
  worldPos, normal, viewDir, baseColor, metallic, alphaSq, F0,
187
260
  );
188
- let toLight = lightPos - worldPos;
189
- let l = normalize(toLight);
190
- let cone = smoothstep(cosOuter, cosInner, dot(l, -lightDir));
191
- return body * cone;
261
+ return body * evalSpotAttenuation(
262
+ lightPos, lightDir, worldPos, cosInner, cosOuter, invRangeSquared,
263
+ ) / max(evalDistanceAttenuation(dot(lightPos - worldPos, lightPos - worldPos), invRangeSquared), 1e-4);
192
264
  }
193
265
 
194
266
  // feat-20260625-spot-light-shadow-mapping M3 / w15 (plan-strategy D-3 + D-4 +
@@ -234,6 +306,8 @@ fn evalSpotShadowed(
234
306
  shadowAtlasTile : i32,
235
307
  depthBias : f32,
236
308
  normalBias : f32,
309
+ pcfKernelSize : f32,
310
+ shadowIntensity : f32,
237
311
  ) -> vec3<f32> {
238
312
  let body = evalSpot(
239
313
  lightPos, lightDir, colorTimesIntensity, cosInner, cosOuter, invRangeSquared,
@@ -245,11 +319,8 @@ fn evalSpotShadowed(
245
319
  // Perspective divide; guard a zero/near-zero w (fragment behind the light or
246
320
  // a degenerate matrix) so the OOB gate below catches it as fully lit.
247
321
  let invW = select(1.0 / splane.w, 0.0, abs(splane.w) < 1e-6);
248
- let ndcXY = splane.xy * invW;
322
+ let clipUv = projectSpotUv(lightViewProj, worldPos);
249
323
  let depthRef = splane.z * invW;
250
- // Clip-space [-1,1] -> texture UV [0,1] with the standard Y flip.
251
- let clipUv = vec2<f32>(ndcXY.x * 0.5 + 0.5, ndcXY.y * -0.5 + 0.5);
252
-
253
324
  // OOB / NaN gate: outside the light frustum (or NaN from a degenerate matrix)
254
325
  // returns fully lit. Mirrors the directional `>= 0 && <= 1` NaN-safe form.
255
326
  if (!(clipUv.x >= 0.0 && clipUv.x <= 1.0 && clipUv.y >= 0.0 && clipUv.y <= 1.0 && depthRef <= 1.0)) {
@@ -267,8 +338,12 @@ fn evalSpotShadowed(
267
338
  let texel = vec2<f32>(1.0, 1.0) / atlasDims;
268
339
 
269
340
  let nDotL = max(dot(normal, normalize(lightPos - worldPos)), 0.0);
270
- let shadowFactor = sample_shadow_2d(
341
+ let shadowFactor = sample_shadow_2d_kernel(
271
342
  spotShadowMap, shadowSampler, atlasUv, texel, depthRef, normalBias, depthBias, nDotL,
343
+ pcfKernelSize,
272
344
  );
273
- return body * shadowFactor;
345
+ // Three's SpotLight.shadow.intensity is the opacity of the shadow, not a
346
+ // multiplier on the light's candela radiance. A value of 1 keeps the
347
+ // sampled visibility fully opaque; 0 leaves the unshadowed body intact.
348
+ return body * mix(1.0, shadowFactor, clamp(shadowIntensity, 0.0, 1.0));
274
349
  }
@@ -1,7 +1,6 @@
1
1
  #pragma variant_axis STORAGE_BUFFER_AVAILABLE
2
2
 
3
- #import forgeax_view::common::{View, FogViewParams, FogRay, Mesh, view, meshes, sampleMaterialTexture}
4
- #import forgeax_view::fog::{apply_fog}
3
+ #import forgeax_view::common::{View, Mesh, view, meshes, sampleMaterialTexture}
5
4
 
6
5
  // @forgeax/engine-shader - msdf-text.wgsl
7
6
  // (feat-20260531-world-space-msdf-text-rendering M5 / w20).
@@ -146,22 +145,6 @@ fn screen_px_range(uv : vec2<f32>) -> f32 {
146
145
  return max(0.5 * dot(unit_range, screen_tex_size), 1.0);
147
146
  }
148
147
 
149
- fn applySceneFog(viewParams : View, color : vec3<f32>, alpha : f32, worldPos : vec3<f32>) -> vec4<f32> {
150
- var origin = viewParams.cameraPos;
151
- var direction = normalize(worldPos - origin);
152
- var rayDistance = length(worldPos - origin);
153
- if (viewParams.temporalProjection.z >= 0.5) {
154
- let nearH = viewParams.inverseViewProj * vec4<f32>(0.0, 0.0, 0.0, 1.0);
155
- let farH = viewParams.inverseViewProj * vec4<f32>(0.0, 0.0, 1.0, 1.0);
156
- let nearPoint = nearH.xyz / nearH.w;
157
- let farPoint = farH.xyz / farH.w;
158
- direction = normalize(farPoint - nearPoint);
159
- origin = worldPos - direction * dot(worldPos - viewParams.cameraPos, direction);
160
- rayDistance = max(dot(worldPos - origin, direction), 0.0);
161
- }
162
- return apply_fog(viewParams.fog, FogRay(origin, direction, rayDistance), vec4<f32>(color, alpha));
163
- }
164
-
165
148
  // fs_main_hdr: outputs linear premultiplied alpha for the rgba16float
166
149
  // offscreen target (D-7 / R-7). The tonemap fullscreen pass handles sRGB
167
150
  // encoding; writing hdrColor lets the bloom bright-pass catch the text.
@@ -176,8 +159,7 @@ fn fs_main_hdr(in : VsOut) -> @location(0) vec4<f32> {
176
159
  let alpha = clamp(dist + 0.5, 0.0, 1.0) * material.tintColor.a;
177
160
  // Premultiplied output: rgb already multiplied by alpha for srcFactor=ONE /
178
161
  // dstFactor=ONE_MINUS_SRC_ALPHA (wiki section 6 SSOT).
179
- let fogged = applySceneFog(view, material.tintColor.rgb, alpha, in.worldPos);
180
- return vec4<f32>(fogged.rgb * fogged.a, fogged.a);
162
+ return vec4<f32>(material.tintColor.rgb * alpha, alpha);
181
163
  }
182
164
 
183
165
  // linear_to_srgb: per-channel IEC 61966-2-1 transfer function for the LDR
@@ -197,12 +179,11 @@ fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
197
179
  let sd = median(msd.r, msd.g, msd.b);
198
180
  let dist = (sd - 0.5) * screen_px_range(in.uv);
199
181
  let alpha = clamp(dist + 0.5, 0.0, 1.0) * material.tintColor.a;
200
- let fogged = applySceneFog(view, material.tintColor.rgb, alpha, in.worldPos);
201
- let premult = fogged.rgb * fogged.a;
182
+ let premult = material.tintColor.rgb * alpha;
202
183
  return vec4<f32>(
203
184
  linear_to_srgb(premult.r),
204
185
  linear_to_srgb(premult.g),
205
186
  linear_to_srgb(premult.b),
206
- fogged.a,
187
+ alpha,
207
188
  );
208
189
  }
@@ -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
@@ -49,6 +92,22 @@ fn sample_shadow_2d(
49
92
  normalBias : f32,
50
93
  depthBias : f32,
51
94
  nDotL : f32,
95
+ ) -> f32 {
96
+ return sample_shadow_2d_kernel(
97
+ shadowMap, shadowSampler, uv, texel, depthRef, normalBias, depthBias, nDotL, 3.0,
98
+ );
99
+ }
100
+
101
+ fn sample_shadow_2d_kernel(
102
+ shadowMap : texture_depth_2d,
103
+ shadowSampler: sampler_comparison,
104
+ uv : vec2<f32>,
105
+ texel : vec2<f32>,
106
+ depthRef : f32,
107
+ normalBias : f32,
108
+ depthBias : f32,
109
+ nDotL : f32,
110
+ kernelSize : f32,
52
111
  ) -> f32 {
53
112
  // feat-20260621-merge-directionallightshadow-into-directionallight M3 / m3-t5:
54
113
  // param names aligned to the D-1 directional convention -- the slope
@@ -60,17 +119,22 @@ fn sample_shadow_2d(
60
119
  let bias = max(normalBias * (1.0 - nDotL), depthBias / 1000.0);
61
120
  let adjustedDepth = depthRef - bias;
62
121
 
63
- // 9-tap 3x3 PCF kernel. textureSampleCompareLevel returns 1.0 when
64
- // adjustedDepth < sampledDepth (sampler compare op = 'less'), 0.0 otherwise.
65
- // Sampler uses clamp-to-edge wrap so OOB texels return the nearest border value.
122
+ let halfWidth = clamp((i32(round(kernelSize)) - 1) / 2, 0, 2);
123
+ // Variable odd-kernel PCF. textureSampleCompareLevel returns 1.0 when
124
+ // adjustedDepth < sampledDepth; sampler clamp-to-edge handles OOB texels.
66
125
  var blocked = 0.0;
67
- for (var i = 0u; i < 9u; i++) {
68
- let off = PCF_OFFSETS[i];
69
- let offsetUv = uv + vec2<f32>(f32(off.x), f32(off.y)) * texel;
70
- let lit = textureSampleCompareLevel(shadowMap, shadowSampler, offsetUv, adjustedDepth);
71
- blocked = blocked + (1.0 - lit);
126
+ var samples = 0.0;
127
+ for (var y = -2; y <= 2; y = y + 1) {
128
+ for (var x = -2; x <= 2; x = x + 1) {
129
+ if (abs(x) <= halfWidth && abs(y) <= halfWidth) {
130
+ let offsetUv = uv + vec2<f32>(f32(x), f32(y)) * texel;
131
+ let lit = textureSampleCompareLevel(shadowMap, shadowSampler, offsetUv, adjustedDepth);
132
+ blocked = blocked + (1.0 - lit);
133
+ samples = samples + 1.0;
134
+ }
135
+ }
72
136
  }
73
- return 1.0 - blocked / 9.0;
137
+ return 1.0 - blocked / max(samples, 1.0);
74
138
  }
75
139
 
76
140
  // === Cube point-light-shadow wrapper =========================================
@@ -78,7 +142,7 @@ fn sample_shadow_2d(
78
142
  // `shadowAtlas` is @group(0) @binding(5) texture_depth_cube_array.
79
143
  // `shadowSampler` is @group(0) @binding(4) sampler_comparison (shared with
80
144
  // directional shadows — same sampler type, no dimension distinction).
81
- // `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
82
146
  // coordinate system (Bevy convention: left-handed cubemap; caller applies
83
147
  // flip_z = vec3(1,1,-1) to convert right-hand world to left-hand cubemap).
84
148
  // `layer` is the cube_array layer index (i32; 0..3 for 4 shadow-casting
@@ -117,4 +181,4 @@ fn sample_shadow_cube_hw2x2(
117
181
  // handles face-boundary texel fetches. Returns 1.0 = fully lit, 0.0 = fully
118
182
  // shadowed.
119
183
  return textureSampleCompareLevel(shadowAtlas, shadowSampler, lightLocal, layer, adjustedDepth);
120
- }
184
+ }