@forgeax/engine-shader 0.1.2

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 (162) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +99 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/ShaderRegistry.d.ts +208 -0
  5. package/dist/ShaderRegistry.d.ts.map +1 -0
  6. package/dist/__tests__/atmosphere-background.test.d.ts +2 -0
  7. package/dist/__tests__/atmosphere-background.test.d.ts.map +1 -0
  8. package/dist/__tests__/atmosphere-cubemap-orientation.test.d.ts +2 -0
  9. package/dist/__tests__/atmosphere-cubemap-orientation.test.d.ts.map +1 -0
  10. package/dist/__tests__/atmosphere-preetham-numeric.test.d.ts +2 -0
  11. package/dist/__tests__/atmosphere-preetham-numeric.test.d.ts.map +1 -0
  12. package/dist/__tests__/atmosphere-preetham-red.test.d.ts +2 -0
  13. package/dist/__tests__/atmosphere-preetham-red.test.d.ts.map +1 -0
  14. package/dist/__tests__/atmosphere-preetham.test.d.ts +2 -0
  15. package/dist/__tests__/atmosphere-preetham.test.d.ts.map +1 -0
  16. package/dist/__tests__/atmosphere-three-r184-reference.test.d.ts +2 -0
  17. package/dist/__tests__/atmosphere-three-r184-reference.test.d.ts.map +1 -0
  18. package/dist/__tests__/bloom-fxaa-tonemap.unit.test.d.ts +2 -0
  19. package/dist/__tests__/bloom-fxaa-tonemap.unit.test.d.ts.map +1 -0
  20. package/dist/__tests__/builtin-texture-sampling-contract.test.d.ts +2 -0
  21. package/dist/__tests__/builtin-texture-sampling-contract.test.d.ts.map +1 -0
  22. package/dist/__tests__/default-standard-pbr-alpha.unit.test.d.ts +2 -0
  23. package/dist/__tests__/default-standard-pbr-alpha.unit.test.d.ts.map +1 -0
  24. package/dist/__tests__/deferred-lighting-ssao.test.d.ts +2 -0
  25. package/dist/__tests__/deferred-lighting-ssao.test.d.ts.map +1 -0
  26. package/dist/__tests__/fog-oracle.unit.test.d.ts +2 -0
  27. package/dist/__tests__/fog-oracle.unit.test.d.ts.map +1 -0
  28. package/dist/__tests__/fog-orthographic-ray.unit.test.d.ts +2 -0
  29. package/dist/__tests__/fog-orthographic-ray.unit.test.d.ts.map +1 -0
  30. package/dist/__tests__/fog-producer-matrix.integration.test.d.ts +2 -0
  31. package/dist/__tests__/fog-producer-matrix.integration.test.d.ts.map +1 -0
  32. package/dist/__tests__/hdrp-ssao-compile.test.d.ts +2 -0
  33. package/dist/__tests__/hdrp-ssao-compile.test.d.ts.map +1 -0
  34. package/dist/__tests__/ibl-irradiance.unit.test.d.ts +2 -0
  35. package/dist/__tests__/ibl-irradiance.unit.test.d.ts.map +1 -0
  36. package/dist/__tests__/ibl-sampling.unit.test.d.ts +2 -0
  37. package/dist/__tests__/ibl-sampling.unit.test.d.ts.map +1 -0
  38. package/dist/__tests__/lighting-punctual.unit.test.d.ts +2 -0
  39. package/dist/__tests__/lighting-punctual.unit.test.d.ts.map +1 -0
  40. package/dist/__tests__/material-artifact-dedup.unit.test.d.ts +2 -0
  41. package/dist/__tests__/material-artifact-dedup.unit.test.d.ts.map +1 -0
  42. package/dist/__tests__/material-builtins.unit.test.d.ts +2 -0
  43. package/dist/__tests__/material-builtins.unit.test.d.ts.map +1 -0
  44. package/dist/__tests__/material-contract.unit.test.d.ts +2 -0
  45. package/dist/__tests__/material-contract.unit.test.d.ts.map +1 -0
  46. package/dist/__tests__/material-derived-builtins.integration.test.d.ts +2 -0
  47. package/dist/__tests__/material-derived-builtins.integration.test.d.ts.map +1 -0
  48. package/dist/__tests__/public-surface.unit.test.d.ts +2 -0
  49. package/dist/__tests__/public-surface.unit.test.d.ts.map +1 -0
  50. package/dist/__tests__/scene-temporal.unit.test.d.ts +2 -0
  51. package/dist/__tests__/scene-temporal.unit.test.d.ts.map +1 -0
  52. package/dist/__tests__/shader-registry-transaction.unit.test.d.ts +2 -0
  53. package/dist/__tests__/shader-registry-transaction.unit.test.d.ts.map +1 -0
  54. package/dist/__tests__/shader.unit.test.d.ts +2 -0
  55. package/dist/__tests__/shader.unit.test.d.ts.map +1 -0
  56. package/dist/__tests__/sprite-lit-shader.test.d.ts +2 -0
  57. package/dist/__tests__/sprite-lit-shader.test.d.ts.map +1 -0
  58. package/dist/__tests__/sprite-variants.unit.test.d.ts +2 -0
  59. package/dist/__tests__/sprite-variants.unit.test.d.ts.map +1 -0
  60. package/dist/__tests__/taa-resolve.unit.test.d.ts +2 -0
  61. package/dist/__tests__/taa-resolve.unit.test.d.ts.map +1 -0
  62. package/dist/__tests__/transparent-pbr.unit.test.d.ts +2 -0
  63. package/dist/__tests__/transparent-pbr.unit.test.d.ts.map +1 -0
  64. package/dist/__tests__/vertex-color-variant.unit.test.d.ts +2 -0
  65. package/dist/__tests__/vertex-color-variant.unit.test.d.ts.map +1 -0
  66. package/dist/errors.d.ts +96 -0
  67. package/dist/errors.d.ts.map +1 -0
  68. package/dist/index.d.ts +44 -0
  69. package/dist/index.d.ts.map +1 -0
  70. package/dist/index.mjs +560 -0
  71. package/dist/index.mjs.map +1 -0
  72. package/dist/material/artifact-registry.d.ts +31 -0
  73. package/dist/material/artifact-registry.d.ts.map +1 -0
  74. package/dist/material/artifact-types.d.ts +13 -0
  75. package/dist/material/artifact-types.d.ts.map +1 -0
  76. package/dist/material-schemas.d.ts +8 -0
  77. package/dist/material-schemas.d.ts.map +1 -0
  78. package/dist/register-default-sprite-lit.d.ts +40 -0
  79. package/dist/register-default-sprite-lit.d.ts.map +1 -0
  80. package/dist/register-default-standard-pbr-skin.d.ts +34 -0
  81. package/dist/register-default-standard-pbr-skin.d.ts.map +1 -0
  82. package/dist/tonemap.d.ts +13 -0
  83. package/dist/tonemap.d.ts.map +1 -0
  84. package/dist/types.d.ts +47 -0
  85. package/dist/types.d.ts.map +1 -0
  86. package/package.json +58 -0
  87. package/src/ShaderRegistry.ts +519 -0
  88. package/src/__tests__/atmosphere-background.test.ts +33 -0
  89. package/src/__tests__/atmosphere-cubemap-orientation.test.ts +68 -0
  90. package/src/__tests__/atmosphere-preetham-numeric.test.ts +122 -0
  91. package/src/__tests__/atmosphere-preetham-red.test.ts +52 -0
  92. package/src/__tests__/atmosphere-preetham.test.ts +132 -0
  93. package/src/__tests__/atmosphere-three-r184-reference.test.ts +122 -0
  94. package/src/__tests__/bloom-fxaa-tonemap.unit.test.ts +31 -0
  95. package/src/__tests__/builtin-texture-sampling-contract.test.ts +56 -0
  96. package/src/__tests__/default-standard-pbr-alpha.unit.test.ts +19 -0
  97. package/src/__tests__/deferred-lighting-ssao.test.ts +114 -0
  98. package/src/__tests__/fog-oracle.unit.test.ts +132 -0
  99. package/src/__tests__/fog-orthographic-ray.unit.test.ts +62 -0
  100. package/src/__tests__/fog-producer-matrix.integration.test.ts +77 -0
  101. package/src/__tests__/hdrp-ssao-compile.test.ts +185 -0
  102. package/src/__tests__/ibl-irradiance.unit.test.ts +43 -0
  103. package/src/__tests__/ibl-sampling.unit.test.ts +40 -0
  104. package/src/__tests__/lighting-punctual.unit.test.ts +66 -0
  105. package/src/__tests__/manifest.fixture.json +22 -0
  106. package/src/__tests__/material-artifact-dedup.unit.test.ts +21 -0
  107. package/src/__tests__/material-builtins.unit.test.ts +111 -0
  108. package/src/__tests__/material-contract.unit.test.ts +79 -0
  109. package/src/__tests__/material-derived-builtins.integration.test.ts +30 -0
  110. package/src/__tests__/public-surface.unit.test.ts +9 -0
  111. package/src/__tests__/scene-temporal.unit.test.ts +32 -0
  112. package/src/__tests__/shader-registry-transaction.unit.test.ts +116 -0
  113. package/src/__tests__/shader.unit.test.ts +1909 -0
  114. package/src/__tests__/sprite-lit-shader.test.ts +240 -0
  115. package/src/__tests__/sprite-variants.unit.test.ts +209 -0
  116. package/src/__tests__/taa-resolve.unit.test.ts +32 -0
  117. package/src/__tests__/transparent-pbr.unit.test.ts +21 -0
  118. package/src/__tests__/vertex-color-variant.unit.test.ts +52 -0
  119. package/src/atmosphere-background.wgsl +80 -0
  120. package/src/atmosphere-cubemap.wgsl +72 -0
  121. package/src/atmosphere-ibl.wgsl +127 -0
  122. package/src/atmosphere-preetham.wgsl +214 -0
  123. package/src/bloom-blur.wgsl +89 -0
  124. package/src/bloom-bright.wgsl +50 -0
  125. package/src/bloom-composite.wgsl +54 -0
  126. package/src/brdf.wgsl +25 -0
  127. package/src/common.wgsl +523 -0
  128. package/src/default-standard-pbr-skin.wgsl +538 -0
  129. package/src/default-standard-pbr.wgsl +794 -0
  130. package/src/errors.ts +169 -0
  131. package/src/fog.wgsl +27 -0
  132. package/src/fxaa.wgsl +259 -0
  133. package/src/hdrp-cluster-forward.wgsl +186 -0
  134. package/src/hdrp-deferred-lighting.wgsl +69 -0
  135. package/src/hdrp-ssao.wgsl +174 -0
  136. package/src/ibl-brdf-lut.wgsl +92 -0
  137. package/src/ibl-equirect-to-cube.wgsl +56 -0
  138. package/src/ibl-irradiance.wgsl +89 -0
  139. package/src/ibl-prefilter.wgsl +101 -0
  140. package/src/ibl-sampling.wgsl +58 -0
  141. package/src/ibl-shared.wgsl +118 -0
  142. package/src/index.ts +166 -0
  143. package/src/lighting-directional.wgsl +471 -0
  144. package/src/lighting-punctual.wgsl +274 -0
  145. package/src/material/artifact-registry.ts +78 -0
  146. package/src/material/artifact-types.ts +26 -0
  147. package/src/material-schemas.ts +48 -0
  148. package/src/msdf-text.wgsl +208 -0
  149. package/src/pbr-temporal.wgsl +73 -0
  150. package/src/register-default-sprite-lit.ts +87 -0
  151. package/src/register-default-standard-pbr-skin.ts +74 -0
  152. package/src/shadow-pcf.wgsl +120 -0
  153. package/src/shadow_caster.wgsl +54 -0
  154. package/src/skybox.wgsl +75 -0
  155. package/src/sprite-lit.wgsl +307 -0
  156. package/src/sprite.wgsl +365 -0
  157. package/src/taa-resolve.wgsl +129 -0
  158. package/src/tbn.wgsl +54 -0
  159. package/src/tonemap.ts +13 -0
  160. package/src/tonemap.wgsl +177 -0
  161. package/src/types.ts +59 -0
  162. package/src/unlit.wgsl +186 -0
@@ -0,0 +1,794 @@
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}
3
+ #import forgeax_pbr::temporal::{projectPbrSceneTemporal}
4
+ #import forgeax_pbr::brdf::{f_schlick, v_smith, d_ggx}
5
+ #import forgeax_pbr::ibl_sampling::{sampleIblDiffuse, sampleIblSpecular}
6
+ #import forgeax_pbr::tbn::{decodeTangentSpaceNormalRg, scaleTangentSpaceNormal, applyTBN}
7
+ #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
+ #ifdef CLUSTER_FORWARD_AVAILABLE
15
+ #import forgeax_hdrp::cluster_forward::{evaluate_cluster_lights, get_ssao_intensity}
16
+ #endif
17
+
18
+ #pragma variant_axis STORAGE_BUFFER_AVAILABLE
19
+ #pragma variant_axis CLUSTER_FORWARD_AVAILABLE
20
+ #pragma variant_axis VERTEX_COLOR_AVAILABLE
21
+
22
+ // @forgeax/engine-shader - default-standard-pbr.wgsl
23
+ // (feat-20260523-shader-template-instance-split M5 / T04).
24
+ //
25
+ // Engine-shipped default standard PBR material shader, registered under the
26
+ // reserved path identifier `forgeax::default-standard-pbr` (plan-strategy
27
+ // D-DefaultStandardPbr-Identifier + plan-strategy section 8.2). This file is
28
+ // the M5 successor to the monolithic `pbr.wgsl`; the BRDF / IBL / TBN /
29
+ // lighting helpers all live in independent ShaderModules and are pulled in
30
+ // via naga_oil #import (charter F1 grep gate -- AI users grep the #import
31
+ // header to enumerate every helper dependency in one shot).
32
+ //
33
+ // Bindings (4 BG layout slots; View + Mesh bindings inherited from
34
+ // 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):
37
+ //
38
+ // @group(0) @binding(0) view uniform (see common.wgsl)
39
+ // @group(1) @binding(0) material uniform (baseColor vec4
40
+ // + metallic + roughness
41
+ // + 4 channel selectors f32
42
+ // + emissive vec3 + emissiveIntensity
43
+ // + uv/alpha/clearcoat/specularTint = 112 B)
44
+ // @group(1) @binding(1) baseColorSampler sampler
45
+ // @group(1) @binding(2) baseColorTexture texture_2d<f32>
46
+ // @group(1) @binding(3) metallicRoughnessSampler sampler
47
+ // @group(1) @binding(4) metallicRoughnessTexture texture_2d<f32>
48
+ // @group(1) @binding(5) normalSampler sampler
49
+ // @group(1) @binding(6) normalTexture texture_2d<f32>
50
+ // @group(1) @binding(7) specularTintSampler + @binding(8) specularTintTexture
51
+ // @group(1) @binding(9..15) Skylight (irradiance / prefilter / brdfLut +
52
+ // samplers) + skylight uniform (intensity)
53
+ // @group(2) @binding(0) meshes storage (worldFromLocal mat4
54
+ // + normalMatrix mat3,
55
+ // see common.wgsl)
56
+ // @group(3) @binding(0) instances storage (per-instance
57
+ // localFromInstance mat4;
58
+ // indexed by @builtin
59
+ // (instance_index);
60
+ // see common.wgsl)
61
+ //
62
+ // 20 entries fit within `device.limits.maxBindingsPerBindGroup` (default
63
+ // 1000 across all known WebGPU devices; chrome-beta + dawn confirmed via
64
+ // the runtime probe in createRenderer.ts -- requirements R-E acceptance
65
+ // gate). Adding more bindings requires raising the entry-count fixture in
66
+ // the M5-T04 acceptanceCheck readback assertion.
67
+ //
68
+ // Two-layer fail-fast for roughness=0 NaN avoidance (plan-strategy section
69
+ // 5.3 + AC-02 b/c):
70
+ // layer 1: AssetRegistry.register fail-fast (M1 + M4 paramValues 3-tier)
71
+ // returns 'asset-invalid-value' / 'material-param-type-mismatch'
72
+ // before the payload reaches the GPU.
73
+ // layer 2: shader internal `let a = max(material.roughness, 0.04); a = a * a;`
74
+ // keeps D_GGX finite even if a producer somehow bypasses layer 1.
75
+ //
76
+ // Normal mapping (AC-05 + plan-strategy D-4): TBN basis built from
77
+ // per-vertex tangent (vec4 with handedness sign in .w) + interpolated
78
+ // world-space normal (consumed via mesh.normalMatrix from common.wgsl,
79
+ // plan-strategy D-5). RG-only tangent-space normal: sample.rg encodes
80
+ // (x,y) of the unit-length tangent normal, z is reconstructed via
81
+ // z = sqrt(1 - x^2 - y^2). Default 1x1 normal fallback texture is
82
+ // RG=(128,128) which decodes to tangent (0,0,1) -- zero perturbation when
83
+ // normalTexture is absent (host-side pipelineState.defaultNormalTextureView,
84
+ // distinct from the white fallback used by baseColor / metallicRoughness
85
+ // slots so a missing normal does not pollute the white-on-missing semantics
86
+ // of the other two slots). RG encoding also matches BC5 / RG normal maps
87
+ // and tolerates RGB normal maps (b is dropped, z is recomputed --
88
+ // equivalent for unit vectors).
89
+
90
+ struct Material {
91
+ baseColor : vec4<f32>,
92
+ metallic : f32,
93
+ roughness : f32,
94
+ // Channel selectors (D-8): 4 independent f32 entries split out of the
95
+ // legacy `channelMap : vec4<u32>`. Each value is a small integer encoded
96
+ // as f32 in {0.0, 1.0, 2.0, 3.0} indexing into {r,g,b,a}. Default glTF 2.0
97
+ // packing = (metallicChannel=2, roughnessChannel=1, aoChannel=0,
98
+ // extraChannel=0). The fragment casts to u32 at the pick site -- f32 is
99
+ // chosen so the schema entry type aligns with the 14-tuple
100
+ // MaterialParamType numeric-run packing rule (4-byte stride, 16 B span).
101
+ metallicChannel : f32,
102
+ roughnessChannel : f32,
103
+ aoChannel : f32,
104
+ extraChannel : f32,
105
+ // vec3 align=16 inserts 8 implicit padding bytes after extraChannel
106
+ // (offsets 40..48) so emissive lands at offset 48.
107
+ emissive : vec3<f32>,
108
+ emissiveIntensity : f32,
109
+ occlusionStrength : f32,
110
+ alphaCutoff : f32,
111
+ clearcoat : f32,
112
+ clearcoatRoughness : f32,
113
+ specularTint : vec3<f32>,
114
+ normalScale : f32,
115
+ baseColorTextureCoordinatesTransform : vec4<f32>,
116
+ baseColorTextureCoordinatesMetadata : vec4<f32>,
117
+ metallicRoughnessTextureCoordinatesTransform : vec4<f32>,
118
+ metallicRoughnessTextureCoordinatesMetadata : vec4<f32>,
119
+ normalTextureCoordinatesTransform : vec4<f32>,
120
+ normalTextureCoordinatesMetadata : vec4<f32>,
121
+ specularTintTextureCoordinatesTransform : vec4<f32>,
122
+ specularTintTextureCoordinatesMetadata : vec4<f32>,
123
+ emissiveTextureCoordinatesTransform : vec4<f32>,
124
+ emissiveTextureCoordinatesMetadata : vec4<f32>,
125
+ occlusionTextureCoordinatesTransform : vec4<f32>,
126
+ occlusionTextureCoordinatesMetadata : vec4<f32>,
127
+ };
128
+
129
+ @group(1) @binding(0) var<uniform> material : Material;
130
+ @group(1) @binding(1) var baseColorSampler : sampler;
131
+ @group(1) @binding(2) var baseColorTexture : texture_2d<f32>;
132
+ @group(1) @binding(3) var metallicRoughnessSampler : sampler;
133
+ @group(1) @binding(4) var metallicRoughnessTexture : texture_2d<f32>;
134
+ @group(1) @binding(5) var normalSampler : sampler;
135
+ @group(1) @binding(6) var normalTexture : texture_2d<f32>;
136
+ @group(1) @binding(7) var specularTintSampler : sampler;
137
+ @group(1) @binding(8) var specularTintTexture : texture_2d<f32>;
138
+ @group(1) @binding(9) var emissiveSampler : sampler;
139
+ @group(1) @binding(10) var emissiveTexture : texture_2d<f32>;
140
+ @group(1) @binding(11) var occlusionSampler : sampler;
141
+ @group(1) @binding(12) var occlusionTexture : texture_2d<f32>;
142
+
143
+ // Naga reflection does not retain filtering usage through the generic shared
144
+ // helper. These compile-time-only witnesses retain the binding contract while
145
+ // every runtime material sample still goes through sampleMaterialTexture.
146
+ fn materialTextureFilteringWitness() {
147
+ let base = baseColorTexture;
148
+ let metallicRoughness = metallicRoughnessTexture;
149
+ let normal = normalTexture;
150
+ let specularTint = specularTintTexture;
151
+ let emissive = emissiveTexture;
152
+ let occlusion = occlusionTexture;
153
+ let baseWitness = textureSample(base, baseColorSampler, vec2<f32>(0.0));
154
+ let metallicRoughnessWitness = textureSample(metallicRoughness, metallicRoughnessSampler, vec2<f32>(0.0));
155
+ let normalWitness = textureSample(normal, normalSampler, vec2<f32>(0.0));
156
+ let specularTintWitness = textureSample(specularTint, specularTintSampler, vec2<f32>(0.0));
157
+ let emissiveWitness = textureSample(emissive, emissiveSampler, vec2<f32>(0.0));
158
+ let occlusionWitness = textureSample(occlusion, occlusionSampler, vec2<f32>(0.0));
159
+ }
160
+
161
+ // Skylight bindings merged into the PBR material BGL
162
+ // (feat-20260520-skylight-ibl-cubemap M3 / t48 round-4 amend per D-5
163
+ // round-4 REVISED). The round-2 stand-alone group 4 Skylight BGL collided
164
+ // with WebGPU's default maxBindGroups=4 in chrome-beta and blocked pbr-pl
165
+ // pipeline-layout creation; round-4 appends the 7 Skylight entries to the
166
+ // PBR material BindGroupLayout at binding 7..13. The material BG factory
167
+ // (mergeSkylightIntoMaterialBgl in
168
+ // packages/runtime/src/ibl/skylight-bind-group.ts) extends the layout to
169
+ // 14 entries; render-system-record assembles a single merged material
170
+ // BindGroup (no extra setBindGroup(4) call). Identity (default) resources
171
+ // produce ambient = 0; with a real Skylight, ibl_sampling helpers project
172
+ // the IBL irradiance + split-sum specular into `ambient` below.
173
+ struct SkylightUniforms {
174
+ intensity : f32,
175
+ // The former pad0/1/2 lanes now carry the linear-space ambient `color` tint
176
+ // (downstream integration #4). Kept as three scalars (NOT vec3<f32>) so the
177
+ // struct stays exactly 16 B: a vec3 has 16-byte alignment in std140 and
178
+ // would push `color` to offset 16. The rotation vec4 follows at offset 16,
179
+ // so the host writes one 32 B payload.
180
+ colorR : f32,
181
+ colorG : f32,
182
+ colorB : f32,
183
+ rotation : vec4<f32>,
184
+ };
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;
192
+
193
+ // feat-20260612-hdrp-ssao M7 (round 2) D-B + D-C, scope-amend-webgl2-ubo:
194
+ // SSAO sampling lives on the HDRP unified BGL @group(2) alongside the
195
+ // cluster bindings (binding 7 = ssao texture, binding 8 = sampler). The
196
+ // intensity scalar is folded into `cluster_uniform.near_far_log.w` (the
197
+ // previously-unused std140 pad lane on @binding(6)) — declaring a
198
+ // dedicated UBO at @binding(9) overflows WebGL2's
199
+ // `max_uniform_buffers_per_shader_stage = 11` budget on rhi-wgpu's
200
+ // fallback path. Disabled SSAO path binds 1x1 white at @binding(7) + the
201
+ // host writes intensity=0 into the cluster pad lane, so the synthesis
202
+ // collapses identically.
203
+ #ifdef CLUSTER_FORWARD_AVAILABLE
204
+ @group(2) @binding(7) var ssaoBlurredTexture : texture_2d<f32>;
205
+ @group(2) @binding(8) var ssaoBlurredSampler : sampler;
206
+ #endif
207
+
208
+ struct VsIn {
209
+ @location(0) pos : vec3<f32>,
210
+ @location(1) normal : vec3<f32>,
211
+ @location(2) uv : vec2<f32>,
212
+ @location(3) tangent : vec4<f32>,
213
+ // MaterialAsset per-slot texCoord: reserve the canonical UV0-UV7 inputs.
214
+ // Missing mesh sets are supplied by the pipeline's clamp-to-last aliases.
215
+ @location(6) uv1 : vec2<f32>,
216
+ @location(7) uv2 : vec2<f32>,
217
+ @location(8) uv3 : vec2<f32>,
218
+ @location(9) uv4 : vec2<f32>,
219
+ @location(10) uv5 : vec2<f32>,
220
+ @location(11) uv6 : vec2<f32>,
221
+ @location(12) uv7 : vec2<f32>,
222
+ #ifdef VERTEX_COLOR_AVAILABLE
223
+ @location(13) color : vec4<f32>,
224
+ #endif
225
+ };
226
+ struct VsOut {
227
+ @builtin(position) clip : vec4<f32>,
228
+ @location(0) worldPos : vec3<f32>,
229
+ @location(1) worldNormal : vec3<f32>,
230
+ @location(2) uv : vec2<f32>,
231
+ @location(3) worldTangent : vec4<f32>,
232
+ @location(4) @interpolate(flat) instanceIdx : u32,
233
+ // feat-city-glb multi-UV tiling: second UV set inter-stage varying. Uses the
234
+ // previously-vacant @location(5) so @location(6)/(7) stay byte-stable with
235
+ // the prior layout (CSM M5/w19).
236
+ @location(5) uv1 : vec2<f32>,
237
+ @location(8) uv2 : vec2<f32>,
238
+ @location(9) uv3 : vec2<f32>,
239
+ @location(10) uv4 : vec2<f32>,
240
+ @location(11) uv5 : vec2<f32>,
241
+ @location(12) uv6 : vec2<f32>,
242
+ @location(13) uv7 : vec2<f32>,
243
+ #ifdef VERTEX_COLOR_AVAILABLE
244
+ @location(14) color : vec4<f32>,
245
+ #endif
246
+ @location(6) ndc : vec3<f32>, // NDC for HDRP cluster lookup (w10)
247
+ // feat-20260609-hdrp-cluster-fragment-ggx M4.5-followup: view-space z is
248
+ // needed by ndc_position_to_cluster (slice index uses log-z mapping that
249
+ // takes negative view_z, NOT NDC z which is [0,1]). Earlier `in.ndc.z`
250
+ // pass through view_z slot collapsed every fragment to slice 0, so cube
251
+ // surfaces -- whose cluster cells were unrelated to the floor's slice 0
252
+ // hot zone -- received zero light. Forward view_z explicitly. M5 / w19:
253
+ // also feeds CSM cascade selection in evalDirectional.
254
+ @location(7) viewZ : f32,
255
+ };
256
+
257
+ fn pick_channel(rgba : vec4<f32>, channelIndex : u32) -> f32 {
258
+ // Branch-free channel pick. WGSL has no array<f32, 4>(rgba) addressable
259
+ // helper; manual switch keeps the path uniform.
260
+ switch (channelIndex) {
261
+ case 0u: { return rgba.r; }
262
+ case 1u: { return rgba.g; }
263
+ case 2u: { return rgba.b; }
264
+ default: { return rgba.a; }
265
+ }
266
+ }
267
+
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
+ // Light evaluators live in forgeax_pbr::lighting_directional +
288
+ // forgeax_pbr::lighting_punctual (M5 / T02). evalDirectional consumes the
289
+ // host's view UBO + shadowMap (LO 3.1.3 slope-scaled-bias 3x3 PCF;
290
+ // feat-20260520-directional-light-shadow-mapping byte-equivalent). evalPoint
291
+ // / evalSpot share evalPunctualBody (GGX specular + Lambertian diffuse +
292
+ // KHR_lights_punctual quartic range attenuation;
293
+ // feat-20260519-light-casters-point-spot-pbr M4 / w22 byte-equivalent).
294
+ // Charter P4: spot light is a thin cone-multiplier on top of the punctual
295
+ // body, point light is the body unchanged -- no magic-value collapse.
296
+
297
+ @vertex
298
+ fn vs_main(in : VsIn, @builtin(instance_index) idx : u32) -> VsOut {
299
+ // feat-20260604-instances-per-instance-transform-shader-group3-bin M1 / w4:
300
+ // Entity world from meshes[0] — the @group(2) dynamic-offset window has
301
+ // already been aimed at this entity's slot (render-system-record.ts:2996
302
+ // setBindGroup(2, meshBindGroup, [i * MESH_PER_ENTITY_STRIDE])). Per-instance
303
+ // local from instances[idx] — @group(3) is a flat per-instance buffer indexed
304
+ // directly by instance_index (firstInstance=0, render-system-record.ts:2898).
305
+ // Combine: entity_world * per_instance_local.
306
+ let instanceLocal = instances[idx].localFromInstance;
307
+ let entityWorld = meshes[0].worldFromLocal;
308
+ let world = entityWorld * instanceLocal * vec4<f32>(in.pos, 1.0);
309
+ var out : VsOut;
310
+ out.clip = view.worldViewProj * world;
311
+ out.worldPos = world.xyz;
312
+ // Normal: derive the inverse-transpose from the world matrix columns. This
313
+ // keeps non-uniform entity scale correct while avoiding the Mesh.normalMatrix
314
+ // storage field whose mat3x3 layout is not consumed consistently by the GPU
315
+ // path. The per-instance local transform remains uniform-scale-only, as it
316
+ // was before this owner fix.
317
+ let a = entityWorld[0].xyz;
318
+ let b = entityWorld[1].xyz;
319
+ let c = entityWorld[2].xyz;
320
+ let cof0 = cross(b, c);
321
+ let cof1 = cross(c, a);
322
+ let cof2 = cross(a, b);
323
+ let det = dot(a, cof0);
324
+ let entityNormal = select(
325
+ in.normal,
326
+ (cof0 * in.normal.x + cof1 * in.normal.y + cof2 * in.normal.z) / det,
327
+ abs(det) >= 1e-6,
328
+ );
329
+ out.worldNormal = normalize(entityNormal);
330
+ // Tangent transformed by the combined entity*instance chain as a direction
331
+ // (w=0); .w handedness preserved for bitangent reconstruction in fragment.
332
+ let worldTangentXyz = normalize((entityWorld * instanceLocal * vec4<f32>(in.tangent.xyz, 0.0)).xyz);
333
+ out.worldTangent = vec4<f32>(worldTangentXyz, in.tangent.w);
334
+ out.uv = in.uv;
335
+ out.uv1 = in.uv1;
336
+ out.uv2 = in.uv2;
337
+ out.uv3 = in.uv3;
338
+ out.uv4 = in.uv4;
339
+ out.uv5 = in.uv5;
340
+ out.uv6 = in.uv6;
341
+ out.uv7 = in.uv7;
342
+ #ifdef VERTEX_COLOR_AVAILABLE
343
+ out.color = in.color;
344
+ #endif
345
+ out.instanceIdx = idx;
346
+ // feat-20260613-csm-cascaded-shadow-maps M5 / w19: the per-fragment
347
+ // light-space position varying is gone; evalDirectional computes
348
+ // per-cascade lightViewProj * worldPos in the fragment stage from
349
+ // viewZ + worldPos.
350
+ // NDC for HDRP cluster lookup (feat-20260609-hdrp-cluster-fragment-ggx M2 / w10).
351
+ // Perspective divide on clip-space position; ndc.z retains depth-buffer value.
352
+ let clipPos = out.clip;
353
+ out.ndc = vec3(clipPos.xy / clipPos.w, clipPos.z / clipPos.w);
354
+ // Standard WebGPU/GL perspective projection has clip.w = -view.z, so
355
+ // view_z = -clip.w (negative in front of the camera). M4.5-followup:
356
+ // ndc_position_to_cluster needs this for log-z slice mapping; passing
357
+ // ndc.z (in [0,1]) instead made every fragment land in slice 0.
358
+ out.viewZ = -clipPos.w;
359
+ return out;
360
+ }
361
+
362
+ fn transformedMaterialUv(transform : vec4<f32>, metadata : vec4<f32>, in : VsOut) -> vec2<f32> {
363
+ var source = in.uv;
364
+ if (metadata.x >= 1.0) { source = in.uv1; }
365
+ if (metadata.x >= 2.0) { source = in.uv2; }
366
+ if (metadata.x >= 3.0) { source = in.uv3; }
367
+ if (metadata.x >= 4.0) { source = in.uv4; }
368
+ if (metadata.x >= 5.0) { source = in.uv5; }
369
+ if (metadata.x >= 6.0) { source = in.uv6; }
370
+ if (metadata.x >= 7.0) { source = in.uv7; }
371
+ let scaled = source * transform.zw;
372
+ let angle = metadata.y;
373
+ let c = cos(angle);
374
+ let s = sin(angle);
375
+ return vec2<f32>(scaled.x * c - scaled.y * s, scaled.x * s + scaled.y * c) + transform.xy;
376
+ }
377
+
378
+ fn materialAlpha(baseSample : vec4<f32>) -> f32 {
379
+ return material.baseColor.a * baseSample.a;
380
+ }
381
+
382
+ fn materialVertexColor(in : VsOut) -> vec4<f32> {
383
+ #ifdef VERTEX_COLOR_AVAILABLE
384
+ return in.color;
385
+ #else
386
+ return vec4<f32>(1.0);
387
+ #endif
388
+ }
389
+
390
+ // linearColorDomain: transparent source and destination values are blended
391
+ // before any display encoding. The fixed-function material blend state uses
392
+ // this same equation for the render target's declared linear domain.
393
+ fn blendLinearTransparent(
394
+ source : vec3<f32>,
395
+ destination : vec3<f32>,
396
+ alpha : f32,
397
+ ) -> vec3<f32> {
398
+ return source * alpha + destination * (1.0 - alpha);
399
+ }
400
+
401
+ // linearHdrColorDomain: fs_main writes linear HDR when its target is HDR.
402
+ // toneStageInput: the value remains linear until the fullscreen tone stage.
403
+
404
+ fn alphaTest(alpha : f32) {
405
+ if (material.alphaCutoff > 0.0 && alpha <= material.alphaCutoff) {
406
+ discard;
407
+ }
408
+ }
409
+
410
+ @fragment
411
+ fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
412
+ let baseUv = transformedMaterialUv(material.baseColorTextureCoordinatesTransform, material.baseColorTextureCoordinatesMetadata, in);
413
+ let baseSample = sampleMaterialTexture(baseColorTexture, baseColorSampler, baseUv, material.baseColorTextureCoordinatesMetadata.zw);
414
+ let vertexColor = materialVertexColor(in);
415
+ alphaTest(material.baseColor.a * baseSample.a * vertexColor.a);
416
+ let alpha = materialAlpha(baseSample) * vertexColor.a;
417
+ let albedo = material.baseColor.rgb * baseSample.rgb * vertexColor.rgb;
418
+
419
+ // Metallic-roughness texture sampling with per-field channel selectors
420
+ // (D-8): glTF 2.0 default layout B=metallic, G=roughness, R=occlusion is
421
+ // encoded by the host as 4 independent f32 selectors in the merged UBO
422
+ // (metallicChannel/roughnessChannel/aoChannel/extraChannel). Cast to u32
423
+ // at the pick_channel call site; values stay in {0,1,2,3}.
424
+ let mrUv = transformedMaterialUv(material.metallicRoughnessTextureCoordinatesTransform, material.metallicRoughnessTextureCoordinatesMetadata, in);
425
+ 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));
428
+
429
+ // Layer-2 fail-fast: shader internal clamp keeps D_GGX finite for
430
+ // roughness=0 even if the asset somehow bypasses layer-1 register
431
+ // fail-fast (plan-strategy section 5.3 + AC-02 (b)+(c)). Apply to the
432
+ // material scalar prior to the channelMap-extracted texture multiplier
433
+ // so the final roughness >= 0.04 floor is preserved (texture sample is
434
+ // a 1.0-default white when metallicRoughnessTexture is absent, so
435
+ // multiplying after the clamp keeps the floor intact).
436
+ var a = max(material.roughness, 0.04);
437
+ a = a * roughnessTex;
438
+ a = a * a;
439
+
440
+ // TBN basis composed via forgeax_pbr::tbn helpers; default fallback
441
+ // (defaultNormalTextureView) RG=(128,128) -> tangent (0,0,1) -> world n
442
+ // unchanged.
443
+ let normalUv = transformedMaterialUv(material.normalTextureCoordinatesTransform, material.normalTextureCoordinatesMetadata, in);
444
+ let normSampleRg = sampleMaterialTexture(normalTexture, normalSampler, normalUv, material.normalTextureCoordinatesMetadata.zw).rg;
445
+ let normTangent = scaleTangentSpaceNormal(
446
+ decodeTangentSpaceNormalRg(normSampleRg), material.normalScale,
447
+ );
448
+ let n = applyTBN(in.worldNormal, in.worldTangent, normTangent);
449
+
450
+ let v = normalize(view.cameraPos - in.worldPos);
451
+ let specularUv = transformedMaterialUv(material.specularTintTextureCoordinatesTransform, material.specularTintTextureCoordinatesMetadata, in);
452
+ let specularTint = material.specularTint * sampleMaterialTexture(
453
+ specularTintTexture, specularTintSampler, specularUv, material.specularTintTextureCoordinatesMetadata.zw,
454
+ ).rgb;
455
+ let f0 = mix(vec3<f32>(0.04) * specularTint, albedo, metallic);
456
+ let coatRoughness = max(material.clearcoatRoughness, 0.04);
457
+ let coatAlpha = coatRoughness * coatRoughness;
458
+ let coatF = f_schlick(max(dot(n, v), 0.0), vec3<f32>(0.04)) * material.clearcoat;
459
+
460
+ // Ambient (IBL) + 1 + N + N accumulation
461
+ // (feat-20260520-skylight-ibl-cubemap M3 / t48 +
462
+ // feat-20260520-directional-light-shadow-mapping +
463
+ // feat-20260518-pbr-direct-lighting-mvp Finding 4):
464
+ //
465
+ // color = ambient(IBL) + directional(shadowed) + sum(point) + sum(spot)
466
+ //
467
+ // When the host Skylight bind group provides default-zero resources the IBL
468
+ // helpers sample to vec3(0) and ambient = 0, so the shader falls through to
469
+ // direct lighting naturally (zero contribution, no branch, no #if guard).
470
+ //
471
+ // sampleIblDiffuse / sampleIblSpecular are imported from
472
+ // forgeax_pbr::ibl_sampling (ibl-sampling.wgsl) and take the
473
+ // @group(1) @binding(7..13) Skylight resources as function arguments
474
+ // -- the runtime helper module is zero-binding so the host owns the
475
+ // binding layout (round-4 amend: Skylight merged into material BGL).
476
+ // `material.roughness` is the unsquared scalar; `a` above is the
477
+ // alpha*alpha form used by direct-light D_GGX, which is wrong for the
478
+ // split-sum mip lookup, so we re-derive the post-shader-clamp roughness
479
+ // here (matches sampleIblSpecular's `mip = roughness * 4.0` expectation).
480
+ //
481
+ // Direct lights (1 + N + N): one directional + N point + N spot, summed
482
+ // sequentially. LIGHT_ARRAY_MAX_SLOTS = 4 host-side bounds the loop trip
483
+ // counts. GGX BRDF helpers (d_ggx / v_smith / f_schlick from
484
+ // forgeax_pbr::brdf) run inside each evalDirectional / evalPoint / evalSpot
485
+ // so all three light types share the same microfacet specular + Lambertian
486
+ // diffuse form. The directional path additionally projects worldPos
487
+ // through the per-cascade light-space matrix in the fragment stage for
488
+ // shadow-map PCF lookup (CSM, feat-20260613).
489
+ 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;
491
+ let irradiance = sampleIblDiffuse(n, skylight.rotation, irradianceMap, irradianceSampler);
492
+ let specularIbl = sampleIblSpecular(
493
+ n, v, iblRoughness, f0,
494
+ skylight.rotation,
495
+ prefilterMap, prefilterSampler, brdfLut, brdfLutSampler,
496
+ );
497
+ let occlusionUv = transformedMaterialUv(material.occlusionTextureCoordinatesTransform, material.occlusionTextureCoordinatesMetadata, in);
498
+ let aoSample = sampleMaterialTexture(occlusionTexture, occlusionSampler, occlusionUv, material.occlusionTextureCoordinatesMetadata.zw);
499
+ let ao = mix(1.0, aoSample.r, material.occlusionStrength);
500
+ // feat-20260612-hdrp-ssao M7 round-2: `var` (mutable) so the
501
+ // CLUSTER_FORWARD_AVAILABLE branch below can `ambient *=` the SSAO
502
+ // factor. The non-HDRP path leaves ambient untouched.
503
+ let skyColor = vec3<f32>(skylight.colorR, skylight.colorG, skylight.colorB);
504
+ var ambient = (kD * irradiance * albedo + specularIbl) * (vec3<f32>(1.0) - coatF);
505
+ if (material.clearcoat != 0.0) {
506
+ let clearcoatIbl = sampleIblSpecular(
507
+ n, v, coatRoughness, vec3<f32>(0.04),
508
+ skylight.rotation,
509
+ prefilterMap, prefilterSampler, brdfLut, brdfLutSampler,
510
+ );
511
+ ambient = ambient + clearcoatIbl * material.clearcoat;
512
+ }
513
+ ambient = ambient * skyColor * skylight.intensity * ao;
514
+ #ifdef CLUSTER_FORWARD_AVAILABLE
515
+ // feat-20260612-hdrp-ssao M2 round-1 + M7 round-2 (plan-strategy D-7 + D-B + D-C):
516
+ // SSAO ambient synthesis. Reads the half-res R8 `ssaoBlurredTexture` from
517
+ // the ssao-blur pass (HDRP unified BGL @group(2) @binding(7..9)). The host
518
+ // always binds those slots: when SSAO is disabled, binding 7 receives a
519
+ // 1x1 white fallback (AO=1.0) and binding 9 receives a zero-intensity
520
+ // uniform — `mix(1.0, ssao*ao, 0.0) = 1.0` so ambient collapses to the
521
+ // round-1 baseline (no PSO recompile across the toggle).
522
+ //
523
+ // Sampling uses the screen-space NDC -> [0,1] uv; we recover it from the
524
+ // clip-space xy that the vertex stage already emits via in.ndc.xy.
525
+ let ssaoUv = in.ndc.xy * vec2<f32>(0.5, -0.5) + vec2<f32>(0.5, 0.5);
526
+ let ssaoFactor = textureSample(ssaoBlurredTexture, ssaoBlurredSampler, ssaoUv).r;
527
+ // scope-amend-webgl2-ubo: intensity packed into cluster_uniform.near_far_log.w.
528
+ let ssaoIntensity = get_ssao_intensity();
529
+ ambient *= mix(1.0, ssaoFactor * ao, ssaoIntensity);
530
+ #endif
531
+ var color = ambient;
532
+ let directionalShadow = evalDirectionalShadowFactor(n, in.worldPos, in.viewZ);
533
+ let directionalBase = evalDirectionalNoShadow(n, v, albedo, metallic, a, f0);
534
+ color = color + directionalShadow * directionalBase;
535
+ if (material.clearcoat != 0.0) {
536
+ let directionalClearcoat = evalDirectionalNoShadow(
537
+ n, v, vec3<f32>(0.0), 1.0, coatAlpha, vec3<f32>(0.04),
538
+ );
539
+ color = color + directionalShadow * material.clearcoat * directionalClearcoat;
540
+ }
541
+ #ifdef CLUSTER_FORWARD_AVAILABLE
542
+ // NDC from vertex shader (perspective-divided clip-space, interpolated).
543
+ // 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);
545
+ if (material.clearcoat != 0.0) {
546
+ color = color + material.clearcoat * evaluate_cluster_lights(
547
+ in.ndc, in.viewZ, in.worldPos, n, v, vec3<f32>(0.0), 1.0, coatAlpha, vec3<f32>(0.04),
548
+ );
549
+ }
550
+ #else
551
+ let pointCount = pointLightsBuffer.count;
552
+ for (var i: u32 = 0u; i < pointCount; i = i + 1u) {
553
+ let p = pointLightsBuffer.slots[i];
554
+ #ifdef POINT_SHADOW_AVAILABLE
555
+ // feat-20260612-point-light-shadows-urp-hdrp Round-2 F-1: gate between
556
+ // shadowed and unshadowed evaluation on the host-assigned
557
+ // shadowAtlasLayer (>= 0 means PointLightShadow companion exists; -1
558
+ // sentinel means no shadow). shadowParams[layer] = (near, far,
559
+ // 1/(far-near), 0) is bound at @group(0) @binding(6) (shadowParams
560
+ // declared in common.wgsl).
561
+ if (p.shadowAtlasLayer >= 0) {
562
+ let lane = shadowParams[p.shadowAtlasLayer];
563
+ color = color + evalPointShadowed(
564
+ p.position, p.colorTimesIntensity, p.invRangeSquared,
565
+ in.worldPos, n, v, albedo, metallic, a, f0,
566
+ p.shadowAtlasLayer, lane.x, lane.y, 0.005, 0.05,
567
+ );
568
+ if (material.clearcoat != 0.0) {
569
+ color = color + material.clearcoat * evalPointShadowed(
570
+ p.position, p.colorTimesIntensity, p.invRangeSquared,
571
+ in.worldPos, n, v, vec3<f32>(0.0), 1.0, coatAlpha, vec3<f32>(0.04),
572
+ p.shadowAtlasLayer, lane.x, lane.y, 0.005, 0.05,
573
+ );
574
+ }
575
+ } else {
576
+ color = color + evalPoint(
577
+ p.position, p.colorTimesIntensity, p.invRangeSquared,
578
+ in.worldPos, n, v, albedo, metallic, a, f0,
579
+ );
580
+ if (material.clearcoat != 0.0) {
581
+ color = color + material.clearcoat * evalPoint(
582
+ p.position, p.colorTimesIntensity, p.invRangeSquared,
583
+ in.worldPos, n, v, vec3<f32>(0.0), 1.0, coatAlpha, vec3<f32>(0.04),
584
+ );
585
+ }
586
+ }
587
+ #else
588
+ color = color + evalPoint(
589
+ p.position, p.colorTimesIntensity, p.invRangeSquared,
590
+ in.worldPos, n, v, albedo, metallic, a, f0,
591
+ );
592
+ if (material.clearcoat != 0.0) {
593
+ color = color + material.clearcoat * evalPoint(
594
+ p.position, p.colorTimesIntensity, p.invRangeSquared,
595
+ in.worldPos, n, v, vec3<f32>(0.0), 1.0, coatAlpha, vec3<f32>(0.04),
596
+ );
597
+ }
598
+ #endif
599
+ }
600
+ let spotCount = spotLightsBuffer.count;
601
+ for (var i: u32 = 0u; i < spotCount; i = i + 1u) {
602
+ let s = spotLightsBuffer.slots[i];
603
+ // feat-20260625-spot-light-shadow-mapping M3 / w15 (plan-strategy D-1
604
+ // fragment side + D-3 + D-4): gate on the host-assigned shadowAtlasTile
605
+ // (>= 0 means a spot atlas tile was allocated; -1 sentinel means no
606
+ // shadow / clipped / direction-degenerate). The shadowed path reads the
607
+ // per-spot perspective matrix from the View UBO `view.spotLightViewProj`
608
+ // array (lane N = shadowAtlasTile N; folded from the standalone binding 9
609
+ // 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.
613
+ if (s.shadowAtlasTile >= 0) {
614
+ color = color + evalSpotShadowed(
615
+ s.position, s.direction, s.colorTimesIntensity,
616
+ s.cosInner, s.cosOuter, s.invRangeSquared,
617
+ in.worldPos, n, v, albedo, metallic, a, f0,
618
+ view.spotLightViewProj[s.shadowAtlasTile], s.shadowAtlasTile, 0.005, 0.05,
619
+ );
620
+ if (material.clearcoat != 0.0) {
621
+ color = color + material.clearcoat * evalSpotShadowed(
622
+ s.position, s.direction, s.colorTimesIntensity,
623
+ s.cosInner, s.cosOuter, s.invRangeSquared,
624
+ 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
+ );
627
+ }
628
+ } else {
629
+ color = color + evalSpot(
630
+ s.position, s.direction, s.colorTimesIntensity,
631
+ s.cosInner, s.cosOuter, s.invRangeSquared,
632
+ in.worldPos, n, v, albedo, metallic, a, f0,
633
+ );
634
+ if (material.clearcoat != 0.0) {
635
+ color = color + material.clearcoat * evalSpot(
636
+ s.position, s.direction, s.colorTimesIntensity,
637
+ s.cosInner, s.cosOuter, s.invRangeSquared,
638
+ in.worldPos, n, v, vec3<f32>(0.0), 1.0, coatAlpha, vec3<f32>(0.04),
639
+ );
640
+ }
641
+ }
642
+ }
643
+ #endif // CLUSTER_FORWARD_AVAILABLE
644
+ let emissiveUv = transformedMaterialUv(material.emissiveTextureCoordinatesTransform, material.emissiveTextureCoordinatesMetadata, in);
645
+ let emissiveSample = sampleMaterialTexture(emissiveTexture, emissiveSampler, emissiveUv, material.emissiveTextureCoordinatesMetadata.zw).rgb;
646
+ color = color + material.emissive * material.emissiveIntensity * emissiveSample;
647
+ return applySceneFog(view, color, alpha, in.worldPos);
648
+ }
649
+
650
+ // ── G-buffer output struct (feat-20260612-hdrp-deferred-shading M2 / w12) ──
651
+ //
652
+ // D-8: g-buffer fragment lives in default-standard-pbr as an additional entry
653
+ // point (`fs_gbuffer`), NOT a separate MaterialShader. This aligns with D-1
654
+ // (concept count compression — no new shader id for the same material).
655
+ //
656
+ // D-2 / requirements §3.2 g-buffer schema:
657
+ // @location(0) RT0 = normal.rgb + roughness.a → rgba16f
658
+ // @location(1) RT1 = albedo.rgb + metallic.a → rgba8unorm
659
+ // @location(2) RT2 = emissive.rgb + ao.a → rgba16f
660
+
661
+ struct GBufferOutput {
662
+ @location(0) normal_roughness : vec4<f32>,
663
+ @location(1) albedo_metallic : vec4<f32>,
664
+ @location(2) emissive_ao : vec4<f32>,
665
+ };
666
+
667
+ /// Deferred g-buffer fragment entry: writes material properties (normal,
668
+ /// albedo, roughness, metallic, emissive, ao) to a 3-RT g-buffer for the
669
+ /// deferred lighting pass to decode. Lighting evaluation is deferred — this
670
+ /// entry does NOT compute GGX / directional / cluster lights.
671
+ ///
672
+ /// Shares the same vertex shader `vs_main` and the same material UBO / texture
673
+ /// bindings as `fs_main`; only the fragment output differs. The HDRP
674
+ /// pipeline's g-buffer render pass binds this entry point via the shader's
675
+ /// multi-entry support (passKind='deferred' selects `fs_gbuffer`).
676
+ @fragment
677
+ fn fs_gbuffer(in : VsOut) -> GBufferOutput {
678
+ let baseUv = transformedMaterialUv(
679
+ material.baseColorTextureCoordinatesTransform,
680
+ material.baseColorTextureCoordinatesMetadata,
681
+ in,
682
+ );
683
+ let baseSample = sampleMaterialTexture(baseColorTexture, baseColorSampler, baseUv, material.baseColorTextureCoordinatesMetadata.zw);
684
+ let vertexColor = materialVertexColor(in);
685
+ alphaTest(material.baseColor.a * baseSample.a * vertexColor.a);
686
+ let alpha = materialAlpha(baseSample) * vertexColor.a;
687
+ let albedo = material.baseColor.rgb * baseSample.rgb * vertexColor.rgb;
688
+
689
+ let mrUv = transformedMaterialUv(
690
+ material.metallicRoughnessTextureCoordinatesTransform,
691
+ material.metallicRoughnessTextureCoordinatesMetadata,
692
+ in,
693
+ );
694
+ let mrSample = sampleMaterialTexture(metallicRoughnessTexture, metallicRoughnessSampler, mrUv, material.metallicRoughnessTextureCoordinatesMetadata.zw);
695
+ let metallic = material.metallic * pick_channel(mrSample, u32(material.metallicChannel));
696
+ let roughnessTex = pick_channel(mrSample, u32(material.roughnessChannel));
697
+
698
+ var a = max(material.roughness, 0.04);
699
+ a = a * roughnessTex;
700
+
701
+ let normalUv = transformedMaterialUv(material.normalTextureCoordinatesTransform, material.normalTextureCoordinatesMetadata, in);
702
+ let normSampleRg = sampleMaterialTexture(normalTexture, normalSampler, normalUv, material.normalTextureCoordinatesMetadata.zw).rg;
703
+ let normTangent = scaleTangentSpaceNormal(
704
+ decodeTangentSpaceNormalRg(normSampleRg), material.normalScale,
705
+ );
706
+ let n = applyTBN(in.worldNormal, in.worldTangent, normTangent);
707
+
708
+ let emissiveUv = transformedMaterialUv(material.emissiveTextureCoordinatesTransform, material.emissiveTextureCoordinatesMetadata, in);
709
+ let emissiveSample = sampleMaterialTexture(emissiveTexture, emissiveSampler, emissiveUv, material.emissiveTextureCoordinatesMetadata.zw).rgb;
710
+ let emissive = material.emissive * material.emissiveIntensity * emissiveSample;
711
+
712
+ let occlusionUv = transformedMaterialUv(material.occlusionTextureCoordinatesTransform, material.occlusionTextureCoordinatesMetadata, in);
713
+ let aoSample = sampleMaterialTexture(occlusionTexture, occlusionSampler, occlusionUv, material.occlusionTextureCoordinatesMetadata.zw);
714
+ let ao = mix(1.0, aoSample.r, material.occlusionStrength);
715
+
716
+ var out : GBufferOutput;
717
+ out.normal_roughness = vec4<f32>(n * 0.5 + 0.5, a);
718
+ out.albedo_metallic = vec4<f32>(albedo, metallic);
719
+ out.emissive_ao = vec4<f32>(emissive, ao);
720
+ return out;
721
+ }
722
+
723
+ struct TemporalVsOut {
724
+ @builtin(position) clip : vec4<f32>,
725
+ @location(0) uv : vec2<f32>,
726
+ @location(1) uv1 : vec2<f32>,
727
+ @location(2) uv2 : vec2<f32>,
728
+ @location(3) uv3 : vec2<f32>,
729
+ @location(4) uv4 : vec2<f32>,
730
+ @location(5) uv5 : vec2<f32>,
731
+ @location(6) uv6 : vec2<f32>,
732
+ @location(7) uv7 : vec2<f32>,
733
+ @location(8) @interpolate(linear) currentClip : vec4<f32>,
734
+ @location(9) @interpolate(linear) previousClip : vec4<f32>,
735
+ #ifdef VERTEX_COLOR_AVAILABLE
736
+ @location(14) color : vec4<f32>,
737
+ #endif
738
+ };
739
+
740
+ @vertex
741
+ fn vs_temporal(in : VsIn, @builtin(instance_index) idx : u32) -> TemporalVsOut {
742
+ let currentWorld = meshes[0].worldFromLocal *
743
+ instances[idx].localFromInstance * vec4<f32>(in.pos, 1.0);
744
+ var previousWorld = currentWorld;
745
+ #if STORAGE_BUFFER_AVAILABLE == true
746
+ previousWorld = meshes[0].previousWorldFromLocal *
747
+ instances[idx].previousLocalFromInstance * vec4<f32>(in.pos, 1.0);
748
+ #endif
749
+ var out : TemporalVsOut;
750
+ out.currentClip = view.temporalCurrentViewProj * currentWorld;
751
+ out.clip = out.currentClip;
752
+ out.previousClip = view.temporalPreviousViewProj * previousWorld;
753
+ out.uv = in.uv;
754
+ out.uv1 = in.uv1;
755
+ out.uv2 = in.uv2;
756
+ out.uv3 = in.uv3;
757
+ out.uv4 = in.uv4;
758
+ out.uv5 = in.uv5;
759
+ out.uv6 = in.uv6;
760
+ out.uv7 = in.uv7;
761
+ #ifdef VERTEX_COLOR_AVAILABLE
762
+ out.color = in.color;
763
+ #endif
764
+ return out;
765
+ }
766
+
767
+ fn temporalVertexAlpha(in : TemporalVsOut) -> f32 {
768
+ #ifdef VERTEX_COLOR_AVAILABLE
769
+ return in.color.a;
770
+ #else
771
+ return 1.0;
772
+ #endif
773
+ }
774
+
775
+ @fragment
776
+ fn fs_temporal(in : TemporalVsOut) -> @location(0) vec4<f32> {
777
+ var reactive = 1.0;
778
+ #if STORAGE_BUFFER_AVAILABLE == true
779
+ reactive = meshes[0].temporal.x;
780
+ #endif
781
+ return projectPbrSceneTemporal(
782
+ material.baseColor.a * temporalVertexAlpha(in),
783
+ material.alphaCutoff,
784
+ baseColorTexture,
785
+ baseColorSampler,
786
+ material.baseColorTextureCoordinatesTransform,
787
+ material.baseColorTextureCoordinatesMetadata,
788
+ in.currentClip,
789
+ in.previousClip,
790
+ reactive,
791
+ in.uv, in.uv1, in.uv2, in.uv3,
792
+ in.uv4, in.uv5, in.uv6, in.uv7,
793
+ );
794
+ }