@forgeax/engine-shader 0.1.7 → 0.1.20

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 (117) hide show
  1. package/README.md +34 -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 +53 -2
  7. package/dist/index.mjs.map +1 -1
  8. package/dist/material-schemas.d.ts.map +1 -1
  9. package/package.json +6 -3
  10. package/src/ShaderRegistry.ts +50 -0
  11. package/src/__tests__/bloom-fxaa-tonemap.unit.test.ts +86 -12
  12. package/src/__tests__/default-standard-pbr-transmission.unit.test.ts +185 -0
  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 +48 -0
  17. package/src/__tests__/lighting-punctual.unit.test.ts +1 -1
  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-builtins.unit.test.ts +38 -1
  21. package/src/__tests__/material-contract.unit.test.ts +123 -0
  22. package/src/__tests__/motion-blur.unit.test.ts +44 -0
  23. package/src/__tests__/scene-temporal.unit.test.ts +23 -5
  24. package/src/__tests__/shader-manifest-transmission.unit.test.ts +75 -0
  25. package/src/__tests__/shader.unit.test.ts +44 -3
  26. package/src/__tests__/sprite-variants.unit.test.ts +20 -0
  27. package/src/__tests__/taa-resolve.unit.test.ts +75 -3
  28. package/src/__tests__/transmission-thickness-scale.unit.test.ts +19 -0
  29. package/src/common.wgsl +34 -65
  30. package/src/default-standard-pbr-skin.wgsl +29 -28
  31. package/src/default-standard-pbr.wgsl +257 -57
  32. package/src/fxaa.wgsl +5 -6
  33. package/src/hdrp-cluster-forward.wgsl +41 -3
  34. package/src/hdrp-deferred-lighting.wgsl +3 -27
  35. package/src/index.ts +3 -0
  36. package/src/lighting-attenuation.wgsl +58 -0
  37. package/src/lighting-directional.wgsl +31 -3
  38. package/src/lighting-punctual.wgsl +57 -12
  39. package/src/material-schemas.ts +7 -0
  40. package/src/motion-blur.wgsl +77 -0
  41. package/src/msdf-text.wgsl +4 -23
  42. package/src/pbr-temporal.wgsl +15 -2
  43. package/src/scene-temporal.wgsl +54 -0
  44. package/src/shadow-pcf.wgsl +31 -10
  45. package/src/shadow_caster.wgsl +27 -2
  46. package/src/skybox.wgsl +2 -7
  47. package/src/sprite-lit.wgsl +7 -25
  48. package/src/sprite.wgsl +7 -25
  49. package/src/taa-resolve.wgsl +76 -81
  50. package/src/tonemap.wgsl +7 -9
  51. package/src/unlit.wgsl +7 -28
  52. package/src/volume/volume-composite.wgsl +113 -0
  53. package/src/volume/volume-inject.wgsl +249 -0
  54. package/src/volume/volume-integrate.wgsl +342 -0
  55. package/src/volume/volume-temporal.wgsl +70 -0
  56. package/dist/__tests__/atmosphere-background.test.d.ts +0 -2
  57. package/dist/__tests__/atmosphere-background.test.d.ts.map +0 -1
  58. package/dist/__tests__/atmosphere-cubemap-orientation.test.d.ts +0 -2
  59. package/dist/__tests__/atmosphere-cubemap-orientation.test.d.ts.map +0 -1
  60. package/dist/__tests__/atmosphere-preetham-numeric.test.d.ts +0 -2
  61. package/dist/__tests__/atmosphere-preetham-numeric.test.d.ts.map +0 -1
  62. package/dist/__tests__/atmosphere-preetham-red.test.d.ts +0 -2
  63. package/dist/__tests__/atmosphere-preetham-red.test.d.ts.map +0 -1
  64. package/dist/__tests__/atmosphere-preetham.test.d.ts +0 -2
  65. package/dist/__tests__/atmosphere-preetham.test.d.ts.map +0 -1
  66. package/dist/__tests__/atmosphere-three-r184-reference.test.d.ts +0 -2
  67. package/dist/__tests__/atmosphere-three-r184-reference.test.d.ts.map +0 -1
  68. package/dist/__tests__/bloom-fxaa-tonemap.unit.test.d.ts +0 -2
  69. package/dist/__tests__/bloom-fxaa-tonemap.unit.test.d.ts.map +0 -1
  70. package/dist/__tests__/builtin-texture-sampling-contract.test.d.ts +0 -2
  71. package/dist/__tests__/builtin-texture-sampling-contract.test.d.ts.map +0 -1
  72. package/dist/__tests__/default-standard-pbr-alpha.unit.test.d.ts +0 -2
  73. package/dist/__tests__/default-standard-pbr-alpha.unit.test.d.ts.map +0 -1
  74. package/dist/__tests__/deferred-lighting-ssao.test.d.ts +0 -2
  75. package/dist/__tests__/deferred-lighting-ssao.test.d.ts.map +0 -1
  76. package/dist/__tests__/fog-oracle.unit.test.d.ts +0 -2
  77. package/dist/__tests__/fog-oracle.unit.test.d.ts.map +0 -1
  78. package/dist/__tests__/fog-orthographic-ray.unit.test.d.ts +0 -2
  79. package/dist/__tests__/fog-orthographic-ray.unit.test.d.ts.map +0 -1
  80. package/dist/__tests__/fog-producer-matrix.integration.test.d.ts +0 -2
  81. package/dist/__tests__/fog-producer-matrix.integration.test.d.ts.map +0 -1
  82. package/dist/__tests__/hdrp-ssao-compile.test.d.ts +0 -2
  83. package/dist/__tests__/hdrp-ssao-compile.test.d.ts.map +0 -1
  84. package/dist/__tests__/ibl-irradiance.unit.test.d.ts +0 -2
  85. package/dist/__tests__/ibl-irradiance.unit.test.d.ts.map +0 -1
  86. package/dist/__tests__/ibl-sampling.unit.test.d.ts +0 -2
  87. package/dist/__tests__/ibl-sampling.unit.test.d.ts.map +0 -1
  88. package/dist/__tests__/lighting-punctual.unit.test.d.ts +0 -2
  89. package/dist/__tests__/lighting-punctual.unit.test.d.ts.map +0 -1
  90. package/dist/__tests__/material-artifact-dedup.unit.test.d.ts +0 -2
  91. package/dist/__tests__/material-artifact-dedup.unit.test.d.ts.map +0 -1
  92. package/dist/__tests__/material-builtins.unit.test.d.ts +0 -2
  93. package/dist/__tests__/material-builtins.unit.test.d.ts.map +0 -1
  94. package/dist/__tests__/material-contract.unit.test.d.ts +0 -2
  95. package/dist/__tests__/material-contract.unit.test.d.ts.map +0 -1
  96. package/dist/__tests__/material-derived-builtins.integration.test.d.ts +0 -2
  97. package/dist/__tests__/material-derived-builtins.integration.test.d.ts.map +0 -1
  98. package/dist/__tests__/points-lines-shader.unit.test.d.ts +0 -2
  99. package/dist/__tests__/points-lines-shader.unit.test.d.ts.map +0 -1
  100. package/dist/__tests__/public-surface.unit.test.d.ts +0 -2
  101. package/dist/__tests__/public-surface.unit.test.d.ts.map +0 -1
  102. package/dist/__tests__/scene-temporal.unit.test.d.ts +0 -2
  103. package/dist/__tests__/scene-temporal.unit.test.d.ts.map +0 -1
  104. package/dist/__tests__/shader-registry-transaction.unit.test.d.ts +0 -2
  105. package/dist/__tests__/shader-registry-transaction.unit.test.d.ts.map +0 -1
  106. package/dist/__tests__/shader.unit.test.d.ts +0 -2
  107. package/dist/__tests__/shader.unit.test.d.ts.map +0 -1
  108. package/dist/__tests__/sprite-lit-shader.test.d.ts +0 -2
  109. package/dist/__tests__/sprite-lit-shader.test.d.ts.map +0 -1
  110. package/dist/__tests__/sprite-variants.unit.test.d.ts +0 -2
  111. package/dist/__tests__/sprite-variants.unit.test.d.ts.map +0 -1
  112. package/dist/__tests__/taa-resolve.unit.test.d.ts +0 -2
  113. package/dist/__tests__/taa-resolve.unit.test.d.ts.map +0 -1
  114. package/dist/__tests__/transparent-pbr.unit.test.d.ts +0 -2
  115. package/dist/__tests__/transparent-pbr.unit.test.d.ts.map +0 -1
  116. package/dist/__tests__/vertex-color-variant.unit.test.d.ts +0 -2
  117. package/dist/__tests__/vertex-color-variant.unit.test.d.ts.map +0 -1
@@ -1,16 +1,36 @@
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, PointLight, SpotLight, pointLightsBuffer, spotLightsBuffer, shadowMap, shadowSampler, sampleMaterialTexture}
3
+ #ifdef PROJECTOR_AVAILABLE
4
+ #import forgeax_view::common::{projectorTexture, projectorSampler}
5
+ #endif
3
6
  #import forgeax_pbr::temporal::{projectPbrSceneTemporal}
4
7
  #import forgeax_pbr::brdf::{f_schlick, v_smith, d_ggx}
5
8
  #import forgeax_pbr::ibl_sampling::{sampleIblDiffuse, sampleIblSpecular}
6
9
  #import forgeax_pbr::tbn::{decodeTangentSpaceNormalRg, scaleTangentSpaceNormal, applyTBN}
7
10
  #import forgeax_pbr::lighting_directional::{evalDirectionalNoShadow, evalDirectionalShadowFactor}
8
- #import forgeax_pbr::lighting_punctual::{evalPoint, evalSpot, evalSpotShadowed}
9
- #import forgeax_view::fog::{apply_fog}
11
+ #import forgeax_pbr::lighting_punctual::{evalPoint, evalSpot, evalSpotShadowed, projectSpotUv}
10
12
  #ifdef POINT_SHADOW_AVAILABLE
11
13
  #import forgeax_pbr::lighting_punctual::{evalPointShadowed}
12
14
  #import forgeax_view::common::{shadowParams}
13
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
+ }
14
34
  #ifdef CLUSTER_FORWARD_AVAILABLE
15
35
  #import forgeax_hdrp::cluster_forward::{evaluate_cluster_lights, get_ssao_intensity}
16
36
  #endif
@@ -18,6 +38,8 @@
18
38
  #pragma variant_axis STORAGE_BUFFER_AVAILABLE
19
39
  #pragma variant_axis CLUSTER_FORWARD_AVAILABLE
20
40
  #pragma variant_axis VERTEX_COLOR_AVAILABLE
41
+ #pragma variant_axis TRANSMISSION_AVAILABLE
42
+ #pragma variant_axis PROJECTOR_AVAILABLE
21
43
 
22
44
  // @forgeax/engine-shader - default-standard-pbr.wgsl
23
45
  // (feat-20260523-shader-template-instance-split M5 / T04).
@@ -32,8 +54,8 @@
32
54
  //
33
55
  // Bindings (4 BG layout slots; View + Mesh bindings inherited from
34
56
  // forgeax_view::common; shadow map + comparison sampler at view BG
35
- // @binding(3..4) per shadow-mapping feat; Skylight 7 bindings merged into
36
- // the PBR material BGL at @binding(7..13) per D-5 round-4):
57
+ // @binding(3..4) per shadow-mapping feat; Skylight 7 bindings merged after
58
+ // the Standard user region per D-5 round-4:
37
59
  //
38
60
  // @group(0) @binding(0) view uniform (see common.wgsl)
39
61
  // @group(1) @binding(0) material uniform (baseColor vec4
@@ -112,6 +134,11 @@ struct Material {
112
134
  clearcoatRoughness : f32,
113
135
  specularTint : vec3<f32>,
114
136
  normalScale : f32,
137
+ transmission : f32,
138
+ ior : f32,
139
+ thickness : f32,
140
+ attenuationColor : vec3<f32>,
141
+ attenuationDistance : f32,
115
142
  baseColorTextureCoordinatesTransform : vec4<f32>,
116
143
  baseColorTextureCoordinatesMetadata : vec4<f32>,
117
144
  metallicRoughnessTextureCoordinatesTransform : vec4<f32>,
@@ -124,6 +151,10 @@ struct Material {
124
151
  emissiveTextureCoordinatesMetadata : vec4<f32>,
125
152
  occlusionTextureCoordinatesTransform : vec4<f32>,
126
153
  occlusionTextureCoordinatesMetadata : vec4<f32>,
154
+ transmissionTextureCoordinatesTransform : vec4<f32>,
155
+ transmissionTextureCoordinatesMetadata : vec4<f32>,
156
+ thicknessTextureCoordinatesTransform : vec4<f32>,
157
+ thicknessTextureCoordinatesMetadata : vec4<f32>,
127
158
  };
128
159
 
129
160
  @group(1) @binding(0) var<uniform> material : Material;
@@ -139,6 +170,14 @@ struct Material {
139
170
  @group(1) @binding(10) var emissiveTexture : texture_2d<f32>;
140
171
  @group(1) @binding(11) var occlusionSampler : sampler;
141
172
  @group(1) @binding(12) var occlusionTexture : texture_2d<f32>;
173
+ #ifdef TRANSMISSION_AVAILABLE
174
+ @group(1) @binding(13) var transmissionSampler : sampler;
175
+ @group(1) @binding(14) var transmissionTexture : texture_2d<f32>;
176
+ @group(1) @binding(15) var thicknessSampler : sampler;
177
+ @group(1) @binding(16) var thicknessTexture : texture_2d<f32>;
178
+ @group(1) @binding(24) var transmissionBackdropSampler : sampler;
179
+ @group(1) @binding(25) var transmissionBackdropTexture : texture_2d<f32>;
180
+ #endif
142
181
 
143
182
  // Naga reflection does not retain filtering usage through the generic shared
144
183
  // helper. These compile-time-only witnesses retain the binding contract while
@@ -156,6 +195,12 @@ fn materialTextureFilteringWitness() {
156
195
  let specularTintWitness = textureSample(specularTint, specularTintSampler, vec2<f32>(0.0));
157
196
  let emissiveWitness = textureSample(emissive, emissiveSampler, vec2<f32>(0.0));
158
197
  let occlusionWitness = textureSample(occlusion, occlusionSampler, vec2<f32>(0.0));
198
+ #ifdef TRANSMISSION_AVAILABLE
199
+ let transmission = transmissionTexture;
200
+ let thickness = thicknessTexture;
201
+ let transmissionWitness = textureSample(transmission, transmissionSampler, vec2<f32>(0.0));
202
+ let thicknessWitness = textureSample(thickness, thicknessSampler, vec2<f32>(0.0));
203
+ #endif
159
204
  }
160
205
 
161
206
  // Skylight bindings merged into the PBR material BGL
@@ -163,10 +208,10 @@ fn materialTextureFilteringWitness() {
163
208
  // round-4 REVISED). The round-2 stand-alone group 4 Skylight BGL collided
164
209
  // with WebGPU's default maxBindGroups=4 in chrome-beta and blocked pbr-pl
165
210
  // pipeline-layout creation; round-4 appends the 7 Skylight entries to the
166
- // PBR material BindGroupLayout at binding 7..13. The material BG factory
211
+ // PBR material BindGroupLayout after the eight Standard texture pairs. The material BG factory
167
212
  // (mergeSkylightIntoMaterialBgl in
168
213
  // packages/runtime/src/ibl/skylight-bind-group.ts) extends the layout to
169
- // 14 entries; render-system-record assembles a single merged material
214
+ // merged entries; render-system-record assembles a single merged material
170
215
  // BindGroup (no extra setBindGroup(4) call). Identity (default) resources
171
216
  // produce ambient = 0; with a real Skylight, ibl_sampling helpers project
172
217
  // the IBL irradiance + split-sum specular into `ambient` below.
@@ -182,13 +227,13 @@ struct SkylightUniforms {
182
227
  colorB : f32,
183
228
  rotation : vec4<f32>,
184
229
  };
185
- @group(1) @binding(13) var irradianceMap : texture_cube<f32>;
186
- @group(1) @binding(14) var irradianceSampler : sampler;
187
- @group(1) @binding(15) var prefilterMap : texture_cube<f32>;
188
- @group(1) @binding(16) var prefilterSampler : sampler;
189
- @group(1) @binding(17) var brdfLut : texture_2d<f32>;
190
- @group(1) @binding(18) var brdfLutSampler : sampler;
191
- @group(1) @binding(19) var<uniform> skylight : SkylightUniforms;
230
+ @group(1) @binding(17) var irradianceMap : texture_cube<f32>;
231
+ @group(1) @binding(18) var irradianceSampler : sampler;
232
+ @group(1) @binding(19) var prefilterMap : texture_cube<f32>;
233
+ @group(1) @binding(20) var prefilterSampler : sampler;
234
+ @group(1) @binding(21) var brdfLut : texture_2d<f32>;
235
+ @group(1) @binding(22) var brdfLutSampler : sampler;
236
+ @group(1) @binding(23) var<uniform> skylight : SkylightUniforms;
192
237
 
193
238
  // feat-20260612-hdrp-ssao M7 (round 2) D-B + D-C, scope-amend-webgl2-ubo:
194
239
  // SSAO sampling lives on the HDRP unified BGL @group(2) alongside the
@@ -265,25 +310,6 @@ fn pick_channel(rgba : vec4<f32>, channelIndex : u32) -> f32 {
265
310
  }
266
311
  }
267
312
 
268
- fn applySceneFog(viewParams : View, color : vec3<f32>, alpha : f32, worldPos : vec3<f32>) -> vec4<f32> {
269
- var origin = viewParams.cameraPos;
270
- var direction = normalize(worldPos - origin);
271
- var rayDistance = length(worldPos - origin);
272
- if (viewParams.temporalProjection.z >= 0.5) {
273
- let nearH = viewParams.inverseViewProj * vec4<f32>(0.0, 0.0, 0.0, 1.0);
274
- let farH = viewParams.inverseViewProj * vec4<f32>(0.0, 0.0, 1.0, 1.0);
275
- let nearPoint = nearH.xyz / nearH.w;
276
- let farPoint = farH.xyz / farH.w;
277
- direction = normalize(farPoint - nearPoint);
278
- // Orthographic rays are parallel, but their origins differ per fragment.
279
- // Project the current fragment onto the camera plane instead of reusing
280
- // the center NDC ray origin for every pixel.
281
- origin = worldPos - direction * dot(worldPos - viewParams.cameraPos, direction);
282
- rayDistance = max(dot(worldPos - origin, direction), 0.0);
283
- }
284
- return apply_fog(viewParams.fog, FogRay(origin, direction, rayDistance), vec4<f32>(color, alpha));
285
- }
286
-
287
313
  // Light evaluators live in forgeax_pbr::lighting_directional +
288
314
  // forgeax_pbr::lighting_punctual (M5 / T02). evalDirectional consumes the
289
315
  // host's view UBO + shadowMap (LO 3.1.3 slope-scaled-bias 3x3 PCF;
@@ -305,7 +331,8 @@ fn vs_main(in : VsIn, @builtin(instance_index) idx : u32) -> VsOut {
305
331
  // Combine: entity_world * per_instance_local.
306
332
  let instanceLocal = instances[idx].localFromInstance;
307
333
  let entityWorld = meshes[0].worldFromLocal;
308
- let world = entityWorld * instanceLocal * vec4<f32>(in.pos, 1.0);
334
+ let localToWorld = entityWorld * instanceLocal;
335
+ let world = localToWorld * vec4<f32>(in.pos, 1.0);
309
336
  var out : VsOut;
310
337
  out.clip = view.worldViewProj * world;
311
338
  out.worldPos = world.xyz;
@@ -387,6 +414,19 @@ fn materialVertexColor(in : VsOut) -> vec4<f32> {
387
414
  #endif
388
415
  }
389
416
 
417
+ fn finiteScalar(value : f32, fallback : f32) -> f32 {
418
+ let bounded = clamp(value, -65504.0, 65504.0);
419
+ return select(fallback, bounded, value == value);
420
+ }
421
+
422
+ fn finiteColor(value : vec3<f32>, fallback : vec3<f32>) -> vec3<f32> {
423
+ return vec3<f32>(
424
+ finiteScalar(value.x, fallback.x),
425
+ finiteScalar(value.y, fallback.y),
426
+ finiteScalar(value.z, fallback.z),
427
+ );
428
+ }
429
+
390
430
  // linearColorDomain: transparent source and destination values are blended
391
431
  // before any display encoding. The fixed-function material blend state uses
392
432
  // this same equation for the render target's declared linear domain.
@@ -423,8 +463,17 @@ fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
423
463
  // at the pick_channel call site; values stay in {0,1,2,3}.
424
464
  let mrUv = transformedMaterialUv(material.metallicRoughnessTextureCoordinatesTransform, material.metallicRoughnessTextureCoordinatesMetadata, in);
425
465
  let mrSample = sampleMaterialTexture(metallicRoughnessTexture, metallicRoughnessSampler, mrUv, material.metallicRoughnessTextureCoordinatesMetadata.zw);
426
- let metallic = material.metallic * pick_channel(mrSample, u32(material.metallicChannel));
427
- let roughnessTex = pick_channel(mrSample, u32(material.roughnessChannel));
466
+ let metallic = clamp(
467
+ finiteScalar(material.metallic, 0.0) *
468
+ finiteScalar(pick_channel(mrSample, u32(material.metallicChannel)), 1.0),
469
+ 0.0,
470
+ 1.0,
471
+ );
472
+ let roughnessTex = clamp(
473
+ finiteScalar(pick_channel(mrSample, u32(material.roughnessChannel)), 1.0),
474
+ 0.0,
475
+ 1.0,
476
+ );
428
477
 
429
478
  // Layer-2 fail-fast: shader internal clamp keeps D_GGX finite for
430
479
  // roughness=0 even if the asset somehow bypasses layer-1 register
@@ -433,7 +482,7 @@ fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
433
482
  // so the final roughness >= 0.04 floor is preserved (texture sample is
434
483
  // a 1.0-default white when metallicRoughnessTexture is absent, so
435
484
  // multiplying after the clamp keeps the floor intact).
436
- var a = max(material.roughness, 0.04);
485
+ var a = max(finiteScalar(material.roughness, 0.5), 0.04);
437
486
  a = a * roughnessTex;
438
487
  a = a * a;
439
488
 
@@ -452,7 +501,57 @@ fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
452
501
  let specularTint = material.specularTint * sampleMaterialTexture(
453
502
  specularTintTexture, specularTintSampler, specularUv, material.specularTintTextureCoordinatesMetadata.zw,
454
503
  ).rgb;
455
- let f0 = mix(vec3<f32>(0.04) * specularTint, albedo, metallic);
504
+ let safeIor = max(finiteScalar(material.ior, 1.5), 1.0);
505
+ let dielectricF0 = pow((safeIor - 1.0) / (safeIor + 1.0), 2.0);
506
+ let f0 = mix(vec3<f32>(dielectricF0) * specularTint, albedo, metallic);
507
+ var diffuseAlbedo = albedo;
508
+ #ifdef TRANSMISSION_AVAILABLE
509
+ let transmissionUv = transformedMaterialUv(
510
+ material.transmissionTextureCoordinatesTransform,
511
+ material.transmissionTextureCoordinatesMetadata,
512
+ in,
513
+ );
514
+ let thicknessUv = transformedMaterialUv(
515
+ material.thicknessTextureCoordinatesTransform,
516
+ material.thicknessTextureCoordinatesMetadata,
517
+ in,
518
+ );
519
+ let transmissionSample = sampleMaterialTexture(
520
+ transmissionTexture,
521
+ transmissionSampler,
522
+ transmissionUv,
523
+ material.transmissionTextureCoordinatesMetadata.zw,
524
+ ).r;
525
+ let thicknessSample = sampleMaterialTexture(
526
+ thicknessTexture,
527
+ thicknessSampler,
528
+ thicknessUv,
529
+ material.thicknessTextureCoordinatesMetadata.zw,
530
+ ).g;
531
+ let transmissionFactor = clamp(
532
+ finiteScalar(material.transmission, 0.0) * finiteScalar(transmissionSample, 1.0),
533
+ 0.0,
534
+ 1.0,
535
+ );
536
+ let viewDot = finiteScalar(dot(n, v), 0.0);
537
+ let refractionFromInside = viewDot < 0.0;
538
+ let refractionNormal = select(n, -n, refractionFromInside);
539
+ let refractionEta = select(1.0 / safeIor, safeIor, refractionFromInside);
540
+ let incident = -v;
541
+ let refracted = refract(incident, refractionNormal, refractionEta);
542
+ let refractedLengthSquared = dot(refracted, refracted);
543
+ let viewCos = clamp(abs(viewDot), 0.0, 1.0);
544
+ let fresnel = clamp(f_schlick(viewCos, vec3<f32>(dielectricF0)).x, 0.0, 1.0);
545
+ // `refract` returns the zero vector for total internal reflection. Keep the
546
+ // reflection/IBL term above authoritative and remove transmission energy
547
+ // instead of sampling and adding a second reflected environment.
548
+ let transmittedEnergy = select(
549
+ 0.0,
550
+ transmissionFactor * (1.0 - metallic) * (1.0 - fresnel),
551
+ refractedLengthSquared > 1e-6,
552
+ );
553
+ diffuseAlbedo = albedo * (1.0 - transmittedEnergy);
554
+ #endif
456
555
  let coatRoughness = max(material.clearcoatRoughness, 0.04);
457
556
  let coatAlpha = coatRoughness * coatRoughness;
458
557
  let coatF = f_schlick(max(dot(n, v), 0.0), vec3<f32>(0.04)) * material.clearcoat;
@@ -487,7 +586,7 @@ fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
487
586
  // through the per-cascade light-space matrix in the fragment stage for
488
587
  // shadow-map PCF lookup (CSM, feat-20260613).
489
588
  let kD = (vec3<f32>(1.0) - f_schlick(max(dot(n, v), 0.0), f0)) * (1.0 - metallic);
490
- let iblRoughness = max(material.roughness, 0.04) * roughnessTex;
589
+ let iblRoughness = clamp(max(finiteScalar(material.roughness, 0.5), 0.04) * roughnessTex, 0.04, 1.0);
491
590
  let irradiance = sampleIblDiffuse(n, skylight.rotation, irradianceMap, irradianceSampler);
492
591
  let specularIbl = sampleIblSpecular(
493
592
  n, v, iblRoughness, f0,
@@ -501,7 +600,7 @@ fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
501
600
  // CLUSTER_FORWARD_AVAILABLE branch below can `ambient *=` the SSAO
502
601
  // factor. The non-HDRP path leaves ambient untouched.
503
602
  let skyColor = vec3<f32>(skylight.colorR, skylight.colorG, skylight.colorB);
504
- var ambient = (kD * irradiance * albedo + specularIbl) * (vec3<f32>(1.0) - coatF);
603
+ var ambient = (kD * irradiance * diffuseAlbedo + specularIbl) * (vec3<f32>(1.0) - coatF);
505
604
  if (material.clearcoat != 0.0) {
506
605
  let clearcoatIbl = sampleIblSpecular(
507
606
  n, v, coatRoughness, vec3<f32>(0.04),
@@ -529,8 +628,91 @@ fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
529
628
  ambient *= mix(1.0, ssaoFactor * ao, ssaoIntensity);
530
629
  #endif
531
630
  var color = ambient;
631
+ #ifdef TRANSMISSION_AVAILABLE
632
+ let screenUv = in.ndc.xy * vec2<f32>(0.5, -0.5) + vec2<f32>(0.5);
633
+ // Read the same renderer-owned transform tables that the vertex stage uses
634
+ // instead of adding six more interstage varyings. WebGPU guarantees only 16
635
+ // user-defined vertex output locations; keeping the affine conversion in
636
+ // the fragment stage preserves that contract and keeps thickness in the
637
+ // glTF unit-scale mesh space before measuring the refracted segment in world
638
+ // 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;
643
+ let inverseCofactor0 = cross(localToWorld1, localToWorld2);
644
+ let inverseCofactor1 = cross(localToWorld2, localToWorld0);
645
+ let inverseCofactor2 = cross(localToWorld0, localToWorld1);
646
+ let localToWorldDet = dot(localToWorld0, inverseCofactor0);
647
+ let safeLocalToWorldDet = select(1.0, localToWorldDet, abs(localToWorldDet) >= 1e-6);
648
+ let worldToLocal0 = vec3<f32>(
649
+ inverseCofactor0.x,
650
+ inverseCofactor1.x,
651
+ inverseCofactor2.x,
652
+ ) / safeLocalToWorldDet;
653
+ let worldToLocal1 = vec3<f32>(
654
+ inverseCofactor0.y,
655
+ inverseCofactor1.y,
656
+ inverseCofactor2.y,
657
+ ) / safeLocalToWorldDet;
658
+ let worldToLocal2 = vec3<f32>(
659
+ inverseCofactor0.z,
660
+ inverseCofactor1.z,
661
+ inverseCofactor2.z,
662
+ ) / safeLocalToWorldDet;
663
+ let refractedLocal = normalize(
664
+ worldToLocal0 * refracted.x +
665
+ worldToLocal1 * refracted.y +
666
+ worldToLocal2 * refracted.z,
667
+ );
668
+ let worldRefractedDirection =
669
+ localToWorld0 * refractedLocal.x +
670
+ localToWorld1 * refractedLocal.y +
671
+ localToWorld2 * refractedLocal.z;
672
+ let worldThickness = max(
673
+ finiteScalar(material.thickness, 0.0) * length(worldRefractedDirection) *
674
+ finiteScalar(thicknessSample, 1.0),
675
+ 0.0,
676
+ );
677
+ let refractedUv = screenUv + (refracted.xy - incident.xy) * worldThickness * 0.25;
678
+ let guardBand = 0.02;
679
+ let insideGuardBand = all(refractedUv >= vec2<f32>(guardBand)) &&
680
+ all(refractedUv <= vec2<f32>(1.0 - guardBand));
681
+ let backdropMipCount = textureNumLevels(transmissionBackdropTexture);
682
+ let backdropMaxLod = max(f32(backdropMipCount) - 1.0, 0.0);
683
+ let backdropLod = clamp(iblRoughness * iblRoughness * backdropMaxLod, 0.0, backdropMaxLod);
684
+ let unrefractedBackdrop = textureSampleLevel(
685
+ transmissionBackdropTexture,
686
+ transmissionBackdropSampler,
687
+ clamp(screenUv, vec2<f32>(0.0), vec2<f32>(1.0)),
688
+ backdropLod,
689
+ ).rgb;
690
+ var transmittedBackdrop = unrefractedBackdrop;
691
+ if (refractedLengthSquared > 1e-6 && insideGuardBand) {
692
+ transmittedBackdrop = textureSampleLevel(
693
+ transmissionBackdropTexture,
694
+ transmissionBackdropSampler,
695
+ clamp(refractedUv, vec2<f32>(0.0), vec2<f32>(1.0)),
696
+ backdropLod,
697
+ ).rgb;
698
+ }
699
+ let safeAttenuationColor = clamp(
700
+ finiteColor(material.attenuationColor, vec3<f32>(1.0)),
701
+ vec3<f32>(0.0),
702
+ vec3<f32>(1.0),
703
+ );
704
+ let safeAttenuationDistance = max(finiteScalar(material.attenuationDistance, 0.0), 0.0);
705
+ let attenuationExponent = worldThickness / max(safeAttenuationDistance, 1e-6);
706
+ let beerAttenuation = select(
707
+ vec3<f32>(1.0),
708
+ pow(safeAttenuationColor, vec3<f32>(attenuationExponent)),
709
+ safeAttenuationDistance > 1e-6 && worldThickness > 0.0,
710
+ );
711
+ color = color + finiteColor(transmittedBackdrop, vec3<f32>(0.0)) *
712
+ transmittedEnergy * beerAttenuation;
713
+ #endif
532
714
  let directionalShadow = evalDirectionalShadowFactor(n, in.worldPos, in.viewZ);
533
- let directionalBase = evalDirectionalNoShadow(n, v, albedo, metallic, a, f0);
715
+ let directionalBase = evalDirectionalNoShadow(n, v, diffuseAlbedo, metallic, a, f0);
534
716
  color = color + directionalShadow * directionalBase;
535
717
  if (material.clearcoat != 0.0) {
536
718
  let directionalClearcoat = evalDirectionalNoShadow(
@@ -541,7 +723,7 @@ fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
541
723
  #ifdef CLUSTER_FORWARD_AVAILABLE
542
724
  // NDC from vertex shader (perspective-divided clip-space, interpolated).
543
725
  // view_z: NDC depth for cluster Z-slice lookup.
544
- color = color + evaluate_cluster_lights(in.ndc, in.viewZ, in.worldPos, n, v, albedo, metallic, a, f0);
726
+ color = color + evaluate_cluster_lights(in.ndc, in.viewZ, in.worldPos, n, v, diffuseAlbedo, metallic, a, f0);
545
727
  if (material.clearcoat != 0.0) {
546
728
  color = color + material.clearcoat * evaluate_cluster_lights(
547
729
  in.ndc, in.viewZ, in.worldPos, n, v, vec3<f32>(0.0), 1.0, coatAlpha, vec3<f32>(0.04),
@@ -562,7 +744,7 @@ fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
562
744
  let lane = shadowParams[p.shadowAtlasLayer];
563
745
  color = color + evalPointShadowed(
564
746
  p.position, p.colorTimesIntensity, p.invRangeSquared,
565
- in.worldPos, n, v, albedo, metallic, a, f0,
747
+ in.worldPos, n, v, diffuseAlbedo, metallic, a, f0,
566
748
  p.shadowAtlasLayer, lane.x, lane.y, 0.005, 0.05,
567
749
  );
568
750
  if (material.clearcoat != 0.0) {
@@ -575,7 +757,7 @@ fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
575
757
  } else {
576
758
  color = color + evalPoint(
577
759
  p.position, p.colorTimesIntensity, p.invRangeSquared,
578
- in.worldPos, n, v, albedo, metallic, a, f0,
760
+ in.worldPos, n, v, diffuseAlbedo, metallic, a, f0,
579
761
  );
580
762
  if (material.clearcoat != 0.0) {
581
763
  color = color + material.clearcoat * evalPoint(
@@ -587,7 +769,7 @@ fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
587
769
  #else
588
770
  color = color + evalPoint(
589
771
  p.position, p.colorTimesIntensity, p.invRangeSquared,
590
- in.worldPos, n, v, albedo, metallic, a, f0,
772
+ in.worldPos, n, v, diffuseAlbedo, metallic, a, f0,
591
773
  );
592
774
  if (material.clearcoat != 0.0) {
593
775
  color = color + material.clearcoat * evalPoint(
@@ -598,6 +780,9 @@ fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
598
780
  #endif
599
781
  }
600
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;
601
786
  for (var i: u32 = 0u; i < spotCount; i = i + 1u) {
602
787
  let s = spotLightsBuffer.slots[i];
603
788
  // feat-20260625-spot-light-shadow-mapping M3 / w15 (plan-strategy D-1
@@ -607,29 +792,43 @@ fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
607
792
  // per-spot perspective matrix from the View UBO `view.spotLightViewProj`
608
793
  // array (lane N = shadowAtlasTile N; folded from the standalone binding 9
609
794
  // into the View UBO in feat-20260625 w25 for WebGL2 uniform-buffer budget);
610
- // the unshadowed path stays on evalSpot. bias hardcoded 0.005 / 0.05 aligns
611
- // with the DirectionalLight defaults (D-6); the OOB/NaN gate lives inside
612
- // evalSpotShadowed, not here.
795
+ // the unshadowed path stays on evalSpot. Receiver controls come from the
796
+ // same packed SpotLight snapshot used by the volume path.
613
797
  if (s.shadowAtlasTile >= 0) {
798
+ let projector = sampleSpotProjector(view.spotLightViewProj[s.shadowAtlasTile], in.worldPos);
614
799
  color = color + evalSpotShadowed(
615
800
  s.position, s.direction, s.colorTimesIntensity,
616
801
  s.cosInner, s.cosOuter, s.invRangeSquared,
617
802
  in.worldPos, n, v, albedo, metallic, a, f0,
618
- view.spotLightViewProj[s.shadowAtlasTile], s.shadowAtlasTile, 0.005, 0.05,
619
- );
803
+ view.spotLightViewProj[s.shadowAtlasTile], s.shadowAtlasTile, s.depthBias, s.normalBias, s.pcfKernelSize, s.shadowIntensity,
804
+ ) * projector;
620
805
  if (material.clearcoat != 0.0) {
621
806
  color = color + material.clearcoat * evalSpotShadowed(
622
807
  s.position, s.direction, s.colorTimesIntensity,
623
808
  s.cosInner, s.cosOuter, s.invRangeSquared,
624
809
  in.worldPos, n, v, vec3<f32>(0.0), 1.0, coatAlpha, vec3<f32>(0.04),
625
- view.spotLightViewProj[s.shadowAtlasTile], s.shadowAtlasTile, 0.005, 0.05,
626
- );
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;
627
826
  }
628
827
  } else {
629
828
  color = color + evalSpot(
630
829
  s.position, s.direction, s.colorTimesIntensity,
631
830
  s.cosInner, s.cosOuter, s.invRangeSquared,
632
- in.worldPos, n, v, albedo, metallic, a, f0,
831
+ in.worldPos, n, v, diffuseAlbedo, metallic, a, f0,
633
832
  );
634
833
  if (material.clearcoat != 0.0) {
635
834
  color = color + material.clearcoat * evalSpot(
@@ -644,7 +843,7 @@ fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
644
843
  let emissiveUv = transformedMaterialUv(material.emissiveTextureCoordinatesTransform, material.emissiveTextureCoordinatesMetadata, in);
645
844
  let emissiveSample = sampleMaterialTexture(emissiveTexture, emissiveSampler, emissiveUv, material.emissiveTextureCoordinatesMetadata.zw).rgb;
646
845
  color = color + material.emissive * material.emissiveIntensity * emissiveSample;
647
- return applySceneFog(view, color, alpha, in.worldPos);
846
+ return vec4<f32>(color, alpha);
648
847
  }
649
848
 
650
849
  // ── G-buffer output struct (feat-20260612-hdrp-deferred-shading M2 / w12) ──
@@ -730,8 +929,8 @@ struct TemporalVsOut {
730
929
  @location(5) uv5 : vec2<f32>,
731
930
  @location(6) uv6 : vec2<f32>,
732
931
  @location(7) uv7 : vec2<f32>,
733
- @location(8) @interpolate(linear) currentClip : vec4<f32>,
734
- @location(9) @interpolate(linear) previousClip : vec4<f32>,
932
+ @location(8) @interpolate(perspective) currentClip : vec4<f32>,
933
+ @location(9) @interpolate(perspective) previousClip : vec4<f32>,
735
934
  #ifdef VERTEX_COLOR_AVAILABLE
736
935
  @location(14) color : vec4<f32>,
737
936
  #endif
@@ -774,7 +973,7 @@ fn temporalVertexAlpha(in : TemporalVsOut) -> f32 {
774
973
 
775
974
  @fragment
776
975
  fn fs_temporal(in : TemporalVsOut) -> @location(0) vec4<f32> {
777
- var reactive = 1.0;
976
+ var reactive = 0.0;
778
977
  #if STORAGE_BUFFER_AVAILABLE == true
779
978
  reactive = meshes[0].temporal.x;
780
979
  #endif
@@ -787,6 +986,7 @@ fn fs_temporal(in : TemporalVsOut) -> @location(0) vec4<f32> {
787
986
  material.baseColorTextureCoordinatesMetadata,
788
987
  in.currentClip,
789
988
  in.previousClip,
989
+ view.temporalProjection,
790
990
  reactive,
791
991
  in.uv, in.uv1, in.uv2, in.uv3,
792
992
  in.uv4, in.uv5, in.uv6, in.uv7,
package/src/fxaa.wgsl CHANGED
@@ -33,14 +33,13 @@
33
33
  // holes in shaded surfaces -- the bug this rewrite fixes.
34
34
  //
35
35
  // Bindings (group 0):
36
- // @binding(0) screenTexture : texture_2d<f32> -- sampled LDR input
36
+ // @binding(0) screenTexture : texture_2d<f32> -- sampled display-encoded input
37
37
  // @binding(1) samp : sampler -- linear filterable sampler
38
- // linearLdrColorDomain: FXAA samples and writes linear LDR values; display
39
- // encoding happens only at the final output surface.
38
+ // display-encoded: FXAA samples and writes display-encoded values; the Output
39
+ // Transform owns the sole linear-to-sRGB conversion before this pass.
40
40
 
41
41
  #import forgeax_view::common::FullscreenOutput
42
42
  #import forgeax_view::common::fullscreen_triangle
43
- #import forgeax_view::common::linearToSrgbOetf
44
43
 
45
44
  const EDGE_THRESHOLD_MIN: f32 = 0.0312;
46
45
  const EDGE_THRESHOLD_MAX: f32 = 0.125;
@@ -106,7 +105,7 @@ fn fs_main(in: FullscreenOutput) -> @location(0) vec4<f32> {
106
105
 
107
106
  // Not on an edge (or in a near-uniform region): pass the centre through.
108
107
  if lumaRange < max(EDGE_THRESHOLD_MIN, lumaMax * EDGE_THRESHOLD_MAX) {
109
- return vec4<f32>(linearToSrgbOetf(centerColor), 1.0);
108
+ return vec4<f32>(centerColor, 1.0);
110
109
  }
111
110
 
112
111
  // Lumas of the 4 corners.
@@ -255,5 +254,5 @@ fn fs_main(in: FullscreenOutput) -> @location(0) vec4<f32> {
255
254
  }
256
255
 
257
256
  let finalColor = sampleColor(finalUv);
258
- return vec4<f32>(linearToSrgbOetf(finalColor), 1.0);
257
+ return vec4<f32>(finalColor, 1.0);
259
258
  }
@@ -6,8 +6,11 @@
6
6
 
7
7
  #define_import_path forgeax_hdrp::cluster_forward
8
8
 
9
- #import forgeax_pbr::lighting_punctual::{evalPoint, evalSpot, evalSpotShadowed}
9
+ #import forgeax_pbr::lighting_punctual::{evalPoint, evalSpot, evalSpotShadowed, projectSpotUv}
10
10
  #import forgeax_view::common::{view}
11
+ #ifdef PROJECTOR_AVAILABLE
12
+ #import forgeax_view::common::{projectorTexture, projectorSampler}
13
+ #endif
11
14
  #ifdef POINT_SHADOW_AVAILABLE
12
15
  #import forgeax_pbr::lighting_punctual::{evalPointShadowed}
13
16
  #endif
@@ -16,13 +19,15 @@
16
19
 
17
20
  const KIND_POINT: u32 = 0u;
18
21
  const KIND_SPOT: u32 = 1u;
22
+ const PROJECTOR_ONLY_TILE: i32 = -2;
19
23
 
20
24
  // LightSlot is the byte-frozen 64B std430/std140 transport contract.
21
25
  // [0..2] position, [3] invRangeSquared
22
26
  // [4..6] colorTimesIntensity, [7] cosInner
23
27
  // [8..10] direction, [11] cosOuter
24
28
  // [12] raw kind u32, [13] shadow identity i32
25
- // [14..15] point near/far f32 bits; spot keeps these lanes zero.
29
+ // [14] point near / spot shadow intensity f32 bits
30
+ // [15] point far / spot PCF kernel width f32 bits
26
31
  struct LightSlot {
27
32
  position : vec4<f32>,
28
33
  color : vec4<f32>,
@@ -32,6 +37,23 @@ struct LightSlot {
32
37
 
33
38
  const LIGHTSLOT_BYTE_SIZE: u32 = 64u;
34
39
 
40
+ fn sampleClusterSpotProjector(lightViewProj : mat4x4<f32>, worldPos : vec3<f32>) -> vec3<f32> {
41
+ #ifdef PROJECTOR_AVAILABLE
42
+ let clip = lightViewProj * vec4<f32>(worldPos, 1.0);
43
+ // The projector is a cookie, not an extra cone. A fragment that cannot be
44
+ // projected (or lies outside the tile) keeps the analytic Spot contribution
45
+ // intact, matching Three's SpotLightNode fail-open map gate.
46
+ if (abs(clip.w) < 1e-6) { return vec3<f32>(1.0); }
47
+ let uv = projectSpotUv(lightViewProj, worldPos);
48
+ if (any(uv < vec2<f32>(0.0)) || any(uv > vec2<f32>(1.0))) {
49
+ return vec3<f32>(1.0);
50
+ }
51
+ return textureSampleLevel(projectorTexture, projectorSampler, uv, 0.0).rgb;
52
+ #else
53
+ return vec3<f32>(1.0);
54
+ #endif
55
+ }
56
+
35
57
  struct ClusterUniform {
36
58
  grid : vec4<u32>,
37
59
  near_far_log : vec4<f32>,
@@ -119,6 +141,7 @@ fn evaluate_cluster_light(
119
141
  if (kind == KIND_SPOT) {
120
142
  let tile = bitcast<i32>(light.kind_and_shadow.y);
121
143
  if (tile >= 0) {
144
+ let projector = sampleClusterSpotProjector(view.spotLightViewProj[tile], world_pos);
122
145
  return evalSpotShadowed(
123
146
  light.position.xyz, light.direction.xyz, light.color.xyz,
124
147
  // LightSlot packs cosInner in color.w and cosOuter in direction.w;
@@ -126,7 +149,22 @@ fn evaluate_cluster_light(
126
149
  light.color.w, light.direction.w, light.position.w,
127
150
  world_pos, normal, view_dir, base_color, metallic, alpha_sq, f0,
128
151
  view.spotLightViewProj[tile], tile, 0.005, 0.05,
129
- );
152
+ clamp(
153
+ round(select(3.0, bitcast<f32>(light.kind_and_shadow.w),
154
+ bitcast<f32>(light.kind_and_shadow.w) > 0.5)),
155
+ 1.0,
156
+ 5.0,
157
+ ),
158
+ clamp(bitcast<f32>(light.kind_and_shadow.z), 0.0, 1.0),
159
+ ) * projector;
160
+ }
161
+ if (tile == PROJECTOR_ONLY_TILE) {
162
+ let projector = sampleClusterSpotProjector(view.spotLightViewProj[0], world_pos);
163
+ return evalSpot(
164
+ light.position.xyz, light.direction.xyz, light.color.xyz,
165
+ light.color.w, light.direction.w, light.position.w,
166
+ world_pos, normal, view_dir, base_color, metallic, alpha_sq, f0,
167
+ ) * projector;
130
168
  }
131
169
  return evalSpot(
132
170
  light.position.xyz, light.direction.xyz, light.color.xyz,