@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
@@ -1,24 +1,33 @@
1
1
  #define_import_path forgeax_material::standard
2
- #import forgeax_view::common::{View, FogViewParams, FogRay, Mesh, InstanceData, view, meshes, instances, PointLight, SpotLight, pointLightsBuffer, spotLightsBuffer, shadowMap, shadowSampler, sampleMaterialTexture}
2
+ #import forgeax_view::common::{View, Mesh, InstanceData, view, meshes, instances, sampleMaterialTexture}
3
+ #import forgeax_scene_temporal::{sceneViewZ}
3
4
  #import forgeax_pbr::temporal::{projectPbrSceneTemporal}
4
5
  #import forgeax_pbr::brdf::{f_schlick, v_smith, d_ggx}
5
- #import forgeax_pbr::ibl_sampling::{sampleIblDiffuse, sampleIblSpecular}
6
+ #import forgeax_pbr::ibl_sampling::{box_project, sampleIblDiffuse, sampleIblSpecular, sampleReflectionProbeSpecular}
6
7
  #import forgeax_pbr::tbn::{decodeTangentSpaceNormalRg, scaleTangentSpaceNormal, applyTBN}
7
8
  #import forgeax_pbr::lighting_directional::{evalDirectionalNoShadow, evalDirectionalShadowFactor}
8
- #import forgeax_pbr::lighting_punctual::{evalPoint, evalSpot, evalSpotShadowed}
9
- #import forgeax_view::fog::{apply_fog}
10
- #ifdef POINT_SHADOW_AVAILABLE
11
- #import forgeax_pbr::lighting_punctual::{evalPointShadowed}
12
- #import forgeax_view::common::{shadowParams}
13
- #endif
14
9
  #ifdef CLUSTER_FORWARD_AVAILABLE
15
- #import forgeax_hdrp::cluster_forward::{evaluate_cluster_lights, get_ssao_intensity}
10
+ #import forgeax_standard::cluster::{evaluateStandardClusterLights, get_ssao_intensity}
16
11
  #endif
17
12
 
18
13
  #pragma variant_axis STORAGE_BUFFER_AVAILABLE
19
14
  #pragma variant_axis CLUSTER_FORWARD_AVAILABLE
20
15
  #pragma variant_axis VERTEX_COLOR_AVAILABLE
21
16
  #pragma variant_axis TRANSMISSION_AVAILABLE
17
+ #pragma variant_axis DIRECTIONAL_PCSS_AVAILABLE
18
+ #pragma variant_axis PROJECTOR_AVAILABLE
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
+ }
22
31
 
23
32
  // @forgeax/engine-shader - default-standard-pbr.wgsl
24
33
  // (feat-20260523-shader-template-instance-split M5 / T04).
@@ -253,7 +262,11 @@ struct VsOut {
253
262
  @location(1) worldNormal : vec3<f32>,
254
263
  @location(2) uv : vec2<f32>,
255
264
  @location(3) worldTangent : vec4<f32>,
256
- @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
257
270
  // feat-city-glb multi-UV tiling: second UV set inter-stage varying. Uses the
258
271
  // previously-vacant @location(5) so @location(6)/(7) stay byte-stable with
259
272
  // the prior layout (CSM M5/w19).
@@ -267,7 +280,7 @@ struct VsOut {
267
280
  #ifdef VERTEX_COLOR_AVAILABLE
268
281
  @location(14) color : vec4<f32>,
269
282
  #endif
270
- @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
271
284
  // feat-20260609-hdrp-cluster-fragment-ggx M4.5-followup: view-space z is
272
285
  // needed by ndc_position_to_cluster (slice index uses log-z mapping that
273
286
  // takes negative view_z, NOT NDC z which is [0,1]). Earlier `in.ndc.z`
@@ -276,6 +289,9 @@ struct VsOut {
276
289
  // hot zone -- received zero light. Forward view_z explicitly. M5 / w19:
277
290
  // also feeds CSM cascade selection in evalDirectional.
278
291
  @location(7) viewZ : f32,
292
+ #ifdef TRANSMISSION_AVAILABLE
293
+ @location(15) @interpolate(flat) transmissionBasis1 : vec4<f32>,
294
+ #endif
279
295
  };
280
296
 
281
297
  fn pick_channel(rgba : vec4<f32>, channelIndex : u32) -> f32 {
@@ -289,25 +305,6 @@ fn pick_channel(rgba : vec4<f32>, channelIndex : u32) -> f32 {
289
305
  }
290
306
  }
291
307
 
292
- fn applySceneFog(viewParams : View, color : vec3<f32>, alpha : f32, worldPos : vec3<f32>) -> vec4<f32> {
293
- var origin = viewParams.cameraPos;
294
- var direction = normalize(worldPos - origin);
295
- var rayDistance = length(worldPos - origin);
296
- if (viewParams.temporalProjection.z >= 0.5) {
297
- let nearH = viewParams.inverseViewProj * vec4<f32>(0.0, 0.0, 0.0, 1.0);
298
- let farH = viewParams.inverseViewProj * vec4<f32>(0.0, 0.0, 1.0, 1.0);
299
- let nearPoint = nearH.xyz / nearH.w;
300
- let farPoint = farH.xyz / farH.w;
301
- direction = normalize(farPoint - nearPoint);
302
- // Orthographic rays are parallel, but their origins differ per fragment.
303
- // Project the current fragment onto the camera plane instead of reusing
304
- // the center NDC ray origin for every pixel.
305
- origin = worldPos - direction * dot(worldPos - viewParams.cameraPos, direction);
306
- rayDistance = max(dot(worldPos - origin, direction), 0.0);
307
- }
308
- return apply_fog(viewParams.fog, FogRay(origin, direction, rayDistance), vec4<f32>(color, alpha));
309
- }
310
-
311
308
  // Light evaluators live in forgeax_pbr::lighting_directional +
312
309
  // forgeax_pbr::lighting_punctual (M5 / T02). evalDirectional consumes the
313
310
  // host's view UBO + shadowMap (LO 3.1.3 slope-scaled-bias 3x3 PCF;
@@ -356,6 +353,20 @@ fn vs_main(in : VsIn, @builtin(instance_index) idx : u32) -> VsOut {
356
353
  // (w=0); .w handedness preserved for bitangent reconstruction in fragment.
357
354
  let worldTangentXyz = normalize((entityWorld * instanceLocal * vec4<f32>(in.tangent.xyz, 0.0)).xyz);
358
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
359
370
  out.uv = in.uv;
360
371
  out.uv1 = in.uv1;
361
372
  out.uv2 = in.uv2;
@@ -367,7 +378,6 @@ fn vs_main(in : VsIn, @builtin(instance_index) idx : u32) -> VsOut {
367
378
  #ifdef VERTEX_COLOR_AVAILABLE
368
379
  out.color = in.color;
369
380
  #endif
370
- out.instanceIdx = idx;
371
381
  // feat-20260613-csm-cascaded-shadow-maps M5 / w19: the per-fragment
372
382
  // light-space position varying is gone; evalDirectional computes
373
383
  // per-cascade lightViewProj * worldPos in the fragment stage from
@@ -375,12 +385,14 @@ fn vs_main(in : VsIn, @builtin(instance_index) idx : u32) -> VsOut {
375
385
  // NDC for HDRP cluster lookup (feat-20260609-hdrp-cluster-fragment-ggx M2 / w10).
376
386
  // Perspective divide on clip-space position; ndc.z retains depth-buffer value.
377
387
  let clipPos = out.clip;
378
- 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);
379
389
  // Standard WebGPU/GL perspective projection has clip.w = -view.z, so
380
390
  // view_z = -clip.w (negative in front of the camera). M4.5-followup:
381
391
  // ndc_position_to_cluster needs this for log-z slice mapping; passing
382
392
  // ndc.z (in [0,1]) instead made every fragment land in slice 0.
383
- 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);
384
396
  return out;
385
397
  }
386
398
 
@@ -585,12 +597,26 @@ fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
585
597
  // shadow-map PCF lookup (CSM, feat-20260613).
586
598
  let kD = (vec3<f32>(1.0) - f_schlick(max(dot(n, v), 0.0), f0)) * (1.0 - metallic);
587
599
  let iblRoughness = clamp(max(finiteScalar(material.roughness, 0.5), 0.04) * roughnessTex, 0.04, 1.0);
588
- let irradiance = sampleIblDiffuse(n, skylight.rotation, irradianceMap, irradianceSampler);
589
- let specularIbl = sampleIblSpecular(
590
- n, v, iblRoughness, f0,
591
- skylight.rotation,
592
- prefilterMap, prefilterSampler, brdfLut, brdfLutSampler,
593
- );
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
+ }
594
620
  let occlusionUv = transformedMaterialUv(material.occlusionTextureCoordinatesTransform, material.occlusionTextureCoordinatesMetadata, in);
595
621
  let aoSample = sampleMaterialTexture(occlusionTexture, occlusionSampler, occlusionUv, material.occlusionTextureCoordinatesMetadata.zw);
596
622
  let ao = mix(1.0, aoSample.r, material.occlusionStrength);
@@ -607,7 +633,11 @@ fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
607
633
  );
608
634
  ambient = ambient + clearcoatIbl * material.clearcoat;
609
635
  }
610
- 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
+ }
611
641
  #ifdef CLUSTER_FORWARD_AVAILABLE
612
642
  // feat-20260612-hdrp-ssao M2 round-1 + M7 round-2 (plan-strategy D-7 + D-B + D-C):
613
643
  // SSAO ambient synthesis. Reads the half-res R8 `ssaoBlurredTexture` from
@@ -634,10 +664,17 @@ fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
634
664
  // the fragment stage preserves that contract and keeps thickness in the
635
665
  // glTF unit-scale mesh space before measuring the refracted segment in world
636
666
  // metres.
637
- let localToWorld = meshes[0].worldFromLocal * instances[in.instanceIdx].localFromInstance;
638
- let localToWorld0 = localToWorld[0].xyz;
639
- let localToWorld1 = localToWorld[1].xyz;
640
- 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
+ );
641
678
  let inverseCofactor0 = cross(localToWorld1, localToWorld2);
642
679
  let inverseCofactor1 = cross(localToWorld2, localToWorld0);
643
680
  let inverseCofactor2 = cross(localToWorld0, localToWorld1);
@@ -721,110 +758,19 @@ fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
721
758
  #ifdef CLUSTER_FORWARD_AVAILABLE
722
759
  // NDC from vertex shader (perspective-divided clip-space, interpolated).
723
760
  // view_z: NDC depth for cluster Z-slice lookup.
724
- 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
+ );
725
764
  if (material.clearcoat != 0.0) {
726
- color = color + material.clearcoat * evaluate_cluster_lights(
727
- in.ndc, in.viewZ, in.worldPos, n, v, vec3<f32>(0.0), 1.0, coatAlpha, vec3<f32>(0.04),
728
- );
729
- }
730
- #else
731
- let pointCount = pointLightsBuffer.count;
732
- for (var i: u32 = 0u; i < pointCount; i = i + 1u) {
733
- let p = pointLightsBuffer.slots[i];
734
- #ifdef POINT_SHADOW_AVAILABLE
735
- // feat-20260612-point-light-shadows-urp-hdrp Round-2 F-1: gate between
736
- // shadowed and unshadowed evaluation on the host-assigned
737
- // shadowAtlasLayer (>= 0 means PointLightShadow companion exists; -1
738
- // sentinel means no shadow). shadowParams[layer] = (near, far,
739
- // 1/(far-near), 0) is bound at @group(0) @binding(6) (shadowParams
740
- // declared in common.wgsl).
741
- if (p.shadowAtlasLayer >= 0) {
742
- let lane = shadowParams[p.shadowAtlasLayer];
743
- color = color + evalPointShadowed(
744
- p.position, p.colorTimesIntensity, p.invRangeSquared,
745
- in.worldPos, n, v, diffuseAlbedo, metallic, a, f0,
746
- p.shadowAtlasLayer, lane.x, lane.y, 0.005, 0.05,
747
- );
748
- if (material.clearcoat != 0.0) {
749
- color = color + material.clearcoat * evalPointShadowed(
750
- p.position, p.colorTimesIntensity, p.invRangeSquared,
751
- in.worldPos, n, v, vec3<f32>(0.0), 1.0, coatAlpha, vec3<f32>(0.04),
752
- p.shadowAtlasLayer, lane.x, lane.y, 0.005, 0.05,
753
- );
754
- }
755
- } else {
756
- color = color + evalPoint(
757
- p.position, p.colorTimesIntensity, p.invRangeSquared,
758
- in.worldPos, n, v, diffuseAlbedo, metallic, a, f0,
759
- );
760
- if (material.clearcoat != 0.0) {
761
- color = color + material.clearcoat * evalPoint(
762
- p.position, p.colorTimesIntensity, p.invRangeSquared,
763
- in.worldPos, n, v, vec3<f32>(0.0), 1.0, coatAlpha, vec3<f32>(0.04),
764
- );
765
- }
766
- }
767
- #else
768
- color = color + evalPoint(
769
- p.position, p.colorTimesIntensity, p.invRangeSquared,
770
- 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,
771
767
  );
772
- if (material.clearcoat != 0.0) {
773
- color = color + material.clearcoat * evalPoint(
774
- p.position, p.colorTimesIntensity, p.invRangeSquared,
775
- in.worldPos, n, v, vec3<f32>(0.0), 1.0, coatAlpha, vec3<f32>(0.04),
776
- );
777
- }
778
- #endif
779
- }
780
- let spotCount = spotLightsBuffer.count;
781
- for (var i: u32 = 0u; i < spotCount; i = i + 1u) {
782
- let s = spotLightsBuffer.slots[i];
783
- // feat-20260625-spot-light-shadow-mapping M3 / w15 (plan-strategy D-1
784
- // fragment side + D-3 + D-4): gate on the host-assigned shadowAtlasTile
785
- // (>= 0 means a spot atlas tile was allocated; -1 sentinel means no
786
- // shadow / clipped / direction-degenerate). The shadowed path reads the
787
- // per-spot perspective matrix from the View UBO `view.spotLightViewProj`
788
- // array (lane N = shadowAtlasTile N; folded from the standalone binding 9
789
- // into the View UBO in feat-20260625 w25 for WebGL2 uniform-buffer budget);
790
- // the unshadowed path stays on evalSpot. bias hardcoded 0.005 / 0.05 aligns
791
- // with the DirectionalLight defaults (D-6); the OOB/NaN gate lives inside
792
- // evalSpotShadowed, not here.
793
- if (s.shadowAtlasTile >= 0) {
794
- color = color + evalSpotShadowed(
795
- s.position, s.direction, s.colorTimesIntensity,
796
- s.cosInner, s.cosOuter, s.invRangeSquared,
797
- in.worldPos, n, v, diffuseAlbedo, metallic, a, f0,
798
- view.spotLightViewProj[s.shadowAtlasTile], s.shadowAtlasTile, 0.005, 0.05,
799
- );
800
- if (material.clearcoat != 0.0) {
801
- color = color + material.clearcoat * evalSpotShadowed(
802
- s.position, s.direction, s.colorTimesIntensity,
803
- s.cosInner, s.cosOuter, s.invRangeSquared,
804
- in.worldPos, n, v, vec3<f32>(0.0), 1.0, coatAlpha, vec3<f32>(0.04),
805
- view.spotLightViewProj[s.shadowAtlasTile], s.shadowAtlasTile, 0.005, 0.05,
806
- );
807
- }
808
- } else {
809
- color = color + evalSpot(
810
- s.position, s.direction, s.colorTimesIntensity,
811
- s.cosInner, s.cosOuter, s.invRangeSquared,
812
- in.worldPos, n, v, diffuseAlbedo, metallic, a, f0,
813
- );
814
- if (material.clearcoat != 0.0) {
815
- color = color + material.clearcoat * evalSpot(
816
- s.position, s.direction, s.colorTimesIntensity,
817
- s.cosInner, s.cosOuter, s.invRangeSquared,
818
- in.worldPos, n, v, vec3<f32>(0.0), 1.0, coatAlpha, vec3<f32>(0.04),
819
- );
820
- }
821
- }
822
768
  }
823
769
  #endif // CLUSTER_FORWARD_AVAILABLE
824
770
  let emissiveUv = transformedMaterialUv(material.emissiveTextureCoordinatesTransform, material.emissiveTextureCoordinatesMetadata, in);
825
771
  let emissiveSample = sampleMaterialTexture(emissiveTexture, emissiveSampler, emissiveUv, material.emissiveTextureCoordinatesMetadata.zw).rgb;
826
772
  color = color + material.emissive * material.emissiveIntensity * emissiveSample;
827
- return applySceneFog(view, color, alpha, in.worldPos);
773
+ return vec4<f32>(color, alpha);
828
774
  }
829
775
 
830
776
  // ── G-buffer output struct (feat-20260612-hdrp-deferred-shading M2 / w12) ──
@@ -912,6 +858,7 @@ struct TemporalVsOut {
912
858
  @location(7) uv7 : vec2<f32>,
913
859
  @location(8) @interpolate(perspective) currentClip : vec4<f32>,
914
860
  @location(9) @interpolate(perspective) previousClip : vec4<f32>,
861
+ @location(10) @interpolate(flat) reactive : f32,
915
862
  #ifdef VERTEX_COLOR_AVAILABLE
916
863
  @location(14) color : vec4<f32>,
917
864
  #endif
@@ -930,6 +877,11 @@ fn vs_temporal(in : VsIn, @builtin(instance_index) idx : u32) -> TemporalVsOut {
930
877
  out.currentClip = view.temporalCurrentViewProj * currentWorld;
931
878
  out.clip = out.currentClip;
932
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
933
885
  out.uv = in.uv;
934
886
  out.uv1 = in.uv1;
935
887
  out.uv2 = in.uv2;
@@ -954,10 +906,6 @@ fn temporalVertexAlpha(in : TemporalVsOut) -> f32 {
954
906
 
955
907
  @fragment
956
908
  fn fs_temporal(in : TemporalVsOut) -> @location(0) vec4<f32> {
957
- var reactive = 0.0;
958
- #if STORAGE_BUFFER_AVAILABLE == true
959
- reactive = meshes[0].temporal.x;
960
- #endif
961
909
  return projectPbrSceneTemporal(
962
910
  material.baseColor.a * temporalVertexAlpha(in),
963
911
  material.alphaCutoff,
@@ -968,7 +916,7 @@ fn fs_temporal(in : TemporalVsOut) -> @location(0) vec4<f32> {
968
916
  in.currentClip,
969
917
  in.previousClip,
970
918
  view.temporalProjection,
971
- reactive,
919
+ in.reactive,
972
920
  in.uv, in.uv1, in.uv2, in.uv3,
973
921
  in.uv4, in.uv5, in.uv6, in.uv7,
974
922
  );
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
  }
@@ -1,13 +1,12 @@
1
1
  #define_import_path forgeax_view::hdrp_deferred_lighting
2
2
 
3
3
  #import forgeax_view::common::FullscreenOutput
4
- #import forgeax_view::common::{View, FogViewParams, FogRay}
4
+ #import forgeax_view::common::View
5
5
  #import forgeax_view::common::fullscreen_triangle
6
- #import forgeax_view::fog::{apply_fog}
7
6
 
8
7
  // HDRP deferred lighting producer. The lighting pass consumes the material
9
8
  // G-buffer and current-frame depth, reconstructs the fragment world position,
10
- // then applies the shared analytic Fog exactly once to linear radiance.
9
+ // then writes linear radiance for the shared volume composite pass.
11
10
 
12
11
  @group(0) @binding(0) var<uniform> view : View;
13
12
 
@@ -24,28 +23,6 @@ fn vs_main(@builtin(vertex_index) vertex_index : u32) -> FullscreenOutput {
24
23
  return fullscreen_triangle(vertex_index);
25
24
  }
26
25
 
27
- fn reconstructWorldPosition(uv : vec2<f32>, depth : f32) -> vec3<f32> {
28
- let clip = vec4<f32>(uv * 2.0 - vec2<f32>(1.0), depth, 1.0);
29
- let worldH = view.inverseViewProj * clip;
30
- return worldH.xyz / worldH.w;
31
- }
32
-
33
- fn fogDeferredRadiance(color : vec4<f32>, worldPos : vec3<f32>) -> vec4<f32> {
34
- var origin = view.cameraPos;
35
- var direction = normalize(worldPos - origin);
36
- var rayDistance = length(worldPos - origin);
37
- if (view.temporalProjection.z >= 0.5) {
38
- let nearH = view.inverseViewProj * vec4<f32>(0.0, 0.0, 0.0, 1.0);
39
- let farH = view.inverseViewProj * vec4<f32>(0.0, 0.0, 1.0, 1.0);
40
- let nearPoint = nearH.xyz / nearH.w;
41
- let farPoint = farH.xyz / farH.w;
42
- direction = normalize(farPoint - nearPoint);
43
- origin = worldPos - direction * dot(worldPos - view.cameraPos, direction);
44
- rayDistance = max(dot(worldPos - origin, direction), 0.0);
45
- }
46
- return apply_fog(view.fog, FogRay(origin, direction, rayDistance), color);
47
- }
48
-
49
26
  @fragment
50
27
  fn fs_lighting(in : FullscreenOutput) -> @location(0) vec4<f32> {
51
28
  // The lighting pass runs before the forward lane and shares its cleared
@@ -64,6 +41,5 @@ fn fs_lighting(in : FullscreenOutput) -> @location(0) vec4<f32> {
64
41
  let light = max(dot(normal, normalize(-view.lightDir)), 0.0);
65
42
  let direct = material.rgb * view.lightColor * light * clamp(emissiveSample.a, 0.0, 1.0);
66
43
  let radiance = vec4<f32>(direct + emissiveSample.rgb, material.a);
67
- let worldPos = reconstructWorldPosition(in.uv, depth);
68
- return fogDeferredRadiance(radiance, worldPos);
44
+ return radiance;
69
45
  }
@@ -16,6 +16,8 @@
16
16
  // - sampleIblDiffuse(N, irradianceMap, irradianceSampler)
17
17
  // - sampleIblSpecular(N, V, roughness, F0, prefilterMap, prefilterSampler,
18
18
  // brdfLut, brdfLutSampler)
19
+ // - box_project(worldPosition, direction, boxCenter, boxExtents)
20
+ // - sampleReflectionProbeSpecular(..., probeMap, probeSampler, ...)
19
21
 
20
22
  #import forgeax_pbr::ibl_shared::{PI, fresnelSchlickRoughness, inverseRotateEnvironment}
21
23
 
@@ -56,3 +58,55 @@ fn sampleIblSpecular(
56
58
  let F = fresnelSchlickRoughness(NdotV, F0, roughness);
57
59
  return prefilteredColor * (F * envBRDF.r + envBRDF.g);
58
60
  }
61
+
62
+ // Project a reflection ray from a point inside a probe box onto the box
63
+ // boundary. The helper is binding-free so the scene table and the material
64
+ // resource projection remain owned by the host record stage.
65
+ fn box_project(
66
+ worldPosition: vec3<f32>,
67
+ direction: vec3<f32>,
68
+ boxCenter: vec3<f32>,
69
+ boxExtents: vec3<f32>,
70
+ ) -> vec3<f32> {
71
+ let safeExtents = max(boxExtents, vec3<f32>(0.0001));
72
+ let safeDirection = select(
73
+ vec3<f32>(0.0001),
74
+ direction,
75
+ abs(direction) >= vec3<f32>(0.0001),
76
+ );
77
+ let localPosition = worldPosition - boxCenter;
78
+ let edgeSign = select(vec3<f32>(-1.0), vec3<f32>(1.0), direction >= vec3<f32>(0.0));
79
+ let edge = edgeSign * safeExtents;
80
+ let distances = (edge - localPosition) / safeDirection;
81
+ let travel = min(distances.x, min(distances.y, distances.z));
82
+ return normalize(localPosition + direction * max(travel, 0.0));
83
+ }
84
+
85
+ // Split-sum probe sample. The probe texture is supplied by the caller so a
86
+ // missing scene candidate can keep using sampleIblSpecular with the Skylight
87
+ // resources already present in the Standard material layout.
88
+ fn sampleReflectionProbeSpecular(
89
+ normal: vec3<f32>,
90
+ view: vec3<f32>,
91
+ roughness: f32,
92
+ F0: vec3<f32>,
93
+ worldPosition: vec3<f32>,
94
+ boxCenter: vec3<f32>,
95
+ boxExtents: vec3<f32>,
96
+ rotation: vec4<f32>,
97
+ probeMap: texture_cube<f32>,
98
+ probeSampler: sampler,
99
+ brdfLut: texture_2d<f32>,
100
+ brdfLutSampler: sampler,
101
+ ) -> vec3<f32> {
102
+ let NdotV = max(dot(normal, view), 0.001);
103
+ let reflection = reflect(-view, normal);
104
+ let projected = box_project(worldPosition, reflection, boxCenter, boxExtents);
105
+ let rotated = inverseRotateEnvironment(projected, rotation);
106
+ let probeDirection = vec3<f32>(rotated.x, -rotated.y, rotated.z);
107
+ let mip = roughness * 4.0;
108
+ let prefilteredColor = textureSampleLevel(probeMap, probeSampler, probeDirection, mip).rgb;
109
+ let envBRDF = textureSample(brdfLut, brdfLutSampler, vec2<f32>(NdotV, roughness)).rg;
110
+ let F = fresnelSchlickRoughness(NdotV, F0, roughness);
111
+ return prefilteredColor * (F * envBRDF.r + envBRDF.g);
112
+ }
package/src/index.ts CHANGED
@@ -108,6 +108,12 @@ export const ENGINE_MATERIAL_MODULES = [
108
108
  'forgeax_material::sprite-lit',
109
109
  ] as const;
110
110
 
111
+ /** Names of the zero-binding Standard reflection-probe shader helpers. */
112
+ export const REFLECTION_PROBE_SHADER_HELPERS = Object.freeze([
113
+ 'box_project',
114
+ 'sampleReflectionProbeSpecular',
115
+ ] as const);
116
+
111
117
  export function isEngineMaterialModule(module: string): boolean {
112
118
  return (ENGINE_MATERIAL_MODULES as readonly string[]).includes(module);
113
119
  }
@@ -0,0 +1,58 @@
1
+ #define_import_path forgeax_pbr::lighting_attenuation
2
+
3
+ // Shared punctual light optics. Range is represented by the host-derived
4
+ // inverse squared range; the shader never reconstructs authoring units.
5
+ fn evalDistanceAttenuation(dSquared : f32, invRangeSquared : f32) -> f32 {
6
+ let safeDistance = max(dSquared, 1e-4);
7
+ let window = clamp(1.0 - (safeDistance * invRangeSquared) * (safeDistance * invRangeSquared), 0.0, 1.0);
8
+ return window * window / safeDistance;
9
+ }
10
+
11
+ fn evalSpotAttenuation(
12
+ lightPos : vec3<f32>,
13
+ lightDir : vec3<f32>,
14
+ worldPos : vec3<f32>,
15
+ cosInner : f32,
16
+ cosOuter : f32,
17
+ invRangeSquared : f32,
18
+ ) -> f32 {
19
+ let toLight = lightPos - worldPos;
20
+ let dSquared = dot(toLight, toLight);
21
+ let distance = evalDistanceAttenuation(dSquared, invRangeSquared);
22
+ let direction = normalize(select(vec3<f32>(0.0, 0.0, -1.0), toLight, dSquared > 1e-4));
23
+ let cone = smoothstep(cosOuter, cosInner, dot(direction, -normalize(lightDir)));
24
+ return distance * cone;
25
+ }
26
+
27
+ // Isolated volume radiance helpers: this module has no resource bindings, so
28
+ // compute stages can reuse the exact surface attenuation and cone facts.
29
+ fn evalVolumePointRadiance(
30
+ lightPos : vec3<f32>,
31
+ colorTimesIntensity : vec3<f32>,
32
+ invRangeSquared : f32,
33
+ worldPos : vec3<f32>,
34
+ ) -> vec3<f32> {
35
+ let toLight = lightPos - worldPos;
36
+ let dSquared = max(dot(toLight, toLight), 1e-4);
37
+ return colorTimesIntensity * evalDistanceAttenuation(dSquared, invRangeSquared);
38
+ }
39
+
40
+ fn evalVolumeSpotRadiance(
41
+ lightPos : vec3<f32>,
42
+ lightDir : vec3<f32>,
43
+ colorTimesIntensity : vec3<f32>,
44
+ cosInner : f32,
45
+ cosOuter : f32,
46
+ invRangeSquared : f32,
47
+ worldPos : vec3<f32>,
48
+ ) -> vec3<f32> {
49
+ return colorTimesIntensity * evalSpotAttenuation(
50
+ lightPos, lightDir, worldPos, cosInner, cosOuter, invRangeSquared,
51
+ );
52
+ }
53
+
54
+ fn projectSpotUv(lightViewProj : mat4x4<f32>, worldPos : vec3<f32>) -> vec2<f32> {
55
+ let clip = lightViewProj * vec4<f32>(worldPos, 1.0);
56
+ let invW = select(1.0 / clip.w, 0.0, abs(clip.w) < 1e-6);
57
+ return vec2<f32>(clip.x * invW * 0.5 + 0.5, clip.y * invW * -0.5 + 0.5);
58
+ }