@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/common.wgsl CHANGED
@@ -36,6 +36,36 @@ fn linearToSrgbOetf(color : vec3<f32>) -> vec3<f32> {
36
36
  return select(high, low, safe <= vec3<f32>(0.0031308));
37
37
  }
38
38
 
39
+ // Shared final-surface dither. The output transform and FXAA are both
40
+ // display-encoded fullscreen writers, so they must use one owner-level
41
+ // algorithm rather than carrying subtly different copies in each pass.
42
+ // The bounded shift is ±0.5/255 per channel, matching Three.js's optional
43
+ // material dithering amplitude while the screen-space hash remains stable for
44
+ // a still frame. Callers decide whether this writer is the final 8-bit
45
+ // boundary; intermediate float targets must leave it disabled.
46
+ fn hash32(value : u32) -> u32 {
47
+ var hash = value;
48
+ hash = (hash ^ 61u) ^ (hash >> 16u);
49
+ hash = hash + (hash << 3u);
50
+ hash = hash ^ (hash >> 4u);
51
+ hash = hash * 668265261u;
52
+ hash = hash ^ (hash >> 15u);
53
+ return hash;
54
+ }
55
+
56
+ fn ditherNoise(pixelPosition : vec2<f32>) -> f32 {
57
+ let pixel = vec2<u32>(pixelPosition);
58
+ let seed = (pixel.x * 1973u) ^ (pixel.y * 9277u) ^ 89173u;
59
+ return f32(hash32(seed) & 1023u) / 1023.0;
60
+ }
61
+
62
+ fn ditherUnorm8(value : vec3<f32>, pixelPosition : vec2<f32>) -> vec3<f32> {
63
+ let noise = ditherNoise(pixelPosition);
64
+ var shift = vec3<f32>(0.25 / 255.0, -0.25 / 255.0, 0.25 / 255.0);
65
+ shift = mix(2.0 * shift, -2.0 * shift, noise);
66
+ return clamp(value + shift, vec3<f32>(0.0), vec3<f32>(1.0));
67
+ }
68
+
39
69
  // @forgeax/engine-shader - common.wgsl (M5 T-18 feat-20260512-naga-oil-composition-hmr).
40
70
  //
41
71
  // Shared view + mesh structs extracted from pbr.wgsl + unlit.wgsl via
@@ -50,7 +80,7 @@ fn linearToSrgbOetf(color : vec3<f32>) -> vec3<f32> {
50
80
  // aligned with Bevy's bevy_view::common and matches charter proposition 5
51
81
  // (consistent abstraction - one View struct everywhere).
52
82
 
53
- // View UBO byte layout (592 B std140):
83
+ // View UBO byte layout (960 B payload, 1024 B slot):
54
84
  // [ 0.. 64) worldViewProj mat4x4<f32> (align 16, size 64)
55
85
  // [ 64.. 80) lightDir vec3<f32> (align 16, 12+4 pad)
56
86
  // [ 80.. 96) lightColor vec3<f32> (align 16, 12+4 pad)
@@ -68,8 +98,7 @@ fn linearToSrgbOetf(color : vec3<f32>) -> vec3<f32> {
68
98
  // [500..504) cascadeBlend f32 (align 4, size 4)
69
99
  // [504..508) depthBias f32 (align 4, size 4)
70
100
  // [508..512) normalBias f32 (align 4, size 4)
71
- // [512..516) pcfKernelSize f32 (align 4, size 4)
72
- // [516..528) _align_pad — (mat4 array align=16, 12 B)
101
+ // [512..528) directional shadow carrier f32[4] (profile, radius, penumbra, reserved)
73
102
  // [528..784) spotLightViewProj array<mat4x4<f32>,4> (align 16, size 256)
74
103
  // WGSL prefix = 784 B. Host UBO = VIEW_UBO_BYTES = 960 B (renderer-factory.ts);
75
104
  // view-ubo.ts writes the full 240 f32 payload. The spot matrix
@@ -99,14 +128,15 @@ fn linearToSrgbOetf(color : vec3<f32>) -> vec3<f32> {
99
128
  // shadow_caster.wgsl indexes the 4 fields via `shadowCasterCascade.index`
100
129
  // (binding 5, written per shadow pass).
101
130
  //
102
- // feat-20260621-merge-directionallightshadow-into-directionallight M3 / m3-t3:
103
- // the merged DirectionalLight's shadow bias + PCF kernel width append at the
104
- // tail (depthBias / normalBias / pcfKernelSize at bytes 504/508/512, floats
105
- // 126/127/128). Tail-append only -- field order is byte-for-byte stable
131
+ // feat-20260827-directional-csm-pcss-quality M1:
132
+ // the merged DirectionalLight's shadow bias + accepted filter carrier occupy
133
+ // the existing tail (depthBias / normalBias at bytes 504/508, carrier at
134
+ // bytes 512..528, floats 126/127/128..131). Tail reuse only -- field order is
135
+ // byte-for-byte stable
106
136
  // (charter P4); the prior 88 B host tail pad shrinks to 64 B, total stays
107
137
  // View slot remains within the existing 1024 B stride. lighting-directional.wgsl drives the
108
- // directional shadow bias (D-1: bias = max(normalBias*(1-N.L), depthBias)) and
109
- // a pcfKernelSize-wide PCF loop from these fields.
138
+ // directional shadow bias (D-1: bias = max(normalBias*(1-N.L), depthBias));
139
+ // directional shadow carrier is the receiver-facing profile/radius/penumbra POD.
110
140
  //
111
141
  // feat-20260625-spot-light-shadow-mapping w25 (scope-amend webkit-fallback):
112
142
  // the per-spot perspective `spotLightViewProj` matrices (4 lanes, fragment-read)
@@ -122,7 +152,7 @@ fn linearToSrgbOetf(color : vec3<f32>) -> vec3<f32> {
122
152
  // matrix in the same frame (same-frame write contention); the FRAGMENT read
123
153
  // channel has no such contention — the host writes all <=4 spot matrices once
124
154
  // per frame before the forward pass, so the fold is safe. mat4 array align=16:
125
- // the array lands at byte 528 (next 16 B-aligned offset after pcfKernelSize at
155
+ // the array lands at byte 528 (next 16 B-aligned offset after the filter carrier at
126
156
  // byte 512..516), spanning bytes 528..784. The host allocates VIEW_UBO_BYTES =
127
157
  // 960 (renderer-factory.ts) and writes the full 240 f32
128
158
  // payload (render-system-record.ts). Lane N = the spot with `shadowAtlasTile
@@ -142,10 +172,10 @@ struct View {
142
172
  cascadeBlend : f32,
143
173
  depthBias : f32,
144
174
  normalBias : f32,
145
- pcfKernelSize : f32,
175
+ directionalShadowFilter : vec4<f32>,
146
176
  // feat-20260625-spot-light-shadow-mapping w25: per-spot perspective
147
177
  // light-view-projection matrices, fragment-read. Auto-aligned to byte 528
148
- // (mat4 array align=16, after pcfKernelSize at byte 512); spans bytes
178
+ // (mat4 array align=16, after the four directional carrier lanes at byte 512); spans bytes
149
179
  // 528..784. Lane N = spot with shadowAtlasTile === N (cap = 4). Zeroed lanes
150
180
  // are safe (sample gated on shadowAtlasTile >= 0 in default-standard-pbr.wgsl).
151
181
  spotLightViewProj : array<mat4x4<f32>, 4>,
@@ -1,18 +1,19 @@
1
- #import forgeax_view::common::{View, Mesh, InstanceData, view, meshes, instances, PointLight, SpotLight, pointLightsBuffer, spotLightsBuffer, shadowMap, shadowSampler, sampleMaterialTexture}
1
+ #import forgeax_view::common::{View, Mesh, InstanceData, view, meshes, instances, sampleMaterialTexture}
2
+ #import forgeax_scene_temporal::{sceneViewZ}
2
3
  #import forgeax_pbr::temporal::{projectPbrSceneTemporal}
3
4
  #import forgeax_pbr::brdf::{f_schlick, v_smith, d_ggx}
4
5
  #import forgeax_pbr::ibl_sampling::{sampleIblDiffuse, sampleIblSpecular}
5
6
  #import forgeax_pbr::tbn::{decodeTangentSpaceNormalRg, scaleTangentSpaceNormal, applyTBN}
6
7
  #import forgeax_pbr::lighting_directional::{evalDirectionalNoShadow, evalDirectionalShadowFactor}
7
- #import forgeax_pbr::lighting_punctual::{evalPoint, evalSpot}
8
- #ifdef POINT_SHADOW_AVAILABLE
9
- #import forgeax_pbr::lighting_punctual::{evalPointShadowed}
10
- #import forgeax_view::common::{shadowParams}
8
+ #ifdef CLUSTER_FORWARD_AVAILABLE
9
+ #import forgeax_standard::cluster::{evaluateStandardClusterLights}
11
10
  #endif
12
11
 
13
12
  #define_import_path forgeax_material::pbr-skin
14
13
  #pragma variant_axis STORAGE_BUFFER_AVAILABLE
14
+ #pragma variant_axis CLUSTER_FORWARD_AVAILABLE
15
15
  #pragma variant_axis VERTEX_COLOR_AVAILABLE
16
+ #pragma variant_axis DIRECTIONAL_PCSS_AVAILABLE
16
17
 
17
18
  // @forgeax/engine-shader - default-standard-pbr-skin.wgsl
18
19
  // (feat-20260523-skin-skeleton-animation M3 / T-29).
@@ -204,7 +205,6 @@ struct VsOut {
204
205
  @location(1) worldNormal : vec3<f32>,
205
206
  @location(2) uv : vec2<f32>,
206
207
  @location(3) worldTangent : vec4<f32>,
207
- @location(4) @interpolate(flat) instanceIdx : u32,
208
208
  // feat-city-glb multi-UV tiling: second UV set varying at location 5
209
209
  // (parity with default-standard-pbr.wgsl).
210
210
  @location(5) uv1 : vec2<f32>,
@@ -217,6 +217,9 @@ struct VsOut {
217
217
  #ifdef VERTEX_COLOR_AVAILABLE
218
218
  @location(14) color : vec4<f32>,
219
219
  #endif
220
+ // Vertex-produced Standard cluster coordinates. Fragment builtin position
221
+ // is framebuffer-space and cannot recover the original clip-space NDC.
222
+ @location(6) ndc : vec3<f32>,
220
223
  @location(7) viewZ : f32,
221
224
  };
222
225
 
@@ -298,11 +301,14 @@ fn vs_main(in : VsIn, @builtin(instance_index) idx : u32) -> VsOut {
298
301
  #ifdef VERTEX_COLOR_AVAILABLE
299
302
  out.color = in.color;
300
303
  #endif
301
- out.instanceIdx = idx;
302
304
  // feat-20260613-csm-cascaded-shadow-maps M5 / w19: viewZ replaces the
303
305
  // prior light-space-position varying; evalDirectional picks the cascade
304
306
  // matrix per fragment from viewZ + worldPos.
305
- out.viewZ = -out.clip.w;
307
+ let clipPos = out.clip;
308
+ out.ndc = vec3(clipPos.xy / clipPos.w, clipPos.z / clipPos.w);
309
+ // Keep the cluster depth exactly aligned with the CPU binner for both
310
+ // perspective and off-axis orthographic projections.
311
+ out.viewZ = sceneViewZ(out.clip, view.temporalProjection);
306
312
  return out;
307
313
  }
308
314
 
@@ -397,65 +403,17 @@ fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
397
403
  );
398
404
  color = color + directionalShadow * material.clearcoat * directionalClearcoat;
399
405
  }
400
- let pointCount = pointLightsBuffer.count;
401
- for (var i: u32 = 0u; i < pointCount; i = i + 1u) {
402
- let p = pointLightsBuffer.slots[i];
403
- #ifdef POINT_SHADOW_AVAILABLE
404
- if (p.shadowAtlasLayer >= 0) {
405
- let lane = shadowParams[p.shadowAtlasLayer];
406
- color = color + evalPointShadowed(
407
- p.position, p.colorTimesIntensity, p.invRangeSquared,
408
- in.worldPos, n, v, albedo, metallic, a, f0,
409
- p.shadowAtlasLayer, lane.x, lane.y, 0.005, 0.05,
410
- );
411
- if (material.clearcoat != 0.0) {
412
- color = color + material.clearcoat * evalPointShadowed(
413
- p.position, p.colorTimesIntensity, p.invRangeSquared,
414
- in.worldPos, n, v, vec3<f32>(0.0), 1.0, coatAlpha, vec3<f32>(0.04),
415
- p.shadowAtlasLayer, lane.x, lane.y, 0.005, 0.05,
416
- );
417
- }
418
- } else {
419
- color = color + evalPoint(
420
- p.position, p.colorTimesIntensity, p.invRangeSquared,
421
- in.worldPos, n, v, albedo, metallic, a, f0,
422
- );
423
- if (material.clearcoat != 0.0) {
424
- color = color + material.clearcoat * evalPoint(
425
- p.position, p.colorTimesIntensity, p.invRangeSquared,
426
- in.worldPos, n, v, vec3<f32>(0.0), 1.0, coatAlpha, vec3<f32>(0.04),
427
- );
428
- }
429
- }
430
- #else
431
- color = color + evalPoint(
432
- p.position, p.colorTimesIntensity, p.invRangeSquared,
433
- in.worldPos, n, v, albedo, metallic, a, f0,
434
- );
435
- if (material.clearcoat != 0.0) {
436
- color = color + material.clearcoat * evalPoint(
437
- p.position, p.colorTimesIntensity, p.invRangeSquared,
438
- in.worldPos, n, v, vec3<f32>(0.0), 1.0, coatAlpha, vec3<f32>(0.04),
439
- );
440
- }
441
- #endif
442
- }
443
- let spotCount = spotLightsBuffer.count;
444
- for (var i: u32 = 0u; i < spotCount; i = i + 1u) {
445
- let s = spotLightsBuffer.slots[i];
446
- color = color + evalSpot(
447
- s.position, s.direction, s.colorTimesIntensity,
448
- s.cosInner, s.cosOuter, s.invRangeSquared,
449
- in.worldPos, n, v, albedo, metallic, a, f0,
406
+ #ifdef CLUSTER_FORWARD_AVAILABLE
407
+ color = color + evaluateStandardClusterLights(
408
+ in.ndc, in.viewZ, in.worldPos, n, v, albedo, metallic, a, f0, false,
409
+ );
410
+ if (material.clearcoat != 0.0) {
411
+ color = color + material.clearcoat * evaluateStandardClusterLights(
412
+ in.ndc, in.viewZ, in.worldPos, n, v,
413
+ vec3<f32>(0.0), 1.0, coatAlpha, vec3<f32>(0.04), false,
450
414
  );
451
- if (material.clearcoat != 0.0) {
452
- color = color + material.clearcoat * evalSpot(
453
- s.position, s.direction, s.colorTimesIntensity,
454
- s.cosInner, s.cosOuter, s.invRangeSquared,
455
- in.worldPos, n, v, vec3<f32>(0.0), 1.0, coatAlpha, vec3<f32>(0.04),
456
- );
457
- }
458
415
  }
416
+ #endif
459
417
  return vec4<f32>(color, alpha);
460
418
  }
461
419
 
@@ -471,6 +429,7 @@ struct TemporalVsOut {
471
429
  @location(7) uv7 : vec2<f32>,
472
430
  @location(8) @interpolate(perspective) currentClip : vec4<f32>,
473
431
  @location(9) @interpolate(perspective) previousClip : vec4<f32>,
432
+ @location(10) @interpolate(flat) reactive : f32,
474
433
  #ifdef VERTEX_COLOR_AVAILABLE
475
434
  @location(14) color : vec4<f32>,
476
435
  #endif
@@ -492,6 +451,11 @@ fn vs_temporal(in : VsIn, @builtin(instance_index) idx : u32) -> TemporalVsOut {
492
451
  out.currentClip = view.temporalCurrentViewProj * currentWorld;
493
452
  out.clip = out.currentClip;
494
453
  out.previousClip = view.temporalPreviousViewProj * previousWorld;
454
+ #if STORAGE_BUFFER_AVAILABLE == true
455
+ out.reactive = meshes[0].temporal.x;
456
+ #else
457
+ out.reactive = 1.0;
458
+ #endif
495
459
  out.uv = in.uv;
496
460
  out.uv1 = in.uv1;
497
461
  out.uv2 = in.uv2;
@@ -517,11 +481,6 @@ fn temporalVertexAlpha(in : TemporalVsOut) -> f32 {
517
481
 
518
482
  @fragment
519
483
  fn fs_temporal(in : TemporalVsOut) -> @location(0) vec4<f32> {
520
- #if STORAGE_BUFFER_AVAILABLE == true
521
- let reactive = meshes[0].temporal.x;
522
- #else
523
- let reactive = 1.0;
524
- #endif
525
484
  return projectPbrSceneTemporal(
526
485
  material.baseColor.a * temporalVertexAlpha(in),
527
486
  material.alphaCutoff,
@@ -532,7 +491,7 @@ fn fs_temporal(in : TemporalVsOut) -> @location(0) vec4<f32> {
532
491
  in.currentClip,
533
492
  in.previousClip,
534
493
  view.temporalProjection,
535
- reactive,
494
+ in.reactive,
536
495
  in.uv, in.uv1, in.uv2, in.uv3,
537
496
  in.uv4, in.uv5, in.uv6, in.uv7,
538
497
  );
@@ -1,46 +1,34 @@
1
1
  #define_import_path forgeax_material::standard
2
- #import forgeax_view::common::{View, Mesh, InstanceData, view, meshes, instances, PointLight, SpotLight, pointLightsBuffer, spotLightsBuffer, shadowMap, shadowSampler, sampleMaterialTexture}
3
- #ifdef PROJECTOR_AVAILABLE
4
- #import forgeax_view::common::{projectorTexture, projectorSampler}
5
- #endif
2
+ #import forgeax_view::common::{View, Mesh, InstanceData, view, meshes, instances, sampleMaterialTexture}
3
+ #import forgeax_scene_temporal::{sceneViewZ}
6
4
  #import forgeax_pbr::temporal::{projectPbrSceneTemporal}
7
5
  #import forgeax_pbr::brdf::{f_schlick, v_smith, d_ggx}
8
- #import forgeax_pbr::ibl_sampling::{sampleIblDiffuse, sampleIblSpecular}
6
+ #import forgeax_pbr::ibl_sampling::{box_project, sampleIblDiffuse, sampleIblSpecular, sampleReflectionProbeSpecular}
9
7
  #import forgeax_pbr::tbn::{decodeTangentSpaceNormalRg, scaleTangentSpaceNormal, applyTBN}
10
8
  #import forgeax_pbr::lighting_directional::{evalDirectionalNoShadow, evalDirectionalShadowFactor}
11
- #import forgeax_pbr::lighting_punctual::{evalPoint, evalSpot, evalSpotShadowed, projectSpotUv}
12
- #ifdef POINT_SHADOW_AVAILABLE
13
- #import forgeax_pbr::lighting_punctual::{evalPointShadowed}
14
- #import forgeax_view::common::{shadowParams}
15
- #endif
16
-
17
- fn sampleSpotProjector(lightViewProj : mat4x4<f32>, worldPos : vec3<f32>) -> vec3<f32> {
18
- #ifdef PROJECTOR_AVAILABLE
19
- let uv = projectSpotUv(lightViewProj, worldPos);
20
- if (!(uv.x >= 0.0 && uv.x <= 1.0 && uv.y >= 0.0 && uv.y <= 1.0)) {
21
- // Three's SpotLight.map is a modulation inside the projected light
22
- // frustum, not a second cone mask. Outside the map the original spot
23
- // radiance remains intact; the cone/visibility terms already own whether
24
- // the light reaches the fragment.
25
- return vec3<f32>(1.0);
26
- }
27
- return textureSampleLevel(projectorTexture, projectorSampler, uv, 0.0).rgb;
28
- #else
29
- // Keep the analytic SpotLight contribution intact on adapters whose
30
- // fragment-stage sampled-texture budget cannot carry the optional cookie.
31
- return vec3<f32>(1.0);
32
- #endif
33
- }
34
9
  #ifdef CLUSTER_FORWARD_AVAILABLE
35
- #import forgeax_hdrp::cluster_forward::{evaluate_cluster_lights, get_ssao_intensity}
10
+ #import forgeax_standard::cluster::{evaluateStandardClusterLights, get_ssao_intensity}
36
11
  #endif
37
12
 
38
13
  #pragma variant_axis STORAGE_BUFFER_AVAILABLE
39
14
  #pragma variant_axis CLUSTER_FORWARD_AVAILABLE
40
15
  #pragma variant_axis VERTEX_COLOR_AVAILABLE
41
16
  #pragma variant_axis TRANSMISSION_AVAILABLE
17
+ #pragma variant_axis DIRECTIONAL_PCSS_AVAILABLE
42
18
  #pragma variant_axis PROJECTOR_AVAILABLE
43
19
 
20
+ // Reflection probes remain a bounded scene-projected sampling lane. Their
21
+ // optional texture resources are the same Skylight inputs; when no probe is
22
+ // admitted, the explicit Skylight path below remains authoritative.
23
+ fn reflection_probe_sampling_contract(
24
+ worldPosition: vec3<f32>,
25
+ direction: vec3<f32>,
26
+ boxCenter: vec3<f32>,
27
+ boxExtents: vec3<f32>,
28
+ ) -> vec3<f32> {
29
+ return box_project(worldPosition, direction, boxCenter, boxExtents);
30
+ }
31
+
44
32
  // @forgeax/engine-shader - default-standard-pbr.wgsl
45
33
  // (feat-20260523-shader-template-instance-split M5 / T04).
46
34
  //
@@ -274,7 +262,11 @@ struct VsOut {
274
262
  @location(1) worldNormal : vec3<f32>,
275
263
  @location(2) uv : vec2<f32>,
276
264
  @location(3) worldTangent : vec4<f32>,
277
- @location(4) @interpolate(flat) instanceIdx : u32,
265
+ #ifdef TRANSMISSION_AVAILABLE
266
+ // Keep mesh and instance storage vertex-only; transmission receives the
267
+ // transform basis from the vertex stage below.
268
+ @location(4) @interpolate(flat) transmissionBasis0 : vec4<f32>,
269
+ #endif
278
270
  // feat-city-glb multi-UV tiling: second UV set inter-stage varying. Uses the
279
271
  // previously-vacant @location(5) so @location(6)/(7) stay byte-stable with
280
272
  // the prior layout (CSM M5/w19).
@@ -288,7 +280,7 @@ struct VsOut {
288
280
  #ifdef VERTEX_COLOR_AVAILABLE
289
281
  @location(14) color : vec4<f32>,
290
282
  #endif
291
- @location(6) ndc : vec3<f32>, // NDC for HDRP cluster lookup (w10)
283
+ @location(6) ndc : vec4<f32>, // NDC for HDRP cluster lookup; .w = transmission basis tail
292
284
  // feat-20260609-hdrp-cluster-fragment-ggx M4.5-followup: view-space z is
293
285
  // needed by ndc_position_to_cluster (slice index uses log-z mapping that
294
286
  // takes negative view_z, NOT NDC z which is [0,1]). Earlier `in.ndc.z`
@@ -297,6 +289,9 @@ struct VsOut {
297
289
  // hot zone -- received zero light. Forward view_z explicitly. M5 / w19:
298
290
  // also feeds CSM cascade selection in evalDirectional.
299
291
  @location(7) viewZ : f32,
292
+ #ifdef TRANSMISSION_AVAILABLE
293
+ @location(15) @interpolate(flat) transmissionBasis1 : vec4<f32>,
294
+ #endif
300
295
  };
301
296
 
302
297
  fn pick_channel(rgba : vec4<f32>, channelIndex : u32) -> f32 {
@@ -358,6 +353,20 @@ fn vs_main(in : VsIn, @builtin(instance_index) idx : u32) -> VsOut {
358
353
  // (w=0); .w handedness preserved for bitangent reconstruction in fragment.
359
354
  let worldTangentXyz = normalize((entityWorld * instanceLocal * vec4<f32>(in.tangent.xyz, 0.0)).xyz);
360
355
  out.worldTangent = vec4<f32>(worldTangentXyz, in.tangent.w);
356
+ #ifdef TRANSMISSION_AVAILABLE
357
+ out.transmissionBasis0 = vec4<f32>(
358
+ localToWorld[0].x,
359
+ localToWorld[0].y,
360
+ localToWorld[0].z,
361
+ localToWorld[1].x,
362
+ );
363
+ out.transmissionBasis1 = vec4<f32>(
364
+ localToWorld[1].y,
365
+ localToWorld[1].z,
366
+ localToWorld[2].x,
367
+ localToWorld[2].y,
368
+ );
369
+ #endif
361
370
  out.uv = in.uv;
362
371
  out.uv1 = in.uv1;
363
372
  out.uv2 = in.uv2;
@@ -369,7 +378,6 @@ fn vs_main(in : VsIn, @builtin(instance_index) idx : u32) -> VsOut {
369
378
  #ifdef VERTEX_COLOR_AVAILABLE
370
379
  out.color = in.color;
371
380
  #endif
372
- out.instanceIdx = idx;
373
381
  // feat-20260613-csm-cascaded-shadow-maps M5 / w19: the per-fragment
374
382
  // light-space position varying is gone; evalDirectional computes
375
383
  // per-cascade lightViewProj * worldPos in the fragment stage from
@@ -377,12 +385,14 @@ fn vs_main(in : VsIn, @builtin(instance_index) idx : u32) -> VsOut {
377
385
  // NDC for HDRP cluster lookup (feat-20260609-hdrp-cluster-fragment-ggx M2 / w10).
378
386
  // Perspective divide on clip-space position; ndc.z retains depth-buffer value.
379
387
  let clipPos = out.clip;
380
- out.ndc = vec3(clipPos.xy / clipPos.w, clipPos.z / clipPos.w);
388
+ out.ndc = vec4(clipPos.xy / clipPos.w, clipPos.z / clipPos.w, localToWorld[2].z);
381
389
  // Standard WebGPU/GL perspective projection has clip.w = -view.z, so
382
390
  // view_z = -clip.w (negative in front of the camera). M4.5-followup:
383
391
  // ndc_position_to_cluster needs this for log-z slice mapping; passing
384
392
  // ndc.z (in [0,1]) instead made every fragment land in slice 0.
385
- out.viewZ = -clipPos.w;
393
+ // Keep the cluster depth exactly aligned with the CPU binner for both
394
+ // perspective and off-axis orthographic projections.
395
+ out.viewZ = sceneViewZ(clipPos, view.temporalProjection);
386
396
  return out;
387
397
  }
388
398
 
@@ -587,12 +597,26 @@ fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
587
597
  // shadow-map PCF lookup (CSM, feat-20260613).
588
598
  let kD = (vec3<f32>(1.0) - f_schlick(max(dot(n, v), 0.0), f0)) * (1.0 - metallic);
589
599
  let iblRoughness = clamp(max(finiteScalar(material.roughness, 0.5), 0.04) * roughnessTex, 0.04, 1.0);
590
- let irradiance = sampleIblDiffuse(n, skylight.rotation, irradianceMap, irradianceSampler);
591
- let specularIbl = sampleIblSpecular(
592
- n, v, iblRoughness, f0,
593
- skylight.rotation,
594
- prefilterMap, prefilterSampler, brdfLut, brdfLutSampler,
595
- );
600
+ var irradiance = vec3<f32>(0.0);
601
+ var specularIbl = vec3<f32>(0.0);
602
+ if (skylight.intensity < 0.0) {
603
+ // A negative intensity is the renderer-owned probe sentinel. The four
604
+ // color/rotation lanes carry probe center and half extents in this mode;
605
+ // Skylight resources remain unchanged and therefore preserve the ABI.
606
+ specularIbl = sampleReflectionProbeSpecular(
607
+ n, v, iblRoughness, f0, in.worldPos,
608
+ vec3<f32>(skylight.colorR, skylight.colorG, skylight.colorB),
609
+ skylight.rotation.xyz, vec4<f32>(0.0, 0.0, 0.0, 1.0),
610
+ prefilterMap, prefilterSampler, brdfLut, brdfLutSampler,
611
+ );
612
+ } else {
613
+ irradiance = sampleIblDiffuse(n, skylight.rotation, irradianceMap, irradianceSampler);
614
+ specularIbl = sampleIblSpecular(
615
+ n, v, iblRoughness, f0,
616
+ skylight.rotation,
617
+ prefilterMap, prefilterSampler, brdfLut, brdfLutSampler,
618
+ );
619
+ }
596
620
  let occlusionUv = transformedMaterialUv(material.occlusionTextureCoordinatesTransform, material.occlusionTextureCoordinatesMetadata, in);
597
621
  let aoSample = sampleMaterialTexture(occlusionTexture, occlusionSampler, occlusionUv, material.occlusionTextureCoordinatesMetadata.zw);
598
622
  let ao = mix(1.0, aoSample.r, material.occlusionStrength);
@@ -609,7 +633,11 @@ fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
609
633
  );
610
634
  ambient = ambient + clearcoatIbl * material.clearcoat;
611
635
  }
612
- ambient = ambient * skyColor * skylight.intensity * ao;
636
+ if (skylight.intensity < 0.0) {
637
+ ambient = ambient * (-skylight.intensity) * ao;
638
+ } else {
639
+ ambient = ambient * skyColor * skylight.intensity * ao;
640
+ }
613
641
  #ifdef CLUSTER_FORWARD_AVAILABLE
614
642
  // feat-20260612-hdrp-ssao M2 round-1 + M7 round-2 (plan-strategy D-7 + D-B + D-C):
615
643
  // SSAO ambient synthesis. Reads the half-res R8 `ssaoBlurredTexture` from
@@ -636,10 +664,17 @@ fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
636
664
  // the fragment stage preserves that contract and keeps thickness in the
637
665
  // glTF unit-scale mesh space before measuring the refracted segment in world
638
666
  // metres.
639
- let localToWorld = meshes[0].worldFromLocal * instances[in.instanceIdx].localFromInstance;
640
- let localToWorld0 = localToWorld[0].xyz;
641
- let localToWorld1 = localToWorld[1].xyz;
642
- let localToWorld2 = localToWorld[2].xyz;
667
+ let localToWorld0 = in.transmissionBasis0.xyz;
668
+ let localToWorld1 = vec3<f32>(
669
+ in.transmissionBasis0.w,
670
+ in.transmissionBasis1.x,
671
+ in.transmissionBasis1.y,
672
+ );
673
+ let localToWorld2 = vec3<f32>(
674
+ in.transmissionBasis1.z,
675
+ in.transmissionBasis1.w,
676
+ in.ndc.w,
677
+ );
643
678
  let inverseCofactor0 = cross(localToWorld1, localToWorld2);
644
679
  let inverseCofactor1 = cross(localToWorld2, localToWorld0);
645
680
  let inverseCofactor2 = cross(localToWorld0, localToWorld1);
@@ -723,121 +758,13 @@ fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
723
758
  #ifdef CLUSTER_FORWARD_AVAILABLE
724
759
  // NDC from vertex shader (perspective-divided clip-space, interpolated).
725
760
  // view_z: NDC depth for cluster Z-slice lookup.
726
- color = color + evaluate_cluster_lights(in.ndc, in.viewZ, in.worldPos, n, v, diffuseAlbedo, metallic, a, f0);
761
+ color = color + evaluateStandardClusterLights(
762
+ in.ndc.xyz, in.viewZ, in.worldPos, n, v, diffuseAlbedo, metallic, a, f0, false,
763
+ );
727
764
  if (material.clearcoat != 0.0) {
728
- color = color + material.clearcoat * evaluate_cluster_lights(
729
- in.ndc, in.viewZ, in.worldPos, n, v, vec3<f32>(0.0), 1.0, coatAlpha, vec3<f32>(0.04),
730
- );
731
- }
732
- #else
733
- let pointCount = pointLightsBuffer.count;
734
- for (var i: u32 = 0u; i < pointCount; i = i + 1u) {
735
- let p = pointLightsBuffer.slots[i];
736
- #ifdef POINT_SHADOW_AVAILABLE
737
- // feat-20260612-point-light-shadows-urp-hdrp Round-2 F-1: gate between
738
- // shadowed and unshadowed evaluation on the host-assigned
739
- // shadowAtlasLayer (>= 0 means PointLightShadow companion exists; -1
740
- // sentinel means no shadow). shadowParams[layer] = (near, far,
741
- // 1/(far-near), 0) is bound at @group(0) @binding(6) (shadowParams
742
- // declared in common.wgsl).
743
- if (p.shadowAtlasLayer >= 0) {
744
- let lane = shadowParams[p.shadowAtlasLayer];
745
- color = color + evalPointShadowed(
746
- p.position, p.colorTimesIntensity, p.invRangeSquared,
747
- in.worldPos, n, v, diffuseAlbedo, metallic, a, f0,
748
- p.shadowAtlasLayer, lane.x, lane.y, 0.005, 0.05,
749
- );
750
- if (material.clearcoat != 0.0) {
751
- color = color + material.clearcoat * evalPointShadowed(
752
- p.position, p.colorTimesIntensity, p.invRangeSquared,
753
- in.worldPos, n, v, vec3<f32>(0.0), 1.0, coatAlpha, vec3<f32>(0.04),
754
- p.shadowAtlasLayer, lane.x, lane.y, 0.005, 0.05,
755
- );
756
- }
757
- } else {
758
- color = color + evalPoint(
759
- p.position, p.colorTimesIntensity, p.invRangeSquared,
760
- in.worldPos, n, v, diffuseAlbedo, metallic, a, f0,
761
- );
762
- if (material.clearcoat != 0.0) {
763
- color = color + material.clearcoat * evalPoint(
764
- p.position, p.colorTimesIntensity, p.invRangeSquared,
765
- in.worldPos, n, v, vec3<f32>(0.0), 1.0, coatAlpha, vec3<f32>(0.04),
766
- );
767
- }
768
- }
769
- #else
770
- color = color + evalPoint(
771
- p.position, p.colorTimesIntensity, p.invRangeSquared,
772
- in.worldPos, n, v, diffuseAlbedo, metallic, a, f0,
773
- );
774
- if (material.clearcoat != 0.0) {
775
- color = color + material.clearcoat * evalPoint(
776
- p.position, p.colorTimesIntensity, p.invRangeSquared,
777
- in.worldPos, n, v, vec3<f32>(0.0), 1.0, coatAlpha, vec3<f32>(0.04),
778
- );
779
- }
780
- #endif
781
- }
782
- let spotCount = spotLightsBuffer.count;
783
- // Surface and volume consume the same projectSpotUv / projectorRevision
784
- // tuple; an accepted projector is sampled with textureSampleLevel.
785
- const PROJECTOR_ONLY_TILE : i32 = -2;
786
- for (var i: u32 = 0u; i < spotCount; i = i + 1u) {
787
- let s = spotLightsBuffer.slots[i];
788
- // feat-20260625-spot-light-shadow-mapping M3 / w15 (plan-strategy D-1
789
- // fragment side + D-3 + D-4): gate on the host-assigned shadowAtlasTile
790
- // (>= 0 means a spot atlas tile was allocated; -1 sentinel means no
791
- // shadow / clipped / direction-degenerate). The shadowed path reads the
792
- // per-spot perspective matrix from the View UBO `view.spotLightViewProj`
793
- // array (lane N = shadowAtlasTile N; folded from the standalone binding 9
794
- // into the View UBO in feat-20260625 w25 for WebGL2 uniform-buffer budget);
795
- // the unshadowed path stays on evalSpot. Receiver controls come from the
796
- // same packed SpotLight snapshot used by the volume path.
797
- if (s.shadowAtlasTile >= 0) {
798
- let projector = sampleSpotProjector(view.spotLightViewProj[s.shadowAtlasTile], in.worldPos);
799
- color = color + evalSpotShadowed(
800
- s.position, s.direction, s.colorTimesIntensity,
801
- s.cosInner, s.cosOuter, s.invRangeSquared,
802
- in.worldPos, n, v, albedo, metallic, a, f0,
803
- view.spotLightViewProj[s.shadowAtlasTile], s.shadowAtlasTile, s.depthBias, s.normalBias, s.pcfKernelSize, s.shadowIntensity,
804
- ) * projector;
805
- if (material.clearcoat != 0.0) {
806
- color = color + material.clearcoat * evalSpotShadowed(
807
- s.position, s.direction, s.colorTimesIntensity,
808
- s.cosInner, s.cosOuter, s.invRangeSquared,
809
- in.worldPos, n, v, vec3<f32>(0.0), 1.0, coatAlpha, vec3<f32>(0.04),
810
- view.spotLightViewProj[s.shadowAtlasTile], s.shadowAtlasTile, s.depthBias, s.normalBias, s.pcfKernelSize, s.shadowIntensity,
811
- ) * projector;
812
- }
813
- } else if (s.shadowAtlasTile == PROJECTOR_ONLY_TILE) {
814
- let projector = sampleSpotProjector(view.spotLightViewProj[0], in.worldPos);
815
- color = color + evalSpot(
816
- s.position, s.direction, s.colorTimesIntensity,
817
- s.cosInner, s.cosOuter, s.invRangeSquared,
818
- in.worldPos, n, v, albedo, metallic, a, f0,
819
- ) * projector;
820
- if (material.clearcoat != 0.0) {
821
- color = color + material.clearcoat * evalSpot(
822
- s.position, s.direction, s.colorTimesIntensity,
823
- s.cosInner, s.cosOuter, s.invRangeSquared,
824
- in.worldPos, n, v, vec3<f32>(0.0), 1.0, coatAlpha, vec3<f32>(0.04),
825
- ) * projector;
826
- }
827
- } else {
828
- color = color + evalSpot(
829
- s.position, s.direction, s.colorTimesIntensity,
830
- s.cosInner, s.cosOuter, s.invRangeSquared,
831
- in.worldPos, n, v, diffuseAlbedo, metallic, a, f0,
765
+ color = color + material.clearcoat * evaluateStandardClusterLights(
766
+ in.ndc.xyz, in.viewZ, in.worldPos, n, v, vec3<f32>(0.0), 1.0, coatAlpha, vec3<f32>(0.04), false,
832
767
  );
833
- if (material.clearcoat != 0.0) {
834
- color = color + material.clearcoat * evalSpot(
835
- s.position, s.direction, s.colorTimesIntensity,
836
- s.cosInner, s.cosOuter, s.invRangeSquared,
837
- in.worldPos, n, v, vec3<f32>(0.0), 1.0, coatAlpha, vec3<f32>(0.04),
838
- );
839
- }
840
- }
841
768
  }
842
769
  #endif // CLUSTER_FORWARD_AVAILABLE
843
770
  let emissiveUv = transformedMaterialUv(material.emissiveTextureCoordinatesTransform, material.emissiveTextureCoordinatesMetadata, in);
@@ -931,6 +858,7 @@ struct TemporalVsOut {
931
858
  @location(7) uv7 : vec2<f32>,
932
859
  @location(8) @interpolate(perspective) currentClip : vec4<f32>,
933
860
  @location(9) @interpolate(perspective) previousClip : vec4<f32>,
861
+ @location(10) @interpolate(flat) reactive : f32,
934
862
  #ifdef VERTEX_COLOR_AVAILABLE
935
863
  @location(14) color : vec4<f32>,
936
864
  #endif
@@ -949,6 +877,11 @@ fn vs_temporal(in : VsIn, @builtin(instance_index) idx : u32) -> TemporalVsOut {
949
877
  out.currentClip = view.temporalCurrentViewProj * currentWorld;
950
878
  out.clip = out.currentClip;
951
879
  out.previousClip = view.temporalPreviousViewProj * previousWorld;
880
+ #if STORAGE_BUFFER_AVAILABLE == true
881
+ out.reactive = meshes[0].temporal.x;
882
+ #else
883
+ out.reactive = 1.0;
884
+ #endif
952
885
  out.uv = in.uv;
953
886
  out.uv1 = in.uv1;
954
887
  out.uv2 = in.uv2;
@@ -973,10 +906,6 @@ fn temporalVertexAlpha(in : TemporalVsOut) -> f32 {
973
906
 
974
907
  @fragment
975
908
  fn fs_temporal(in : TemporalVsOut) -> @location(0) vec4<f32> {
976
- var reactive = 0.0;
977
- #if STORAGE_BUFFER_AVAILABLE == true
978
- reactive = meshes[0].temporal.x;
979
- #endif
980
909
  return projectPbrSceneTemporal(
981
910
  material.baseColor.a * temporalVertexAlpha(in),
982
911
  material.alphaCutoff,
@@ -987,7 +916,7 @@ fn fs_temporal(in : TemporalVsOut) -> @location(0) vec4<f32> {
987
916
  in.currentClip,
988
917
  in.previousClip,
989
918
  view.temporalProjection,
990
- reactive,
919
+ in.reactive,
991
920
  in.uv, in.uv1, in.uv2, in.uv3,
992
921
  in.uv4, in.uv5, in.uv6, in.uv7,
993
922
  );