@forgeax/engine-shader 0.1.20 → 0.1.23

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 (74) hide show
  1. package/README.md +10 -0
  2. package/dist/ShaderRegistry.d.ts.map +1 -1
  3. package/dist/index.d.ts +6 -2
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.mjs +33070 -8
  6. package/dist/index.mjs.map +1 -1
  7. package/dist/ltc/provenance.d.ts +17 -0
  8. package/dist/ltc/provenance.d.ts.map +1 -0
  9. package/dist/ltc/tables.d.ts +22 -0
  10. package/dist/ltc/tables.d.ts.map +1 -0
  11. package/dist/material/artifact-types.d.ts +52 -0
  12. package/dist/material/artifact-types.d.ts.map +1 -1
  13. package/dist/material-schemas.d.ts +5 -0
  14. package/dist/material-schemas.d.ts.map +1 -1
  15. package/dist/register-default-sprite-lit.d.ts +3 -6
  16. package/dist/register-default-sprite-lit.d.ts.map +1 -1
  17. package/dist/types.d.ts +2 -1
  18. package/dist/types.d.ts.map +1 -1
  19. package/package.json +4 -4
  20. package/src/ShaderRegistry.ts +65 -0
  21. package/src/__tests__/bloom-fxaa-tonemap.unit.test.ts +30 -4
  22. package/src/__tests__/default-standard-pbr-alpha.unit.test.ts +7 -0
  23. package/src/__tests__/default-standard-pbr-transmission.unit.test.ts +138 -30
  24. package/src/__tests__/direct-light-layout.unit.test.ts +40 -0
  25. package/src/__tests__/ibl-irradiance.unit.test.ts +7 -4
  26. package/src/__tests__/ibl-sampling.unit.test.ts +3 -4
  27. package/src/__tests__/lighting-point-spot-volume.unit.test.ts +16 -2
  28. package/src/__tests__/lighting-punctual.unit.test.ts +61 -8
  29. package/src/__tests__/lighting-spot-volume-attenuation.unit.test.ts +1 -1
  30. package/src/__tests__/ltc-provenance.unit.test.ts +33 -0
  31. package/src/__tests__/manifest.fixture.json +33 -0
  32. package/src/__tests__/material-contract.unit.test.ts +61 -1
  33. package/src/__tests__/material-derived-builtins.integration.test.ts +27 -0
  34. package/src/__tests__/probe-lighting-composition.unit.test.ts +38 -0
  35. package/src/__tests__/rect-area-brdf.unit.test.ts +36 -0
  36. package/src/__tests__/reflection-probe-sampling.unit.test.ts +45 -0
  37. package/src/__tests__/scene-temporal.unit.test.ts +20 -0
  38. package/src/__tests__/shader.unit.test.ts +40 -15
  39. package/src/__tests__/shadow-pcss.unit.test.ts +131 -0
  40. package/src/__tests__/spot-cookie-composition.unit.test.ts +57 -0
  41. package/src/__tests__/spot-modifier-composition.unit.test.ts +19 -0
  42. package/src/__tests__/sprite-lit-shader.test.ts +24 -11
  43. package/src/__tests__/sprite-variants.unit.test.ts +5 -1
  44. package/src/__tests__/transmission-thickness-scale.unit.test.ts +7 -1
  45. package/src/__tests__/transparent-pbr.unit.test.ts +7 -0
  46. package/src/brdf.wgsl +115 -1
  47. package/src/common.wgsl +95 -91
  48. package/src/default-standard-pbr-skin.wgsl +108 -92
  49. package/src/default-standard-pbr.wgsl +189 -191
  50. package/src/fxaa.wgsl +27 -6
  51. package/src/ibl-irradiance.wgsl +4 -1
  52. package/src/ibl-sampling.wgsl +56 -2
  53. package/src/index.ts +23 -0
  54. package/src/lighting-directional.wgsl +161 -132
  55. package/src/lighting-probe.wgsl +51 -0
  56. package/src/lighting-punctual.wgsl +69 -29
  57. package/src/lighting-rect-area.wgsl +162 -0
  58. package/src/lighting-spot-modifiers.wgsl +93 -0
  59. package/src/ltc/provenance.ts +19 -0
  60. package/src/ltc/tables.ts +2831 -0
  61. package/src/material/artifact-types.ts +137 -0
  62. package/src/material-schemas.ts +12 -0
  63. package/src/register-default-sprite-lit.ts +3 -6
  64. package/src/scene-temporal.wgsl +14 -8
  65. package/src/shadow-pcf.wgsl +44 -1
  66. package/src/sprite-lit.wgsl +37 -53
  67. package/src/sprite.wgsl +1 -1
  68. package/src/standard-cluster.wgsl +274 -0
  69. package/src/tonemap.wgsl +8 -3
  70. package/src/types.ts +2 -1
  71. package/src/volume/volume-inject.wgsl +48 -38
  72. package/src/volume/volume-integrate.wgsl +47 -42
  73. package/dist/.tsbuildinfo +0 -1
  74. package/src/hdrp-cluster-forward.wgsl +0 -224
@@ -1,45 +1,25 @@
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, shadowMap, shadowSampler, 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::{sampleIblDiffuse, sampleIblSpecular, sampleReflectionProbeSpecular}
7
+ #import forgeax_pbr::lighting_probe::{evaluateProbeDiffuse}
9
8
  #import forgeax_pbr::tbn::{decodeTangentSpaceNormalRg, scaleTangentSpaceNormal, applyTBN}
10
9
  #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
10
  #ifdef CLUSTER_FORWARD_AVAILABLE
35
- #import forgeax_hdrp::cluster_forward::{evaluate_cluster_lights, get_ssao_intensity}
11
+ #import forgeax_standard::cluster::{evaluateStandardClusterLights, get_ssao_intensity}
36
12
  #endif
37
13
 
38
14
  #pragma variant_axis STORAGE_BUFFER_AVAILABLE
39
15
  #pragma variant_axis CLUSTER_FORWARD_AVAILABLE
40
16
  #pragma variant_axis VERTEX_COLOR_AVAILABLE
17
+ #pragma variant_axis PROBE_BLEND_AVAILABLE
18
+ #pragma variant_axis EXTENDED_LIGHTING_AVAILABLE
41
19
  #pragma variant_axis TRANSMISSION_AVAILABLE
20
+ #pragma variant_axis DIRECTIONAL_PCSS_AVAILABLE
42
21
  #pragma variant_axis PROJECTOR_AVAILABLE
22
+ #pragma variant_axis REFLECTION_FALLBACK_AVAILABLE
43
23
 
44
24
  // @forgeax/engine-shader - default-standard-pbr.wgsl
45
25
  // (feat-20260523-shader-template-instance-split M5 / T04).
@@ -235,6 +215,12 @@ struct SkylightUniforms {
235
215
  @group(1) @binding(22) var brdfLutSampler : sampler;
236
216
  @group(1) @binding(23) var<uniform> skylight : SkylightUniforms;
237
217
 
218
+ #ifdef PROBE_BLEND_AVAILABLE
219
+ // Optional consumer lane. The host must provide the retained 160B record
220
+ // buffer and matching BGL before enabling this capability.
221
+ @group(3) @binding(1) var<storage, read> probeBlendRecords : array<vec4<f32>>;
222
+ #endif
223
+
238
224
  // feat-20260612-hdrp-ssao M7 (round 2) D-B + D-C, scope-amend-webgl2-ubo:
239
225
  // SSAO sampling lives on the HDRP unified BGL @group(2) alongside the
240
226
  // cluster bindings (binding 7 = ssao texture, binding 8 = sampler). The
@@ -274,7 +260,11 @@ struct VsOut {
274
260
  @location(1) worldNormal : vec3<f32>,
275
261
  @location(2) uv : vec2<f32>,
276
262
  @location(3) worldTangent : vec4<f32>,
277
- @location(4) @interpolate(flat) instanceIdx : u32,
263
+ #ifdef TRANSMISSION_AVAILABLE
264
+ // Keep mesh and instance storage vertex-only; transmission receives the
265
+ // transform basis from the vertex stage below.
266
+ @location(4) @interpolate(flat) transmissionBasis0 : vec4<f32>,
267
+ #endif
278
268
  // feat-city-glb multi-UV tiling: second UV set inter-stage varying. Uses the
279
269
  // previously-vacant @location(5) so @location(6)/(7) stay byte-stable with
280
270
  // the prior layout (CSM M5/w19).
@@ -288,7 +278,7 @@ struct VsOut {
288
278
  #ifdef VERTEX_COLOR_AVAILABLE
289
279
  @location(14) color : vec4<f32>,
290
280
  #endif
291
- @location(6) ndc : vec3<f32>, // NDC for HDRP cluster lookup (w10)
281
+ @location(6) ndc : vec4<f32>, // NDC for HDRP cluster lookup; .w = transmission basis tail
292
282
  // feat-20260609-hdrp-cluster-fragment-ggx M4.5-followup: view-space z is
293
283
  // needed by ndc_position_to_cluster (slice index uses log-z mapping that
294
284
  // takes negative view_z, NOT NDC z which is [0,1]). Earlier `in.ndc.z`
@@ -297,6 +287,9 @@ struct VsOut {
297
287
  // hot zone -- received zero light. Forward view_z explicitly. M5 / w19:
298
288
  // also feeds CSM cascade selection in evalDirectional.
299
289
  @location(7) viewZ : f32,
290
+ #ifdef TRANSMISSION_AVAILABLE
291
+ @location(15) @interpolate(flat) transmissionBasis1 : vec4<f32>,
292
+ #endif
300
293
  };
301
294
 
302
295
  fn pick_channel(rgba : vec4<f32>, channelIndex : u32) -> f32 {
@@ -310,18 +303,12 @@ fn pick_channel(rgba : vec4<f32>, channelIndex : u32) -> f32 {
310
303
  }
311
304
  }
312
305
 
313
- // Light evaluators live in forgeax_pbr::lighting_directional +
314
- // forgeax_pbr::lighting_punctual (M5 / T02). evalDirectional consumes the
315
- // host's view UBO + shadowMap (LO 3.1.3 slope-scaled-bias 3x3 PCF;
316
- // feat-20260520-directional-light-shadow-mapping byte-equivalent). evalPoint
317
- // / evalSpot share evalPunctualBody (GGX specular + Lambertian diffuse +
318
- // KHR_lights_punctual quartic range attenuation;
319
- // feat-20260519-light-casters-point-spot-pbr M4 / w22 byte-equivalent).
320
- // Charter P4: spot light is a thin cone-multiplier on top of the punctual
321
- // body, point light is the body unchanged -- no magic-value collapse.
306
+ // Directional lighting lives in forgeax_pbr::lighting_directional. Point,
307
+ // Spot, and Rect local lights are evaluated by the Standard Cluster module
308
+ // below from one indexed light_data corpus; no per-kind fixed-slot loop is
309
+ // present in this material shader.
322
310
 
323
- @vertex
324
- fn vs_main(in : VsIn, @builtin(instance_index) idx : u32) -> VsOut {
311
+ fn vs_main_impl(in : VsIn, meshIndex : u32, instanceIndex : u32) -> VsOut {
325
312
  // feat-20260604-instances-per-instance-transform-shader-group3-bin M1 / w4:
326
313
  // Entity world from meshes[0] — the @group(2) dynamic-offset window has
327
314
  // already been aimed at this entity's slot (render-system-record.ts:2996
@@ -329,8 +316,8 @@ fn vs_main(in : VsIn, @builtin(instance_index) idx : u32) -> VsOut {
329
316
  // local from instances[idx] — @group(3) is a flat per-instance buffer indexed
330
317
  // directly by instance_index (firstInstance=0, render-system-record.ts:2898).
331
318
  // Combine: entity_world * per_instance_local.
332
- let instanceLocal = instances[idx].localFromInstance;
333
- let entityWorld = meshes[0].worldFromLocal;
319
+ let instanceLocal = instances[instanceIndex].localFromInstance;
320
+ let entityWorld = meshes[meshIndex].worldFromLocal;
334
321
  let localToWorld = entityWorld * instanceLocal;
335
322
  let world = localToWorld * vec4<f32>(in.pos, 1.0);
336
323
  var out : VsOut;
@@ -358,6 +345,20 @@ fn vs_main(in : VsIn, @builtin(instance_index) idx : u32) -> VsOut {
358
345
  // (w=0); .w handedness preserved for bitangent reconstruction in fragment.
359
346
  let worldTangentXyz = normalize((entityWorld * instanceLocal * vec4<f32>(in.tangent.xyz, 0.0)).xyz);
360
347
  out.worldTangent = vec4<f32>(worldTangentXyz, in.tangent.w);
348
+ #ifdef TRANSMISSION_AVAILABLE
349
+ out.transmissionBasis0 = vec4<f32>(
350
+ localToWorld[0].x,
351
+ localToWorld[0].y,
352
+ localToWorld[0].z,
353
+ localToWorld[1].x,
354
+ );
355
+ out.transmissionBasis1 = vec4<f32>(
356
+ localToWorld[1].y,
357
+ localToWorld[1].z,
358
+ localToWorld[2].x,
359
+ localToWorld[2].y,
360
+ );
361
+ #endif
361
362
  out.uv = in.uv;
362
363
  out.uv1 = in.uv1;
363
364
  out.uv2 = in.uv2;
@@ -369,7 +370,6 @@ fn vs_main(in : VsIn, @builtin(instance_index) idx : u32) -> VsOut {
369
370
  #ifdef VERTEX_COLOR_AVAILABLE
370
371
  out.color = in.color;
371
372
  #endif
372
- out.instanceIdx = idx;
373
373
  // feat-20260613-csm-cascaded-shadow-maps M5 / w19: the per-fragment
374
374
  // light-space position varying is gone; evalDirectional computes
375
375
  // per-cascade lightViewProj * worldPos in the fragment stage from
@@ -377,15 +377,30 @@ fn vs_main(in : VsIn, @builtin(instance_index) idx : u32) -> VsOut {
377
377
  // NDC for HDRP cluster lookup (feat-20260609-hdrp-cluster-fragment-ggx M2 / w10).
378
378
  // Perspective divide on clip-space position; ndc.z retains depth-buffer value.
379
379
  let clipPos = out.clip;
380
- out.ndc = vec3(clipPos.xy / clipPos.w, clipPos.z / clipPos.w);
380
+ out.ndc = vec4(clipPos.xy / clipPos.w, clipPos.z / clipPos.w, localToWorld[2].z);
381
381
  // Standard WebGPU/GL perspective projection has clip.w = -view.z, so
382
382
  // view_z = -clip.w (negative in front of the camera). M4.5-followup:
383
383
  // ndc_position_to_cluster needs this for log-z slice mapping; passing
384
384
  // ndc.z (in [0,1]) instead made every fragment land in slice 0.
385
- out.viewZ = -clipPos.w;
385
+ // Keep the cluster depth exactly aligned with the CPU binner for both
386
+ // perspective and off-axis orthographic projections.
387
+ out.viewZ = sceneViewZ(clipPos, view.temporalProjection);
386
388
  return out;
387
389
  }
388
390
 
391
+ @vertex
392
+ fn vs_main(in : VsIn, @builtin(instance_index) idx : u32) -> VsOut {
393
+ return vs_main_impl(in, 0u, idx);
394
+ }
395
+
396
+ @vertex
397
+ fn vs_scene_index(in : VsIn, @builtin(instance_index) idx : u32) -> VsOut {
398
+ // GPU-driven scene projection packs one Mesh row per visible candidate and
399
+ // uses the identity instance row. The scene index therefore selects the
400
+ // projected Mesh directly while retaining the Standard PBR varyings.
401
+ return vs_main_impl(in, idx, 0u);
402
+ }
403
+
389
404
  fn transformedMaterialUv(transform : vec4<f32>, metadata : vec4<f32>, in : VsOut) -> vec2<f32> {
390
405
  var source = in.uv;
391
406
  if (metadata.x >= 1.0) { source = in.uv1; }
@@ -447,8 +462,17 @@ fn alphaTest(alpha : f32) {
447
462
  }
448
463
  }
449
464
 
465
+ struct StandardPbrOutput {
466
+ @location(0) color : vec4<f32>,
467
+ #ifdef REFLECTION_FALLBACK_AVAILABLE
468
+ // Linear HDR environment contribution from this same Standard BRDF callsite.
469
+ // The detached producer names this second target S_fallback.
470
+ @location(1) reflectionFallback : vec4<f32>,
471
+ #endif
472
+ };
473
+
450
474
  @fragment
451
- fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
475
+ fn fs_main(in : VsOut) -> StandardPbrOutput {
452
476
  let baseUv = transformedMaterialUv(material.baseColorTextureCoordinatesTransform, material.baseColorTextureCoordinatesMetadata, in);
453
477
  let baseSample = sampleMaterialTexture(baseColorTexture, baseColorSampler, baseUv, material.baseColorTextureCoordinatesMetadata.zw);
454
478
  let vertexColor = materialVertexColor(in);
@@ -561,7 +585,7 @@ fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
561
585
  // feat-20260520-directional-light-shadow-mapping +
562
586
  // feat-20260518-pbr-direct-lighting-mvp Finding 4):
563
587
  //
564
- // color = ambient(IBL) + directional(shadowed) + sum(point) + sum(spot)
588
+ // color = ambient(IBL) + directional(shadowed) + clustered local lights
565
589
  //
566
590
  // When the host Skylight bind group provides default-zero resources the IBL
567
591
  // helpers sample to vec3(0) and ambient = 0, so the shader falls through to
@@ -577,22 +601,33 @@ fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
577
601
  // split-sum mip lookup, so we re-derive the post-shader-clamp roughness
578
602
  // here (matches sampleIblSpecular's `mip = roughness * 4.0` expectation).
579
603
  //
580
- // Direct lights (1 + N + N): one directional + N point + N spot, summed
581
- // sequentially. LIGHT_ARRAY_MAX_SLOTS = 4 host-side bounds the loop trip
582
- // counts. GGX BRDF helpers (d_ggx / v_smith / f_schlick from
583
- // forgeax_pbr::brdf) run inside each evalDirectional / evalPoint / evalSpot
584
- // so all three light types share the same microfacet specular + Lambertian
585
- // diffuse form. The directional path additionally projects worldPos
586
- // through the per-cascade light-space matrix in the fragment stage for
587
- // shadow-map PCF lookup (CSM, feat-20260613).
604
+ // Directional lighting is evaluated here. All local lights (Point, Spot,
605
+ // Rect) are evaluated by the Standard Cluster module below, which reads
606
+ // one indexed light_data corpus and never performs a fixed-slot loop.
588
607
  let kD = (vec3<f32>(1.0) - f_schlick(max(dot(n, v), 0.0), f0)) * (1.0 - metallic);
589
608
  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
- );
609
+ var irradiance = vec3<f32>(0.0);
610
+ var specularIbl = vec3<f32>(0.0);
611
+ if (skylight.intensity < 0.0) {
612
+ // A negative intensity is the renderer-owned probe sentinel. The producer
613
+ // stores -(probeIntensity + 1), so intensity=0 remains distinguishable
614
+ // from the ordinary Skylight zero payload. The four color/rotation lanes
615
+ // carry probe center and half extents in this mode; Skylight resources
616
+ // remain unchanged and therefore preserve the ABI.
617
+ specularIbl = sampleReflectionProbeSpecular(
618
+ n, v, iblRoughness, f0, in.worldPos,
619
+ vec3<f32>(skylight.colorR, skylight.colorG, skylight.colorB),
620
+ skylight.rotation.xyz, vec4<f32>(0.0, 0.0, 0.0, 1.0),
621
+ prefilterMap, prefilterSampler, brdfLut, brdfLutSampler,
622
+ );
623
+ } else {
624
+ irradiance = sampleIblDiffuse(n, skylight.rotation, irradianceMap, irradianceSampler);
625
+ specularIbl = sampleIblSpecular(
626
+ n, v, iblRoughness, f0,
627
+ skylight.rotation,
628
+ prefilterMap, prefilterSampler, brdfLut, brdfLutSampler,
629
+ );
630
+ }
596
631
  let occlusionUv = transformedMaterialUv(material.occlusionTextureCoordinatesTransform, material.occlusionTextureCoordinatesMetadata, in);
597
632
  let aoSample = sampleMaterialTexture(occlusionTexture, occlusionSampler, occlusionUv, material.occlusionTextureCoordinatesMetadata.zw);
598
633
  let ao = mix(1.0, aoSample.r, material.occlusionStrength);
@@ -600,16 +635,67 @@ fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
600
635
  // CLUSTER_FORWARD_AVAILABLE branch below can `ambient *=` the SSAO
601
636
  // factor. The non-HDRP path leaves ambient untouched.
602
637
  let skyColor = vec3<f32>(skylight.colorR, skylight.colorG, skylight.colorB);
638
+ let skyFactor = skyColor * skylight.intensity;
639
+ var reflectionFallback = specularIbl * (vec3<f32>(1.0) - coatF);
603
640
  var ambient = (kD * irradiance * diffuseAlbedo + specularIbl) * (vec3<f32>(1.0) - coatF);
641
+ #ifdef PROBE_BLEND_AVAILABLE
642
+ // group(3) is the per-object bind group; record zero is the object selected
643
+ // by this draw, independent of instance count.
644
+ let probeShPreblend = array<vec4<f32>, 9>(
645
+ probeBlendRecords[1], probeBlendRecords[2], probeBlendRecords[3],
646
+ probeBlendRecords[4], probeBlendRecords[5], probeBlendRecords[6],
647
+ probeBlendRecords[7], probeBlendRecords[8], probeBlendRecords[9],
648
+ );
649
+ let probeLocalBlendFraction = probeBlendRecords[0].z;
650
+ let probeDiffuseK = kD / max(vec3<f32>(1.0 - metallic), vec3<f32>(0.0001));
651
+ let probeDiffuse = evaluateProbeDiffuse(probeShPreblend, probeLocalBlendFraction, n, irradiance * skyFactor, probeDiffuseK, albedo, metallic);
652
+ ambient = (probeDiffuse + specularIbl * skyFactor) * (vec3<f32>(1.0) - coatF);
653
+ #endif
604
654
  if (material.clearcoat != 0.0) {
605
- let clearcoatIbl = sampleIblSpecular(
606
- n, v, coatRoughness, vec3<f32>(0.04),
607
- skylight.rotation,
608
- prefilterMap, prefilterSampler, brdfLut, brdfLutSampler,
609
- );
610
- ambient = ambient + clearcoatIbl * material.clearcoat;
655
+ // Keep the clearcoat lobe on the same selected environment as the base
656
+ // specular lobe. Probe mode reuses the packed center/extents lanes in the
657
+ // per-probe uniform; routing it through global Skylight IBL would make the
658
+ // fallback a mixed-source projection and break c=1 source equivalence.
659
+ var clearcoatIbl = vec3<f32>(0.0);
660
+ if (skylight.intensity < 0.0) {
661
+ clearcoatIbl = sampleReflectionProbeSpecular(
662
+ n, v, coatRoughness, vec3<f32>(0.04), in.worldPos,
663
+ vec3<f32>(skylight.colorR, skylight.colorG, skylight.colorB),
664
+ skylight.rotation.xyz, vec4<f32>(0.0, 0.0, 0.0, 1.0),
665
+ prefilterMap, prefilterSampler, brdfLut, brdfLutSampler,
666
+ );
667
+ } else {
668
+ clearcoatIbl = sampleIblSpecular(
669
+ n, v, coatRoughness, vec3<f32>(0.04),
670
+ skylight.rotation,
671
+ prefilterMap, prefilterSampler, brdfLut, brdfLutSampler,
672
+ );
673
+ }
674
+ let clearcoatContribution = clearcoatIbl * material.clearcoat;
675
+ #ifdef PROBE_BLEND_AVAILABLE
676
+ if (skylight.intensity < 0.0) {
677
+ ambient = ambient + clearcoatContribution;
678
+ } else {
679
+ ambient = ambient + clearcoatIbl * material.clearcoat * skyFactor;
680
+ }
681
+ #else
682
+ ambient = ambient + clearcoatContribution;
683
+ #endif
684
+ reflectionFallback = reflectionFallback + clearcoatContribution;
611
685
  }
612
- ambient = ambient * skyColor * skylight.intensity * ao;
686
+ #ifdef PROBE_BLEND_AVAILABLE
687
+ ambient = ambient * ao;
688
+ #else
689
+ if (skylight.intensity < 0.0) {
690
+ let environmentScale = max(-skylight.intensity - 1.0, 0.0) * ao;
691
+ ambient = ambient * environmentScale;
692
+ reflectionFallback = reflectionFallback * environmentScale;
693
+ } else {
694
+ let environmentScale = skyColor * skylight.intensity * ao;
695
+ ambient = ambient * environmentScale;
696
+ reflectionFallback = reflectionFallback * environmentScale;
697
+ }
698
+ #endif
613
699
  #ifdef CLUSTER_FORWARD_AVAILABLE
614
700
  // feat-20260612-hdrp-ssao M2 round-1 + M7 round-2 (plan-strategy D-7 + D-B + D-C):
615
701
  // SSAO ambient synthesis. Reads the half-res R8 `ssaoBlurredTexture` from
@@ -625,7 +711,9 @@ fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
625
711
  let ssaoFactor = textureSample(ssaoBlurredTexture, ssaoBlurredSampler, ssaoUv).r;
626
712
  // scope-amend-webgl2-ubo: intensity packed into cluster_uniform.near_far_log.w.
627
713
  let ssaoIntensity = get_ssao_intensity();
714
+ let ssaoScale = mix(1.0, ssaoFactor * ao, ssaoIntensity);
628
715
  ambient *= mix(1.0, ssaoFactor * ao, ssaoIntensity);
716
+ reflectionFallback *= ssaoScale;
629
717
  #endif
630
718
  var color = ambient;
631
719
  #ifdef TRANSMISSION_AVAILABLE
@@ -636,10 +724,17 @@ fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
636
724
  // the fragment stage preserves that contract and keeps thickness in the
637
725
  // glTF unit-scale mesh space before measuring the refracted segment in world
638
726
  // 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;
727
+ let localToWorld0 = in.transmissionBasis0.xyz;
728
+ let localToWorld1 = vec3<f32>(
729
+ in.transmissionBasis0.w,
730
+ in.transmissionBasis1.x,
731
+ in.transmissionBasis1.y,
732
+ );
733
+ let localToWorld2 = vec3<f32>(
734
+ in.transmissionBasis1.z,
735
+ in.transmissionBasis1.w,
736
+ in.ndc.w,
737
+ );
643
738
  let inverseCofactor0 = cross(localToWorld1, localToWorld2);
644
739
  let inverseCofactor1 = cross(localToWorld2, localToWorld0);
645
740
  let inverseCofactor2 = cross(localToWorld0, localToWorld1);
@@ -723,127 +818,24 @@ fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
723
818
  #ifdef CLUSTER_FORWARD_AVAILABLE
724
819
  // NDC from vertex shader (perspective-divided clip-space, interpolated).
725
820
  // 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);
821
+ color = color + evaluateStandardClusterLights(
822
+ in.ndc.xyz, in.viewZ, in.worldPos, n, v, diffuseAlbedo, metallic, a, f0, false,
823
+ );
727
824
  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),
825
+ color = color + material.clearcoat * evaluateStandardClusterLights(
826
+ in.ndc.xyz, in.viewZ, in.worldPos, n, v, vec3<f32>(0.0), 1.0, coatAlpha, vec3<f32>(0.04), false,
730
827
  );
731
828
  }
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,
832
- );
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
- }
842
829
  #endif // CLUSTER_FORWARD_AVAILABLE
843
830
  let emissiveUv = transformedMaterialUv(material.emissiveTextureCoordinatesTransform, material.emissiveTextureCoordinatesMetadata, in);
844
831
  let emissiveSample = sampleMaterialTexture(emissiveTexture, emissiveSampler, emissiveUv, material.emissiveTextureCoordinatesMetadata.zw).rgb;
845
832
  color = color + material.emissive * material.emissiveIntensity * emissiveSample;
846
- return vec4<f32>(color, alpha);
833
+ var output : StandardPbrOutput;
834
+ output.color = vec4<f32>(color, alpha);
835
+ #ifdef REFLECTION_FALLBACK_AVAILABLE
836
+ output.reflectionFallback = vec4<f32>(reflectionFallback, 1.0);
837
+ #endif
838
+ return output;
847
839
  }
848
840
 
849
841
  // ── G-buffer output struct (feat-20260612-hdrp-deferred-shading M2 / w12) ──
@@ -931,6 +923,7 @@ struct TemporalVsOut {
931
923
  @location(7) uv7 : vec2<f32>,
932
924
  @location(8) @interpolate(perspective) currentClip : vec4<f32>,
933
925
  @location(9) @interpolate(perspective) previousClip : vec4<f32>,
926
+ @location(10) @interpolate(flat) reactive : f32,
934
927
  #ifdef VERTEX_COLOR_AVAILABLE
935
928
  @location(14) color : vec4<f32>,
936
929
  #endif
@@ -941,7 +934,7 @@ fn vs_temporal(in : VsIn, @builtin(instance_index) idx : u32) -> TemporalVsOut {
941
934
  let currentWorld = meshes[0].worldFromLocal *
942
935
  instances[idx].localFromInstance * vec4<f32>(in.pos, 1.0);
943
936
  var previousWorld = currentWorld;
944
- #if STORAGE_BUFFER_AVAILABLE == true
937
+ #ifdef STORAGE_BUFFER_AVAILABLE
945
938
  previousWorld = meshes[0].previousWorldFromLocal *
946
939
  instances[idx].previousLocalFromInstance * vec4<f32>(in.pos, 1.0);
947
940
  #endif
@@ -949,6 +942,11 @@ fn vs_temporal(in : VsIn, @builtin(instance_index) idx : u32) -> TemporalVsOut {
949
942
  out.currentClip = view.temporalCurrentViewProj * currentWorld;
950
943
  out.clip = out.currentClip;
951
944
  out.previousClip = view.temporalPreviousViewProj * previousWorld;
945
+ #if STORAGE_BUFFER_AVAILABLE == true
946
+ out.reactive = meshes[0].temporal.x;
947
+ #else
948
+ out.reactive = 1.0;
949
+ #endif
952
950
  out.uv = in.uv;
953
951
  out.uv1 = in.uv1;
954
952
  out.uv2 = in.uv2;
@@ -974,7 +972,7 @@ fn temporalVertexAlpha(in : TemporalVsOut) -> f32 {
974
972
  @fragment
975
973
  fn fs_temporal(in : TemporalVsOut) -> @location(0) vec4<f32> {
976
974
  var reactive = 0.0;
977
- #if STORAGE_BUFFER_AVAILABLE == true
975
+ #ifdef STORAGE_BUFFER_AVAILABLE
978
976
  reactive = meshes[0].temporal.x;
979
977
  #endif
980
978
  return projectPbrSceneTemporal(
@@ -987,7 +985,7 @@ fn fs_temporal(in : TemporalVsOut) -> @location(0) vec4<f32> {
987
985
  in.currentClip,
988
986
  in.previousClip,
989
987
  view.temporalProjection,
990
- reactive,
988
+ in.reactive,
991
989
  in.uv, in.uv1, in.uv2, in.uv3,
992
990
  in.uv4, in.uv5, in.uv6, in.uv7,
993
991
  );
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
  }
@@ -83,7 +83,10 @@ fn irradianceConvolve_fs(in0: CubemapVsOut) -> @location(0) vec4<f32> {
83
83
  phi += IRRADIANCE_SAMPLE_DELTA;
84
84
  }
85
85
 
86
- // IRRADIANCE_PAYLOAD_E_TIMES_PI: the stored payload is E * pi.
86
+ // IRRADIANCE_PAYLOAD_E_OVER_PI: PI * the uniform theta/phi sample average
87
+ // produces the Lambert-normalized diffuse radiance E / PI. Runtime sampling
88
+ // consumes this payload directly; applying another Lambert divide would
89
+ // darken diffuse IBL by PI.
87
90
  irradiance = PI * irradiance / max(nrSamples, 1.0);
88
91
  return vec4<f32>(irradiance, 1.0);
89
92
  }