@forgeax/engine-shader 0.1.27 → 0.1.29

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 (73) hide show
  1. package/NOTICE +35 -0
  2. package/README.md +39 -0
  3. package/dist/ShaderRegistry.d.ts +8 -0
  4. package/dist/ShaderRegistry.d.ts.map +1 -1
  5. package/dist/index.d.ts +6 -2
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.mjs +312 -71
  8. package/dist/index.mjs.map +1 -1
  9. package/dist/material/artifact-types.d.ts +12 -2
  10. package/dist/material/artifact-types.d.ts.map +1 -1
  11. package/dist/material-schemas.d.ts +5 -0
  12. package/dist/material-schemas.d.ts.map +1 -1
  13. package/package.json +5 -4
  14. package/src/ShaderRegistry.ts +10 -0
  15. package/src/__tests__/auto-exposure-graph.unit.test.ts +59 -0
  16. package/src/__tests__/bloom-fxaa-tonemap.unit.test.ts +30 -15
  17. package/src/__tests__/builtin-texture-sampling-contract.test.ts +1 -1
  18. package/src/__tests__/default-standard-pbr-alpha.unit.test.ts +2 -1
  19. package/src/__tests__/default-standard-pbr-transmission.unit.test.ts +167 -6
  20. package/src/__tests__/direct-light-layout.unit.test.ts +1 -1
  21. package/src/__tests__/lighting-punctual.unit.test.ts +29 -1
  22. package/src/__tests__/ltc-provenance.unit.test.ts +13 -2
  23. package/src/__tests__/material-builtins.unit.test.ts +1 -0
  24. package/src/__tests__/material-contract.unit.test.ts +42 -9
  25. package/src/__tests__/material-derived-builtins.integration.test.ts +18 -2
  26. package/src/__tests__/probe-lighting-composition.unit.test.ts +1 -1
  27. package/src/__tests__/reflection-probe-sampling.unit.test.ts +23 -2
  28. package/src/__tests__/scene-temporal.unit.test.ts +10 -0
  29. package/src/__tests__/shader.unit.test.ts +1 -0
  30. package/src/__tests__/sprite-variants.unit.test.ts +1 -0
  31. package/src/__tests__/ssr-artifact.unit.test.ts +141 -0
  32. package/src/__tests__/ssr-bgl.integration.test.ts +185 -0
  33. package/src/__tests__/standard-output-domain.unit.test.ts +36 -0
  34. package/src/__tests__/standard-pbr-artifact-receipt.unit.test.ts +93 -0
  35. package/src/__tests__/standard-surface-pass-evaluation.integration.test.ts +16 -4
  36. package/src/__tests__/surface-v1.unit.test.ts +6 -0
  37. package/src/__tests__/taa-resolve.unit.test.ts +20 -9
  38. package/src/__tests__/transmission-thickness-scale.unit.test.ts +6 -1
  39. package/src/__tests__/transparent-pbr.unit.test.ts +1 -1
  40. package/src/__tests__/vertex-color-variant.unit.test.ts +4 -1
  41. package/src/atmosphere-background.wgsl +3 -3
  42. package/src/auto-exposure-meter.wgsl +179 -0
  43. package/src/color-lut.wgsl +17 -0
  44. package/src/common.wgsl +7 -5
  45. package/src/default-standard-pbr-skin.wgsl +142 -40
  46. package/src/default-standard-pbr.wgsl +198 -82
  47. package/src/default_standard_surface.wgsl +56 -19
  48. package/src/fxaa.wgsl +9 -5
  49. package/src/ibl-sampling.wgsl +30 -6
  50. package/src/index.ts +189 -0
  51. package/src/lighting-punctual.wgsl +5 -6
  52. package/src/material/artifact-types.ts +109 -63
  53. package/src/material-schemas.ts +84 -8
  54. package/src/output-encoding.wgsl +10 -0
  55. package/src/pbr-temporal.wgsl +9 -1
  56. package/src/scene-temporal.wgsl +2 -0
  57. package/src/shadow-pcf.wgsl +6 -8
  58. package/src/shadow-surface.wgsl +16 -0
  59. package/src/shadow_caster.wgsl +99 -61
  60. package/src/sprite-lit.wgsl +4 -4
  61. package/src/sprite.wgsl +3 -3
  62. package/src/ssr-compose.wgsl +129 -0
  63. package/src/ssr-hiz-reduce.wgsl +53 -0
  64. package/src/ssr-hiz.wgsl +66 -0
  65. package/src/ssr-temporal.wgsl +305 -0
  66. package/src/ssr-trace.wgsl +784 -0
  67. package/src/standard-cluster.wgsl +6 -4
  68. package/src/standard-surface.wgsl +696 -0
  69. package/src/surface_v1.wgsl +6 -0
  70. package/src/taa-resolve.wgsl +222 -28
  71. package/src/tbn.wgsl +1 -1
  72. package/src/tonemap.wgsl +37 -18
  73. package/src/unlit.wgsl +3 -3
@@ -0,0 +1,696 @@
1
+ #define_import_path forgeax_material::standard_surface
2
+ #import forgeax_view::common::{view, sampleMaterialTexture}
3
+ #import forgeax_pbr::brdf::{f_schlick}
4
+ #import forgeax_pbr::ibl_sampling::{sampleIblDiffuse, sampleIblSpecular, sampleReflectionProbeSpecular}
5
+ #import forgeax_pbr::lighting_probe::{evaluateProbeDiffuse}
6
+ #import forgeax_pbr::tbn::{decodeTangentSpaceNormalRg, scaleTangentSpaceNormal, applyTBN}
7
+ #import forgeax_pbr::lighting_directional::{evalDirectionalNoShadow, evalDirectionalShadowFactor}
8
+ #ifdef CLUSTER_FORWARD_AVAILABLE
9
+ #import forgeax_standard::cluster::{evaluateStandardClusterLights, get_ssao_intensity}
10
+ #endif
11
+
12
+ // Shared Standard material ABI and surface evaluation. Geometry adapters supply
13
+ // world-space inputs; Render owns View, material/environment, Cluster, and shadow
14
+ // resources. This module imports no scene mesh or instance transform table.
15
+
16
+ struct Material {
17
+ baseColor : vec4<f32>,
18
+ metallic : f32,
19
+ roughness : f32,
20
+ // Channel selectors (D-8): 4 independent f32 entries split out of the
21
+ // legacy `channelMap : vec4<u32>`. Each value is a small integer encoded
22
+ // as f32 in {0.0, 1.0, 2.0, 3.0} indexing into {r,g,b,a}. Default glTF 2.0
23
+ // packing = (metallicChannel=2, roughnessChannel=1, aoChannel=0,
24
+ // extraChannel=0). The fragment casts to u32 at the pick site -- f32 is
25
+ // chosen so the schema entry type aligns with the 14-tuple
26
+ // MaterialParamType numeric-run packing rule (4-byte stride, 16 B span).
27
+ metallicChannel : f32,
28
+ roughnessChannel : f32,
29
+ aoChannel : f32,
30
+ extraChannel : f32,
31
+ // vec3 align=16 inserts 8 implicit padding bytes after extraChannel
32
+ // (offsets 40..48) so emissive lands at offset 48.
33
+ emissive : vec3<f32>,
34
+ emissiveIntensity : f32,
35
+ occlusionStrength : f32,
36
+ alphaCutoff : f32,
37
+ clearcoat : f32,
38
+ clearcoatRoughness : f32,
39
+ specularTint : vec3<f32>,
40
+ normalScale : f32,
41
+ transmission : f32,
42
+ ior : f32,
43
+ thickness : f32,
44
+ attenuationColor : vec3<f32>,
45
+ attenuationDistance : f32,
46
+ baseColorTextureCoordinatesTransform : vec4<f32>,
47
+ baseColorTextureCoordinatesMetadata : vec4<f32>,
48
+ metallicRoughnessTextureCoordinatesTransform : vec4<f32>,
49
+ metallicRoughnessTextureCoordinatesMetadata : vec4<f32>,
50
+ normalTextureCoordinatesTransform : vec4<f32>,
51
+ normalTextureCoordinatesMetadata : vec4<f32>,
52
+ specularTintTextureCoordinatesTransform : vec4<f32>,
53
+ specularTintTextureCoordinatesMetadata : vec4<f32>,
54
+ emissiveTextureCoordinatesTransform : vec4<f32>,
55
+ emissiveTextureCoordinatesMetadata : vec4<f32>,
56
+ occlusionTextureCoordinatesTransform : vec4<f32>,
57
+ occlusionTextureCoordinatesMetadata : vec4<f32>,
58
+ transmissionTextureCoordinatesTransform : vec4<f32>,
59
+ transmissionTextureCoordinatesMetadata : vec4<f32>,
60
+ thicknessTextureCoordinatesTransform : vec4<f32>,
61
+ thicknessTextureCoordinatesMetadata : vec4<f32>,
62
+ };
63
+
64
+ @group(1) @binding(0) var<uniform> material : Material;
65
+ @group(1) @binding(1) var baseColorTexture_sampler : sampler;
66
+ @group(1) @binding(2) var baseColorTexture : texture_2d<f32>;
67
+ @group(1) @binding(3) var metallicRoughnessTexture_sampler : sampler;
68
+ @group(1) @binding(4) var metallicRoughnessTexture : texture_2d<f32>;
69
+ @group(1) @binding(5) var normalTexture_sampler : sampler;
70
+ @group(1) @binding(6) var normalTexture : texture_2d<f32>;
71
+ @group(1) @binding(7) var specularTintSampler : sampler;
72
+ @group(1) @binding(8) var specularTintTexture : texture_2d<f32>;
73
+ @group(1) @binding(9) var emissiveTexture_sampler : sampler;
74
+ @group(1) @binding(10) var emissiveTexture : texture_2d<f32>;
75
+ @group(1) @binding(11) var occlusionTexture_sampler : sampler;
76
+ @group(1) @binding(12) var occlusionTexture : texture_2d<f32>;
77
+ @group(1) @binding(13) var transmissionSampler : sampler;
78
+ @group(1) @binding(14) var transmissionTexture : texture_2d<f32>;
79
+ @group(1) @binding(15) var thicknessSampler : sampler;
80
+ @group(1) @binding(16) var thicknessTexture : texture_2d<f32>;
81
+ #ifdef TRANSMISSION_AVAILABLE
82
+ @group(1) @binding(24) var transmissionBackdropSampler : sampler;
83
+ @group(1) @binding(25) var transmissionBackdropTexture : texture_2d<f32>;
84
+ #endif
85
+
86
+ // Naga reflection does not retain filtering usage through the generic shared
87
+ // helper. These compile-time-only witnesses retain the binding contract while
88
+ // every runtime material sample still goes through sampleMaterialTexture.
89
+ fn materialTextureFilteringWitness() {
90
+ let base = baseColorTexture;
91
+ let metallicRoughness = metallicRoughnessTexture;
92
+ let normal = normalTexture;
93
+ let specularTint = specularTintTexture;
94
+ let emissive = emissiveTexture;
95
+ let occlusion = occlusionTexture;
96
+ let baseWitness = textureSample(base, baseColorTexture_sampler, vec2<f32>(0.0));
97
+ let metallicRoughnessWitness = textureSample(metallicRoughness, metallicRoughnessTexture_sampler, vec2<f32>(0.0));
98
+ let normalWitness = textureSample(normal, normalTexture_sampler, vec2<f32>(0.0));
99
+ let specularTintWitness = textureSample(specularTint, specularTintSampler, vec2<f32>(0.0));
100
+ let emissiveWitness = textureSample(emissive, emissiveTexture_sampler, vec2<f32>(0.0));
101
+ let occlusionWitness = textureSample(occlusion, occlusionTexture_sampler, vec2<f32>(0.0));
102
+ let transmission = transmissionTexture;
103
+ let thickness = thicknessTexture;
104
+ let transmissionWitness = textureSample(transmission, transmissionSampler, vec2<f32>(0.0));
105
+ let thicknessWitness = textureSample(thickness, thicknessSampler, vec2<f32>(0.0));
106
+ }
107
+
108
+ // Skylight bindings merged into the PBR material BGL
109
+ // (feat-20260520-skylight-ibl-cubemap M3 / t48 round-4 amend per D-5
110
+ // round-4 REVISED). The round-2 stand-alone group 4 Skylight BGL collided
111
+ // with WebGPU's default maxBindGroups=4 in chrome-beta and blocked pbr-pl
112
+ // pipeline-layout creation; round-4 appends the 7 Skylight entries to the
113
+ // PBR material BindGroupLayout after the eight Standard texture pairs. The material BG factory
114
+ // (mergeSkylightIntoMaterialBgl in
115
+ // packages/runtime/src/ibl/skylight-bind-group.ts) extends the layout to
116
+ // merged entries; render-system-record assembles a single merged material
117
+ // BindGroup (no extra setBindGroup(4) call). Identity (default) resources
118
+ // produce ambient = 0; with a real Skylight, ibl_sampling helpers project
119
+ // the IBL irradiance + split-sum specular into `ambient` below.
120
+ struct SkylightUniforms {
121
+ intensity : f32,
122
+ // The former pad0/1/2 lanes now carry the linear-space ambient `color` tint
123
+ // (downstream integration #4). Kept as three scalars (NOT vec3<f32>) so the
124
+ // struct stays exactly 16 B: a vec3 has 16-byte alignment in std140 and
125
+ // would push `color` to offset 16. The rotation vec4 follows at offset 16,
126
+ // so the host writes one 32 B payload.
127
+ colorR : f32,
128
+ colorG : f32,
129
+ colorB : f32,
130
+ rotation : vec4<f32>,
131
+ };
132
+ @group(1) @binding(17) var irradianceMap : texture_cube<f32>;
133
+ @group(1) @binding(18) var irradianceSampler : sampler;
134
+ @group(1) @binding(19) var prefilterMap : texture_cube<f32>;
135
+ @group(1) @binding(20) var prefilterSampler : sampler;
136
+ @group(1) @binding(21) var brdfLut : texture_2d<f32>;
137
+ @group(1) @binding(22) var brdfLutSampler : sampler;
138
+ @group(1) @binding(23) var<uniform> skylight : SkylightUniforms;
139
+
140
+ #ifdef PROBE_BLEND_AVAILABLE
141
+ // Optional consumer lane. The host must provide the retained 160B record
142
+ // buffer and matching BGL before enabling this capability.
143
+ @group(3) @binding(1) var<storage, read> probeBlendRecords : array<vec4<f32>>;
144
+ #endif
145
+
146
+ // feat-20260612-hdrp-ssao M7 (round 2) D-B + D-C, scope-amend-webgl2-ubo:
147
+ // SSAO sampling lives on the HDRP unified BGL @group(2) alongside the
148
+ // cluster bindings (binding 7 = ssao texture, binding 8 = sampler). The
149
+ // intensity scalar is folded into `cluster_uniform.near_far_log.w` (the
150
+ // previously-unused std140 pad lane on @binding(6)) — declaring a
151
+ // dedicated UBO at @binding(9) overflows WebGL2's
152
+ // `max_uniform_buffers_per_shader_stage = 11` budget on rhi-wgpu's
153
+ // fallback path. Disabled SSAO path binds 1x1 white at @binding(7) + the
154
+ // host writes intensity=0 into the cluster pad lane, so the synthesis
155
+ // collapses identically.
156
+ #ifdef CLUSTER_FORWARD_AVAILABLE
157
+ @group(2) @binding(7) var ssaoBlurredTexture : texture_2d<f32>;
158
+ @group(2) @binding(8) var ssaoBlurredSampler : sampler;
159
+ #endif
160
+
161
+
162
+ struct VsOut {
163
+ @builtin(position) clip : vec4<f32>,
164
+ @location(0) worldPos : vec3<f32>,
165
+ @location(1) worldNormal : vec3<f32>,
166
+ @location(2) uv : vec2<f32>,
167
+ @location(3) worldTangent : vec4<f32>,
168
+ #ifdef TRANSMISSION_AVAILABLE
169
+ // Keep mesh and instance storage vertex-only; transmission receives the
170
+ // transform basis from the vertex stage below.
171
+ @location(4) @interpolate(flat) transmissionBasisFirst : vec4<f32>,
172
+ #endif
173
+ // feat-city-glb multi-UV tiling: second UV set inter-stage varying. Uses the
174
+ // previously-vacant @location(5) so @location(6)/(7) stay byte-stable with
175
+ // the prior layout (CSM M5/w19).
176
+ @location(5) uvOne : vec2<f32>,
177
+ @location(8) uvTwo : vec2<f32>,
178
+ @location(9) uvThree : vec2<f32>,
179
+ @location(10) uvFour : vec2<f32>,
180
+ @location(11) uvFive : vec2<f32>,
181
+ @location(12) uvSix : vec2<f32>,
182
+ @location(13) uvSeven : vec2<f32>,
183
+ #ifdef VERTEX_COLOR_AVAILABLE
184
+ @location(14) color : vec4<f32>,
185
+ #endif
186
+ @location(6) ndc : vec4<f32>, // NDC for HDRP cluster lookup; .w = transmission basis tail
187
+ // feat-20260609-hdrp-cluster-fragment-ggx M4.5-followup: view-space z is
188
+ // needed by ndc_position_to_cluster (slice index uses log-z mapping that
189
+ // takes negative view_z, NOT NDC z which is [0,1]). Earlier `in.ndc.z`
190
+ // pass through view_z slot collapsed every fragment to slice 0, so cube
191
+ // surfaces -- whose cluster cells were unrelated to the floor's slice 0
192
+ // hot zone -- received zero light. Forward view_z explicitly. M5 / w19:
193
+ // also feeds CSM cascade selection in evalDirectional.
194
+ @location(7) viewZ : f32,
195
+ #ifdef TRANSMISSION_AVAILABLE
196
+ @location(15) @interpolate(flat) transmissionBasisSecond : vec4<f32>,
197
+ #endif
198
+ };
199
+
200
+ fn pick_channel(rgba : vec4<f32>, channelIndex : u32) -> f32 {
201
+ // Branch-free channel pick. WGSL has no array<f32, 4>(rgba) addressable
202
+ // helper; manual switch keeps the path uniform.
203
+ switch (channelIndex) {
204
+ case 0u: { return rgba.r; }
205
+ case 1u: { return rgba.g; }
206
+ case 2u: { return rgba.b; }
207
+ default: { return rgba.a; }
208
+ }
209
+ }
210
+
211
+ // Directional light evaluation lives in forgeax_pbr::lighting_directional.
212
+ // Local lights are evaluated by forgeax_standard::cluster when the clustered
213
+ // variant is selected; keeping that ownership in one module avoids a second
214
+ // View-group light-array ABI.
215
+
216
+
217
+
218
+ fn transformedMaterialUv(transform : vec4<f32>, metadata : vec4<f32>, in : VsOut) -> vec2<f32> {
219
+ var source = in.uv;
220
+ if (metadata.x >= 1.0) { source = in.uvOne; }
221
+ if (metadata.x >= 2.0) { source = in.uvTwo; }
222
+ if (metadata.x >= 3.0) { source = in.uvThree; }
223
+ if (metadata.x >= 4.0) { source = in.uvFour; }
224
+ if (metadata.x >= 5.0) { source = in.uvFive; }
225
+ if (metadata.x >= 6.0) { source = in.uvSix; }
226
+ if (metadata.x >= 7.0) { source = in.uvSeven; }
227
+ let scaled = source * transform.zw;
228
+ let angle = metadata.y;
229
+ let c = cos(angle);
230
+ let s = sin(angle);
231
+ return vec2<f32>(scaled.x * c - scaled.y * s, scaled.x * s + scaled.y * c) + transform.xy;
232
+ }
233
+
234
+ fn materialAlpha(baseSample : vec4<f32>) -> f32 {
235
+ return material.baseColor.a * baseSample.a;
236
+ }
237
+
238
+ fn materialVertexColor(in : VsOut) -> vec4<f32> {
239
+ #ifdef VERTEX_COLOR_AVAILABLE
240
+ return in.color;
241
+ #else
242
+ return vec4<f32>(1.0);
243
+ #endif
244
+ }
245
+
246
+ fn finiteScalar(value : f32, fallback : f32) -> f32 {
247
+ let bounded = clamp(value, -65504.0, 65504.0);
248
+ return select(fallback, bounded, value == value);
249
+ }
250
+
251
+ fn finiteColor(value : vec3<f32>, fallback : vec3<f32>) -> vec3<f32> {
252
+ return vec3<f32>(
253
+ finiteScalar(value.x, fallback.x),
254
+ finiteScalar(value.y, fallback.y),
255
+ finiteScalar(value.z, fallback.z),
256
+ );
257
+ }
258
+
259
+ // linearColorDomain: transparent source and destination values are blended
260
+ // before any display encoding. The fixed-function material blend state uses
261
+ // this same equation for the render target's declared linear domain.
262
+ fn blendLinearTransparent(
263
+ source : vec3<f32>,
264
+ destination : vec3<f32>,
265
+ alpha : f32,
266
+ ) -> vec3<f32> {
267
+ return source * alpha + destination * (1.0 - alpha);
268
+ }
269
+
270
+ // linearHdrColorDomain: fs_main writes linear HDR when its target is HDR.
271
+ // toneStageInput: the value remains linear until the fullscreen tone stage.
272
+
273
+ fn alphaTest(alpha : f32) {
274
+ if (material.alphaCutoff > 0.0 && alpha <= material.alphaCutoff) {
275
+ discard;
276
+ }
277
+ }
278
+
279
+ // The renderer supplies the retained ProbeBlendRecord at the per-object
280
+ // consumer boundary. This helper deliberately accepts only diffuse inputs;
281
+ // Skylight/ReflectionProbe continue to own specular.
282
+ fn composeProbeDiffuse(
283
+ shPreblend : array<vec4<f32>, 9>,
284
+ localBlendFraction : f32,
285
+ normal : vec3<f32>,
286
+ skyIrradiance : vec3<f32>,
287
+ kD : vec3<f32>,
288
+ albedo : vec3<f32>,
289
+ metallic : f32,
290
+ ) -> vec3<f32> {
291
+ return evaluateProbeDiffuse(shPreblend, localBlendFraction, normal, skyIrradiance, kD, albedo, metallic);
292
+ }
293
+
294
+ struct StandardPbrOutput {
295
+ @location(0) color : vec4<f32>,
296
+ #ifdef REFLECTION_FALLBACK_AVAILABLE
297
+ // Linear HDR environment contribution from this same Standard BRDF callsite.
298
+ // The detached producer names this second target S_fallback.
299
+ @location(1) reflectionFallback : vec4<f32>,
300
+ #endif
301
+ };
302
+
303
+ // Shared surface evaluator: vertex producers supply geometry and factor values;
304
+ // material textures, direct lights, IBL, and shadow sampling retain one owner.
305
+ struct StandardSurfaceFactors {
306
+ baseColor : vec4<f32>,
307
+ metallic : f32,
308
+ roughness : f32,
309
+ emissive : vec3<f32>,
310
+ emissiveIntensity : f32,
311
+ clearcoat : f32,
312
+ clearcoatRoughness : f32,
313
+ lighting : bool,
314
+ receiveShadows : bool,
315
+ };
316
+
317
+
318
+
319
+ fn evaluateStandardSurface(in : VsOut, factors : StandardSurfaceFactors) -> StandardPbrOutput {
320
+ let baseUv = transformedMaterialUv(material.baseColorTextureCoordinatesTransform, material.baseColorTextureCoordinatesMetadata, in);
321
+ let baseSample = sampleMaterialTexture(baseColorTexture, baseColorTexture_sampler, baseUv, material.baseColorTextureCoordinatesMetadata.zw);
322
+ let vertexColor = materialVertexColor(in);
323
+ alphaTest(factors.baseColor.a * baseSample.a * vertexColor.a);
324
+ let alpha = factors.baseColor.a * baseSample.a * vertexColor.a;
325
+ let albedo = factors.baseColor.rgb * baseSample.rgb * vertexColor.rgb;
326
+
327
+ // Metallic-roughness texture sampling with per-field channel selectors
328
+ // (D-8): glTF 2.0 default layout B=metallic, G=roughness, R=occlusion is
329
+ // encoded by the host as 4 independent f32 selectors in the merged UBO
330
+ // (metallicChannel/roughnessChannel/aoChannel/extraChannel). Cast to u32
331
+ // at the pick_channel call site; values stay in {0,1,2,3}.
332
+ let mrUv = transformedMaterialUv(material.metallicRoughnessTextureCoordinatesTransform, material.metallicRoughnessTextureCoordinatesMetadata, in);
333
+ let mrSample = sampleMaterialTexture(metallicRoughnessTexture, metallicRoughnessTexture_sampler, mrUv, material.metallicRoughnessTextureCoordinatesMetadata.zw);
334
+ let metallic = clamp(
335
+ finiteScalar(factors.metallic, 0.0) *
336
+ finiteScalar(pick_channel(mrSample, u32(material.metallicChannel)), 1.0),
337
+ 0.0,
338
+ 1.0,
339
+ );
340
+ let roughnessTex = clamp(
341
+ finiteScalar(pick_channel(mrSample, u32(material.roughnessChannel)), 1.0),
342
+ 0.0,
343
+ 1.0,
344
+ );
345
+
346
+ // Layer-2 fail-fast: shader internal clamp keeps D_GGX finite for
347
+ // roughness=0 even if the asset somehow bypasses layer-1 register
348
+ // fail-fast (plan-strategy section 5.3 + AC-02 (b)+(c)). Apply to the
349
+ // material scalar prior to the channelMap-extracted texture multiplier
350
+ // so the final roughness >= 0.04 floor is preserved (texture sample is
351
+ // a 1.0-default white when metallicRoughnessTexture is absent, so
352
+ // multiplying after the clamp keeps the floor intact).
353
+ var a = max(finiteScalar(factors.roughness, 0.5), 0.04);
354
+ a = a * roughnessTex;
355
+ a = a * a;
356
+
357
+ // TBN basis composed via forgeax_pbr::tbn helpers; default fallback
358
+ // (defaultNormalTextureView) RG=(128,128) -> tangent (0,0,1) -> world n
359
+ // unchanged.
360
+ let normalUv = transformedMaterialUv(material.normalTextureCoordinatesTransform, material.normalTextureCoordinatesMetadata, in);
361
+ let normSampleRg = sampleMaterialTexture(normalTexture, normalTexture_sampler, normalUv, material.normalTextureCoordinatesMetadata.zw).rg;
362
+ let normTangent = scaleTangentSpaceNormal(
363
+ decodeTangentSpaceNormalRg(normSampleRg), material.normalScale,
364
+ );
365
+ let n = applyTBN(in.worldNormal, in.worldTangent, normTangent);
366
+
367
+ let v = normalize(view.cameraPos - in.worldPos);
368
+ let specularUv = transformedMaterialUv(material.specularTintTextureCoordinatesTransform, material.specularTintTextureCoordinatesMetadata, in);
369
+ let specularTint = material.specularTint * sampleMaterialTexture(
370
+ specularTintTexture, specularTintSampler, specularUv, material.specularTintTextureCoordinatesMetadata.zw,
371
+ ).rgb;
372
+ let safeIor = max(finiteScalar(material.ior, 1.5), 1.0);
373
+ let dielectricF0 = pow((safeIor - 1.0) / (safeIor + 1.0), 2.0);
374
+ let f0 = mix(vec3<f32>(dielectricF0) * specularTint, albedo, metallic);
375
+ var diffuseAlbedo = albedo;
376
+ #ifdef TRANSMISSION_AVAILABLE
377
+ let transmissionUv = transformedMaterialUv(
378
+ material.transmissionTextureCoordinatesTransform,
379
+ material.transmissionTextureCoordinatesMetadata,
380
+ in,
381
+ );
382
+ let thicknessUv = transformedMaterialUv(
383
+ material.thicknessTextureCoordinatesTransform,
384
+ material.thicknessTextureCoordinatesMetadata,
385
+ in,
386
+ );
387
+ let transmissionSample = sampleMaterialTexture(
388
+ transmissionTexture,
389
+ transmissionSampler,
390
+ transmissionUv,
391
+ material.transmissionTextureCoordinatesMetadata.zw,
392
+ ).r;
393
+ let thicknessSample = sampleMaterialTexture(
394
+ thicknessTexture,
395
+ thicknessSampler,
396
+ thicknessUv,
397
+ material.thicknessTextureCoordinatesMetadata.zw,
398
+ ).g;
399
+ let transmissionFactor = clamp(
400
+ finiteScalar(material.transmission, 0.0) * finiteScalar(transmissionSample, 1.0),
401
+ 0.0,
402
+ 1.0,
403
+ );
404
+ let viewDot = finiteScalar(dot(n, v), 0.0);
405
+ let refractionFromInside = viewDot < 0.0;
406
+ let refractionNormal = select(n, -n, refractionFromInside);
407
+ let refractionEta = select(1.0 / safeIor, safeIor, refractionFromInside);
408
+ let incident = -v;
409
+ let refracted = refract(incident, refractionNormal, refractionEta);
410
+ let refractedLengthSquared = dot(refracted, refracted);
411
+ let viewCos = clamp(abs(viewDot), 0.0, 1.0);
412
+ let fresnel = clamp(f_schlick(viewCos, vec3<f32>(dielectricF0)).x, 0.0, 1.0);
413
+ // `refract` returns the zero vector for total internal reflection. Keep the
414
+ // reflection/IBL term above authoritative and remove transmission energy
415
+ // instead of sampling and adding a second reflected environment.
416
+ let transmittedEnergy = select(
417
+ 0.0,
418
+ transmissionFactor * (1.0 - metallic) * (1.0 - fresnel),
419
+ refractedLengthSquared > 1e-6,
420
+ );
421
+ diffuseAlbedo = albedo * (1.0 - transmittedEnergy);
422
+ #endif
423
+ let coatRoughness = max(factors.clearcoatRoughness, 0.04);
424
+ let coatAlpha = coatRoughness * coatRoughness;
425
+ let coatF = f_schlick(max(dot(n, v), 0.0), vec3<f32>(0.04)) * factors.clearcoat;
426
+
427
+ // Ambient (IBL) + 1 + N + N accumulation
428
+ // (feat-20260520-skylight-ibl-cubemap M3 / t48 +
429
+ // feat-20260520-directional-light-shadow-mapping +
430
+ // feat-20260518-pbr-direct-lighting-mvp Finding 4):
431
+ //
432
+ // color = ambient(IBL) + directional(shadowed) + sum(point) + sum(spot)
433
+ //
434
+ // When the host Skylight bind group provides default-zero resources the IBL
435
+ // helpers sample to vec3(0) and ambient = 0, so the shader falls through to
436
+ // direct lighting naturally (zero contribution, no branch, no #if guard).
437
+ //
438
+ // sampleIblDiffuse / sampleIblSpecular are imported from
439
+ // forgeax_pbr::ibl_sampling (ibl-sampling.wgsl) and take the
440
+ // @group(1) @binding(7..13) Skylight resources as function arguments
441
+ // -- the runtime helper module is zero-binding so the host owns the
442
+ // binding layout (round-4 amend: Skylight merged into material BGL).
443
+ // `factors.roughness` is the unsquared scalar; `a` above is the
444
+ // alpha*alpha form used by direct-light D_GGX, which is wrong for the
445
+ // split-sum mip lookup, so we re-derive the post-shader-clamp roughness
446
+ // here (matches sampleIblSpecular's `mip = roughness * 4.0` expectation).
447
+ //
448
+ // Direct lights (1 + N + N): one directional + N point + N spot, summed
449
+ // sequentially. LIGHT_ARRAY_MAX_SLOTS = 4 host-side bounds the loop trip
450
+ // counts. The directional path additionally projects worldPos through the
451
+ // per-cascade light-space matrix in the fragment stage for shadow-map PCF
452
+ // lookup (CSM, feat-20260613). Cluster owns punctual BRDF evaluation in the
453
+ // clustered variant above.
454
+ let kD = (vec3<f32>(1.0) - f_schlick(max(dot(n, v), 0.0), f0)) * (1.0 - metallic);
455
+ let iblRoughness = clamp(max(finiteScalar(factors.roughness, 0.5), 0.04) * roughnessTex, 0.04, 1.0);
456
+ var irradiance = vec3<f32>(0.0);
457
+ var specularIbl = vec3<f32>(0.0);
458
+ if (skylight.intensity < 0.0) {
459
+ // A negative intensity is the renderer-owned probe sentinel. The producer
460
+ // stores -(probeIntensity + 1), so intensity=0 remains distinguishable
461
+ // from the ordinary Skylight zero payload. The four color/rotation lanes
462
+ // carry probe center and half extents in this mode; Skylight resources
463
+ // remain unchanged and therefore preserve the ABI.
464
+ specularIbl = sampleReflectionProbeSpecular(
465
+ n, v, iblRoughness, f0, in.worldPos,
466
+ vec3<f32>(skylight.colorR, skylight.colorG, skylight.colorB),
467
+ skylight.rotation.xyz, vec4<f32>(0.0, 0.0, 0.0, 1.0),
468
+ prefilterMap, prefilterSampler, brdfLut, brdfLutSampler,
469
+ );
470
+ } else {
471
+ irradiance = sampleIblDiffuse(n, skylight.rotation, irradianceMap, irradianceSampler);
472
+ specularIbl = sampleIblSpecular(
473
+ n, v, iblRoughness, f0,
474
+ skylight.rotation,
475
+ prefilterMap, prefilterSampler, brdfLut, brdfLutSampler,
476
+ );
477
+ }
478
+ let occlusionUv = transformedMaterialUv(material.occlusionTextureCoordinatesTransform, material.occlusionTextureCoordinatesMetadata, in);
479
+ let aoSample = sampleMaterialTexture(occlusionTexture, occlusionTexture_sampler, occlusionUv, material.occlusionTextureCoordinatesMetadata.zw);
480
+ let ao = mix(1.0, aoSample.r, material.occlusionStrength);
481
+ // feat-20260612-hdrp-ssao M7 round-2: `var` (mutable) so the
482
+ // CLUSTER_FORWARD_AVAILABLE branch below can `ambient *=` the SSAO
483
+ // factor. The non-HDRP path leaves ambient untouched.
484
+ let skyColor = vec3<f32>(skylight.colorR, skylight.colorG, skylight.colorB);
485
+ let skyFactor = skyColor * skylight.intensity;
486
+ var reflectionFallback = specularIbl * (vec3<f32>(1.0) - coatF);
487
+ var ambient = (kD * irradiance * diffuseAlbedo + specularIbl) * (vec3<f32>(1.0) - coatF);
488
+ #ifdef PROBE_BLEND_AVAILABLE
489
+ // group(3) is the per-object bind group; record zero is the object selected
490
+ // by this draw, independent of instance count.
491
+ let probeShPreblend = array<vec4<f32>, 9>(
492
+ probeBlendRecords[1], probeBlendRecords[2], probeBlendRecords[3],
493
+ probeBlendRecords[4], probeBlendRecords[5], probeBlendRecords[6],
494
+ probeBlendRecords[7], probeBlendRecords[8], probeBlendRecords[9],
495
+ );
496
+ let probeLocalBlendFraction = probeBlendRecords[0].z;
497
+ let probeDiffuseK = kD / max(vec3<f32>(1.0 - metallic), vec3<f32>(0.0001));
498
+ let probeDiffuse = composeProbeDiffuse(probeShPreblend, probeLocalBlendFraction, n, irradiance * skyFactor, probeDiffuseK, albedo, metallic);
499
+ ambient = (probeDiffuse + specularIbl * skyFactor) * (vec3<f32>(1.0) - coatF);
500
+ #endif
501
+ if (factors.clearcoat != 0.0) {
502
+ // Keep the clearcoat lobe on the same selected environment as the base
503
+ // specular lobe. Probe mode reuses the packed center/extents lanes in the
504
+ // per-probe uniform; routing it through global Skylight IBL would make the
505
+ // fallback a mixed-source projection and break c=1 source equivalence.
506
+ var clearcoatIbl = vec3<f32>(0.0);
507
+ if (skylight.intensity < 0.0) {
508
+ clearcoatIbl = sampleReflectionProbeSpecular(
509
+ n, v, coatRoughness, vec3<f32>(0.04), in.worldPos,
510
+ vec3<f32>(skylight.colorR, skylight.colorG, skylight.colorB),
511
+ skylight.rotation.xyz, vec4<f32>(0.0, 0.0, 0.0, 1.0),
512
+ prefilterMap, prefilterSampler, brdfLut, brdfLutSampler,
513
+ );
514
+ } else {
515
+ clearcoatIbl = sampleIblSpecular(
516
+ n, v, coatRoughness, vec3<f32>(0.04),
517
+ skylight.rotation,
518
+ prefilterMap, prefilterSampler, brdfLut, brdfLutSampler,
519
+ );
520
+ }
521
+ let clearcoatContribution = clearcoatIbl * factors.clearcoat;
522
+ #ifdef PROBE_BLEND_AVAILABLE
523
+ if (skylight.intensity < 0.0) {
524
+ ambient = ambient + clearcoatContribution;
525
+ } else {
526
+ ambient = ambient + clearcoatIbl * factors.clearcoat * skyFactor;
527
+ }
528
+ #else
529
+ ambient = ambient + clearcoatContribution;
530
+ #endif
531
+ reflectionFallback = reflectionFallback + clearcoatContribution;
532
+ }
533
+ #ifdef PROBE_BLEND_AVAILABLE
534
+ ambient = ambient * ao;
535
+ #else
536
+ if (skylight.intensity < 0.0) {
537
+ let environmentScale = max(-skylight.intensity - 1.0, 0.0) * ao;
538
+ ambient = ambient * environmentScale;
539
+ reflectionFallback = reflectionFallback * environmentScale;
540
+ } else {
541
+ let environmentScale = skyColor * skylight.intensity * ao;
542
+ ambient = ambient * environmentScale;
543
+ reflectionFallback = reflectionFallback * environmentScale;
544
+ }
545
+ #endif
546
+ #ifdef CLUSTER_FORWARD_AVAILABLE
547
+ // feat-20260612-hdrp-ssao M2 round-1 + M7 round-2 (plan-strategy D-7 + D-B + D-C):
548
+ // SSAO ambient synthesis. Reads the half-res R8 `ssaoBlurredTexture` from
549
+ // the ssao-blur pass (HDRP unified BGL @group(2) @binding(7..9)). The host
550
+ // always binds those slots: when SSAO is disabled, binding 7 receives a
551
+ // 1x1 white fallback (AO=1.0) and binding 9 receives a zero-intensity
552
+ // uniform — `mix(1.0, ssao*ao, 0.0) = 1.0` so ambient collapses to the
553
+ // round-1 baseline (no PSO recompile across the toggle).
554
+ //
555
+ // Sampling uses the screen-space NDC -> [0,1] uv; we recover it from the
556
+ // clip-space xy that the vertex stage already emits via in.ndc.xy.
557
+ let ssaoUv = in.ndc.xy * vec2<f32>(0.5, -0.5) + vec2<f32>(0.5, 0.5);
558
+ let ssaoFactor = textureSample(ssaoBlurredTexture, ssaoBlurredSampler, ssaoUv).r;
559
+ // scope-amend-webgl2-ubo: intensity packed into cluster_uniform.near_far_log.w.
560
+ let ssaoIntensity = get_ssao_intensity();
561
+ let ssaoScale = mix(1.0, ssaoFactor * ao, ssaoIntensity);
562
+ ambient *= mix(1.0, ssaoFactor * ao, ssaoIntensity);
563
+ reflectionFallback *= ssaoScale;
564
+ #endif
565
+ var color = ambient;
566
+ #ifdef TRANSMISSION_AVAILABLE
567
+ let screenUv = in.ndc.xy * vec2<f32>(0.5, -0.5) + vec2<f32>(0.5);
568
+ // Read the same renderer-owned transform tables that the vertex stage uses
569
+ // instead of adding six more interstage varyings. WebGPU guarantees only 16
570
+ // user-defined vertex output locations; keeping the affine conversion in
571
+ // the fragment stage preserves that contract and keeps thickness in the
572
+ // glTF unit-scale mesh space before measuring the refracted segment in world
573
+ // metres.
574
+ let localToWorld0 = in.transmissionBasisFirst.xyz;
575
+ let localToWorld1 = vec3<f32>(
576
+ in.transmissionBasisFirst.w,
577
+ in.transmissionBasisSecond.x,
578
+ in.transmissionBasisSecond.y,
579
+ );
580
+ let localToWorld2 = vec3<f32>(
581
+ in.transmissionBasisSecond.z,
582
+ in.transmissionBasisSecond.w,
583
+ in.ndc.w,
584
+ );
585
+ let inverseCofactor0 = cross(localToWorld1, localToWorld2);
586
+ let inverseCofactor1 = cross(localToWorld2, localToWorld0);
587
+ let inverseCofactor2 = cross(localToWorld0, localToWorld1);
588
+ let localToWorldDet = dot(localToWorld0, inverseCofactor0);
589
+ let safeLocalToWorldDet = select(1.0, localToWorldDet, abs(localToWorldDet) >= 1e-6);
590
+ let worldToLocal0 = vec3<f32>(
591
+ inverseCofactor0.x,
592
+ inverseCofactor1.x,
593
+ inverseCofactor2.x,
594
+ ) / safeLocalToWorldDet;
595
+ let worldToLocal1 = vec3<f32>(
596
+ inverseCofactor0.y,
597
+ inverseCofactor1.y,
598
+ inverseCofactor2.y,
599
+ ) / safeLocalToWorldDet;
600
+ let worldToLocal2 = vec3<f32>(
601
+ inverseCofactor0.z,
602
+ inverseCofactor1.z,
603
+ inverseCofactor2.z,
604
+ ) / safeLocalToWorldDet;
605
+ let refractedLocal = normalize(
606
+ worldToLocal0 * refracted.x +
607
+ worldToLocal1 * refracted.y +
608
+ worldToLocal2 * refracted.z,
609
+ );
610
+ let worldRefractedDirection =
611
+ localToWorld0 * refractedLocal.x +
612
+ localToWorld1 * refractedLocal.y +
613
+ localToWorld2 * refractedLocal.z;
614
+ let worldThickness = max(
615
+ finiteScalar(material.thickness, 0.0) * length(worldRefractedDirection) *
616
+ finiteScalar(thicknessSample, 1.0),
617
+ 0.0,
618
+ );
619
+ let refractedUv = screenUv + (refracted.xy - incident.xy) * worldThickness * 0.25;
620
+ let guardBand = 0.02;
621
+ let insideGuardBand = all(refractedUv >= vec2<f32>(guardBand)) &&
622
+ all(refractedUv <= vec2<f32>(1.0 - guardBand));
623
+ let backdropMipCount = textureNumLevels(transmissionBackdropTexture);
624
+ let backdropMaxLod = max(f32(backdropMipCount) - 1.0, 0.0);
625
+ let backdropLod = clamp(iblRoughness * iblRoughness * backdropMaxLod, 0.0, backdropMaxLod);
626
+ let unrefractedBackdrop = textureSampleLevel(
627
+ transmissionBackdropTexture,
628
+ transmissionBackdropSampler,
629
+ clamp(screenUv, vec2<f32>(0.0), vec2<f32>(1.0)),
630
+ backdropLod,
631
+ ).rgb;
632
+ var transmittedBackdrop = unrefractedBackdrop;
633
+ if (refractedLengthSquared > 1e-6 && insideGuardBand) {
634
+ transmittedBackdrop = textureSampleLevel(
635
+ transmissionBackdropTexture,
636
+ transmissionBackdropSampler,
637
+ clamp(refractedUv, vec2<f32>(0.0), vec2<f32>(1.0)),
638
+ backdropLod,
639
+ ).rgb;
640
+ }
641
+ let safeAttenuationColor = clamp(
642
+ finiteColor(material.attenuationColor, vec3<f32>(1.0)),
643
+ vec3<f32>(0.0),
644
+ vec3<f32>(1.0),
645
+ );
646
+ let safeAttenuationDistance = max(finiteScalar(material.attenuationDistance, 0.0), 0.0);
647
+ let attenuationExponent = worldThickness / max(safeAttenuationDistance, 1e-6);
648
+ let beerAttenuation = select(
649
+ vec3<f32>(1.0),
650
+ pow(safeAttenuationColor, vec3<f32>(attenuationExponent)),
651
+ safeAttenuationDistance > 1e-6 && worldThickness > 0.0,
652
+ );
653
+ color = color + finiteColor(transmittedBackdrop, vec3<f32>(0.0)) *
654
+ transmittedEnergy * beerAttenuation;
655
+ #endif
656
+ let directionalShadow = select(1.0, evalDirectionalShadowFactor(n, in.worldPos, in.viewZ), factors.receiveShadows);
657
+ let directionalBase = evalDirectionalNoShadow(n, v, diffuseAlbedo, metallic, a, f0);
658
+ color = color + directionalShadow * directionalBase;
659
+ if (factors.clearcoat != 0.0) {
660
+ let directionalClearcoat = evalDirectionalNoShadow(
661
+ n, v, vec3<f32>(0.0), 1.0, coatAlpha, vec3<f32>(0.04),
662
+ );
663
+ color = color + directionalShadow * factors.clearcoat * directionalClearcoat;
664
+ }
665
+ #ifdef CLUSTER_FORWARD_AVAILABLE
666
+ // NDC from vertex shader (perspective-divided clip-space, interpolated).
667
+ // view_z: NDC depth for cluster Z-slice lookup.
668
+ color = color + evaluateStandardClusterLights(
669
+ in.ndc.xyz, in.viewZ, in.worldPos, n, v, diffuseAlbedo, metallic, a, f0, false, factors.receiveShadows,
670
+ );
671
+ if (factors.clearcoat != 0.0) {
672
+ color = color + factors.clearcoat * evaluateStandardClusterLights(
673
+ in.ndc.xyz, in.viewZ, in.worldPos, n, v, vec3<f32>(0.0), 1.0, coatAlpha, vec3<f32>(0.04), false, factors.receiveShadows,
674
+ );
675
+ }
676
+ #else
677
+ // Local lights are cluster-owned in the current render topology. The
678
+ // no-cluster variant intentionally remains directional + IBL only; this
679
+ // keeps its group(0) contract aligned with the latest Standard pipeline,
680
+ // where point/spot data no longer lives in the View bind group.
681
+ #endif // CLUSTER_FORWARD_AVAILABLE
682
+ let emissiveUv = transformedMaterialUv(material.emissiveTextureCoordinatesTransform, material.emissiveTextureCoordinatesMetadata, in);
683
+ let emissiveSample = sampleMaterialTexture(emissiveTexture, emissiveTexture_sampler, emissiveUv, material.emissiveTextureCoordinatesMetadata.zw).rgb;
684
+ // Evaluate texture derivatives uniformly; the per-instance lighting switch
685
+ // selects the result, never a divergent implicit-derivative sampling path.
686
+ color = select(albedo, color, factors.lighting) + factors.emissive * factors.emissiveIntensity * emissiveSample;
687
+ var output : StandardPbrOutput;
688
+ output.color = vec4<f32>(color, alpha);
689
+ #ifdef REFLECTION_FALLBACK_AVAILABLE
690
+ output.reflectionFallback = vec4<f32>(select(vec3<f32>(0.0), reflectionFallback, factors.lighting), 1.0);
691
+ #endif
692
+ return output;
693
+ }
694
+
695
+ // ── G-buffer output struct (feat-20260612-hdrp-deferred-shading M2 / w12) ──
696
+ //