@forgeax/engine-shader 0.1.23 → 0.1.24

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 (48) hide show
  1. package/README.md +13 -4
  2. package/dist/ShaderRegistry.d.ts.map +1 -1
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.mjs +28 -52
  6. package/dist/index.mjs.map +1 -1
  7. package/dist/material-schemas.d.ts +31 -0
  8. package/dist/material-schemas.d.ts.map +1 -1
  9. package/package.json +4 -4
  10. package/src/ShaderRegistry.ts +0 -17
  11. package/src/__tests__/default-standard-pbr-alpha.unit.test.ts +9 -3
  12. package/src/__tests__/default-standard-pbr-transmission.unit.test.ts +48 -166
  13. package/src/__tests__/deferred-lighting-ssao.test.ts +3 -3
  14. package/src/__tests__/ibl-irradiance.unit.test.ts +9 -0
  15. package/src/__tests__/lighting-directional.unit.test.ts +36 -0
  16. package/src/__tests__/lighting-punctual.unit.test.ts +12 -4
  17. package/src/__tests__/material-builtins.unit.test.ts +21 -3
  18. package/src/__tests__/material-contract.unit.test.ts +131 -17
  19. package/src/__tests__/physical-clearcoat.unit.test.ts +65 -0
  20. package/src/__tests__/probe-lighting-composition.unit.test.ts +3 -2
  21. package/src/__tests__/shader-manifest-transmission.unit.test.ts +8 -10
  22. package/src/__tests__/shader.unit.test.ts +52 -87
  23. package/src/__tests__/spot-cookie-composition.unit.test.ts +2 -1
  24. package/src/__tests__/standard-webgl2-varying-budget.unit.test.ts +17 -0
  25. package/src/__tests__/surface-v1-negative.unit.test.ts +37 -0
  26. package/src/__tests__/surface-v1.unit.test.ts +83 -0
  27. package/src/__tests__/transmission-thickness-scale.unit.test.ts +2 -6
  28. package/src/__tests__/vertex-color-variant.unit.test.ts +16 -5
  29. package/src/default-standard-pbr-skin.wgsl +678 -192
  30. package/src/default-standard-pbr.wgsl +563 -279
  31. package/src/default_standard_surface.wgsl +88 -0
  32. package/src/hdrp-cluster-forward.wgsl +225 -0
  33. package/src/ibl-irradiance.wgsl +43 -8
  34. package/src/ibl-prefilter.wgsl +13 -3
  35. package/src/ibl-shared.wgsl +9 -2
  36. package/src/index.ts +6 -0
  37. package/src/lighting-directional.wgsl +3 -1
  38. package/src/lighting-spot-modifiers.wgsl +8 -1
  39. package/src/lighting-spot-projector.wgsl +31 -0
  40. package/src/material/artifact-types.ts +2 -2
  41. package/src/material/physical/anisotropy.wgsl +26 -0
  42. package/src/material/physical/clearcoat.wgsl +24 -0
  43. package/src/material/physical/iridescence.wgsl +23 -0
  44. package/src/material/physical/sheen.wgsl +17 -0
  45. package/src/material-schemas.ts +65 -30
  46. package/src/register-default-standard-pbr-skin.ts +2 -2
  47. package/src/standard-cluster.wgsl +1 -36
  48. package/src/surface_v1.wgsl +26 -0
@@ -0,0 +1,88 @@
1
+ #define_import_path forgeax_material::default_standard_surface
2
+
3
+ #import forgeax_material::surface_v1::{SurfaceInput, SurfaceData}
4
+
5
+ // The default Surface owns only base facts. Standard's generated Material
6
+ // interface remains the source of the scalar and texture values; the
7
+ // Standard template owns physical-layer selection and lighting.
8
+ fn surfaceUv(input : SurfaceInput, transform : vec4<f32>, metadata : vec4<f32>) -> vec2<f32> {
9
+ let source = select(input.uv0, input.uv1, metadata.x >= 1.0);
10
+ let scaled = source * transform.zw;
11
+ let c = cos(metadata.y);
12
+ let s = sin(metadata.y);
13
+ return vec2<f32>(scaled.x * c - scaled.y * s, scaled.x * s + scaled.y * c) + transform.xy;
14
+ }
15
+
16
+ fn surfaceChannel(value : vec4<f32>, channel : u32) -> f32 {
17
+ switch (channel) {
18
+ case 0u: { return value.r; }
19
+ case 1u: { return value.g; }
20
+ case 2u: { return value.b; }
21
+ default: { return value.a; }
22
+ }
23
+ }
24
+
25
+ fn surfaceNormal(input : SurfaceInput, encoded : vec4<f32>, normalScale : f32) -> vec3<f32> {
26
+ let tangentXY = (encoded.rg * 2.0 - vec2<f32>(1.0)) * normalScale;
27
+ let tangentZ = sqrt(max(1.0 - dot(tangentXY, tangentXY), 0.0));
28
+ let geometric = normalize(input.geometricNormalWS);
29
+ let tangent = normalize(input.tangentWS.xyz - geometric * dot(geometric, input.tangentWS.xyz));
30
+ let bitangent = normalize(cross(geometric, tangent)) * input.tangentWS.w;
31
+ return normalize(tangent * tangentXY.x + bitangent * tangentXY.y + geometric * tangentZ);
32
+ }
33
+
34
+ fn evaluate_surface(input : SurfaceInput) -> SurfaceData {
35
+ let baseSample = textureSample(
36
+ baseColorTexture,
37
+ baseColorSampler,
38
+ surfaceUv(input, material.baseColorTextureCoordinatesTransform, material.baseColorTextureCoordinatesMetadata) * material.baseColorTextureCoordinatesMetadata.zw,
39
+ );
40
+ let metallicRoughnessSample = textureSample(
41
+ metallicRoughnessTexture,
42
+ metallicRoughnessSampler,
43
+ surfaceUv(input, material.metallicRoughnessTextureCoordinatesTransform, material.metallicRoughnessTextureCoordinatesMetadata) * material.metallicRoughnessTextureCoordinatesMetadata.zw,
44
+ );
45
+ let normalSample = textureSample(
46
+ normalTexture,
47
+ normalSampler,
48
+ surfaceUv(input, material.normalTextureCoordinatesTransform, material.normalTextureCoordinatesMetadata) * material.normalTextureCoordinatesMetadata.zw,
49
+ );
50
+ let emissiveSample = textureSample(
51
+ emissiveTexture,
52
+ emissiveSampler,
53
+ surfaceUv(input, material.emissiveTextureCoordinatesTransform, material.emissiveTextureCoordinatesMetadata) * material.emissiveTextureCoordinatesMetadata.zw,
54
+ );
55
+ let occlusionSample = textureSample(
56
+ occlusionTexture,
57
+ occlusionSampler,
58
+ surfaceUv(input, material.occlusionTextureCoordinatesTransform, material.occlusionTextureCoordinatesMetadata) * material.occlusionTextureCoordinatesMetadata.zw,
59
+ );
60
+ let vertexColor = input.vertexColor;
61
+ let baseColor = material.baseColor.rgb * baseSample.rgb * vertexColor.rgb;
62
+ let metallic = clamp(
63
+ material.metallic * surfaceChannel(metallicRoughnessSample, u32(material.metallicChannel)),
64
+ 0.0,
65
+ 1.0,
66
+ );
67
+ let roughness = clamp(
68
+ material.roughness * surfaceChannel(metallicRoughnessSample, u32(material.roughnessChannel)),
69
+ 0.04,
70
+ 1.0,
71
+ );
72
+ let emissive = material.emissive * material.emissiveIntensity * emissiveSample.rgb;
73
+ let occlusion = clamp(
74
+ 1.0 + (occlusionSample.r - 1.0) * material.occlusionStrength,
75
+ 0.0,
76
+ 1.0,
77
+ );
78
+ return SurfaceData(
79
+ baseColor,
80
+ surfaceNormal(input, normalSample, material.normalScale),
81
+ metallic,
82
+ roughness,
83
+ emissive,
84
+ occlusion,
85
+ clamp(material.baseColor.a * baseSample.a * vertexColor.a, 0.0, 1.0),
86
+ clamp(material.alphaCutoff, 0.0, 1.0),
87
+ );
88
+ }
@@ -0,0 +1,225 @@
1
+ // hdrp-cluster-forward.wgsl — HDRP cluster-forward punctual light evaluation.
2
+ // feat-20260608-cluster-lighting M4 / w17.
3
+ //
4
+ // The cluster module owns membership and raw payload decoding only. Punctual
5
+ // BRDF, range, cone, PCF, and fail-open behavior live in lighting-punctual.
6
+
7
+ #define_import_path forgeax_hdrp::cluster_forward
8
+
9
+ #import forgeax_pbr::lighting_punctual::{evalPoint, evalSpot, evalSpotShadowed}
10
+ #import forgeax_pbr::lighting_attenuation::{projectSpotUv}
11
+ #import forgeax_view::common::{view}
12
+ #ifdef PROJECTOR_AVAILABLE
13
+ #import forgeax_view::common::{projectorTexture, projectorSampler}
14
+ #endif
15
+ #ifdef POINT_SHADOW_AVAILABLE
16
+ #import forgeax_pbr::lighting_punctual::{evalPointShadowed}
17
+ #endif
18
+
19
+ #ifdef CLUSTER_FORWARD_AVAILABLE
20
+
21
+ const KIND_POINT: u32 = 0u;
22
+ const KIND_SPOT: u32 = 1u;
23
+ const PROJECTOR_ONLY_TILE: i32 = -2;
24
+
25
+ // LightSlot is the byte-frozen 64B std430/std140 transport contract.
26
+ // [0..2] position, [3] invRangeSquared
27
+ // [4..6] colorTimesIntensity, [7] cosInner
28
+ // [8..10] direction, [11] cosOuter
29
+ // [12] raw kind u32, [13] shadow identity i32
30
+ // [14] point near / spot shadow intensity f32 bits
31
+ // [15] point far / spot PCF kernel width f32 bits
32
+ struct LightSlot {
33
+ position : vec4<f32>,
34
+ color : vec4<f32>,
35
+ direction : vec4<f32>,
36
+ kind_and_shadow : vec4<u32>,
37
+ };
38
+
39
+ const LIGHTSLOT_BYTE_SIZE: u32 = 64u;
40
+
41
+ fn sampleClusterSpotProjector(lightViewProj : mat4x4<f32>, worldPos : vec3<f32>) -> vec3<f32> {
42
+ #ifdef PROJECTOR_AVAILABLE
43
+ let clip = lightViewProj * vec4<f32>(worldPos, 1.0);
44
+ // The projector is a cookie, not an extra cone. A fragment that cannot be
45
+ // projected (or lies outside the tile) keeps the analytic Spot contribution
46
+ // intact, matching Three's SpotLightNode fail-open map gate.
47
+ if (abs(clip.w) < 1e-6) { return vec3<f32>(1.0); }
48
+ let uv = projectSpotUv(lightViewProj, worldPos);
49
+ if (any(uv < vec2<f32>(0.0)) || any(uv > vec2<f32>(1.0))) {
50
+ return vec3<f32>(1.0);
51
+ }
52
+ return textureSampleLevel(projectorTexture, projectorSampler, uv, 0.0).rgb;
53
+ #else
54
+ return vec3<f32>(1.0);
55
+ #endif
56
+ }
57
+
58
+ struct ClusterUniform {
59
+ grid : vec4<u32>,
60
+ near_far_log : vec4<f32>,
61
+ };
62
+
63
+ #if STORAGE_BUFFER_AVAILABLE == true
64
+ @group(2) @binding(3) var<storage, read> light_data : array<LightSlot, 256>;
65
+ @group(2) @binding(4) var<storage, read> cluster_grid : array<u32>;
66
+ @group(2) @binding(5) var<storage, read> light_index_list : array<u32>;
67
+ #else
68
+ @group(2) @binding(3) var<uniform> light_data_uniform : array<LightSlot, 128>;
69
+ #endif
70
+ @group(2) @binding(6) var<uniform> cluster_uniform : ClusterUniform;
71
+
72
+ fn get_ssao_intensity() -> f32 {
73
+ return cluster_uniform.near_far_log.w;
74
+ }
75
+
76
+ fn view_z_to_z_slice(
77
+ view_z : f32,
78
+ grid_z : u32,
79
+ near : f32,
80
+ far : f32,
81
+ log_far_over_near : f32,
82
+ ) -> u32 {
83
+ if (view_z >= -near) {
84
+ return 0u;
85
+ }
86
+ let slice = floor(log(-view_z / near) / log_far_over_near * f32(grid_z));
87
+ let u_slice = u32(slice);
88
+ if (u_slice >= grid_z) {
89
+ return grid_z - 1u;
90
+ }
91
+ return u_slice;
92
+ }
93
+
94
+ fn ndc_position_to_cluster(
95
+ ndc : vec3<f32>,
96
+ view_z : f32,
97
+ grid_x : u32,
98
+ grid_y : u32,
99
+ grid_z : u32,
100
+ near : f32,
101
+ far : f32,
102
+ log_far : f32,
103
+ ) -> vec3<u32> {
104
+ let cx = clamp(u32(floor((ndc.x * 0.5 + 0.5) * f32(grid_x))), 0u, grid_x - 1u);
105
+ let cy = clamp(u32(floor((ndc.y * 0.5 + 0.5) * f32(grid_y))), 0u, grid_y - 1u);
106
+ let cz = view_z_to_z_slice(view_z, grid_z, near, far, log_far);
107
+ return vec3(cx, cy, cz);
108
+ }
109
+
110
+ // Decode one kind first, then delegate the complete evaluation to the shared
111
+ // punctual owner. Unknown kinds are explicitly zero contribution.
112
+ fn evaluate_cluster_light(
113
+ light : LightSlot,
114
+ world_pos : vec3<f32>,
115
+ normal : vec3<f32>,
116
+ view_dir : vec3<f32>,
117
+ base_color : vec3<f32>,
118
+ metallic : f32,
119
+ alpha_sq : f32,
120
+ f0 : vec3<f32>,
121
+ ) -> vec3<f32> {
122
+ let kind = light.kind_and_shadow.x;
123
+ if (kind == KIND_POINT) {
124
+ #ifdef POINT_SHADOW_AVAILABLE
125
+ let layer = bitcast<i32>(light.kind_and_shadow.y);
126
+ if (layer >= 0) {
127
+ return evalPointShadowed(
128
+ light.position.xyz, light.color.xyz, light.position.w,
129
+ world_pos, normal, view_dir, base_color, metallic, alpha_sq, f0,
130
+ layer,
131
+ bitcast<f32>(light.kind_and_shadow.z),
132
+ bitcast<f32>(light.kind_and_shadow.w),
133
+ 0.005, 0.05,
134
+ );
135
+ }
136
+ #endif
137
+ return evalPoint(
138
+ light.position.xyz, light.color.xyz, light.position.w,
139
+ world_pos, normal, view_dir, base_color, metallic, alpha_sq, f0,
140
+ );
141
+ }
142
+ if (kind == KIND_SPOT) {
143
+ let tile = bitcast<i32>(light.kind_and_shadow.y);
144
+ if (tile >= 0) {
145
+ let projector = sampleClusterSpotProjector(view.spotLightViewProj[tile], world_pos);
146
+ return evalSpotShadowed(
147
+ light.position.xyz, light.direction.xyz, light.color.xyz,
148
+ // LightSlot packs cosInner in color.w and cosOuter in direction.w;
149
+ // keep the evaluator's named cone order intact at the decode boundary.
150
+ light.color.w, light.direction.w, light.position.w,
151
+ world_pos, normal, view_dir, base_color, metallic, alpha_sq, f0,
152
+ view.spotLightViewProj[tile], tile, 0.005, 0.05,
153
+ clamp(
154
+ round(select(3.0, bitcast<f32>(light.kind_and_shadow.w),
155
+ bitcast<f32>(light.kind_and_shadow.w) > 0.5)),
156
+ 1.0,
157
+ 5.0,
158
+ ),
159
+ clamp(bitcast<f32>(light.kind_and_shadow.z), 0.0, 1.0),
160
+ ) * projector;
161
+ }
162
+ if (tile == PROJECTOR_ONLY_TILE) {
163
+ let projector = sampleClusterSpotProjector(view.spotLightViewProj[0], world_pos);
164
+ return evalSpot(
165
+ light.position.xyz, light.direction.xyz, light.color.xyz,
166
+ light.color.w, light.direction.w, light.position.w,
167
+ world_pos, normal, view_dir, base_color, metallic, alpha_sq, f0,
168
+ ) * projector;
169
+ }
170
+ return evalSpot(
171
+ light.position.xyz, light.direction.xyz, light.color.xyz,
172
+ light.color.w, light.direction.w, light.position.w,
173
+ world_pos, normal, view_dir, base_color, metallic, alpha_sq, f0,
174
+ );
175
+ }
176
+ return vec3<f32>(0.0);
177
+ }
178
+
179
+ fn evaluate_cluster_lights(
180
+ ndc : vec3<f32>,
181
+ view_z : f32,
182
+ world_pos : vec3<f32>,
183
+ normal : vec3<f32>,
184
+ view_dir : vec3<f32>,
185
+ base_color : vec3<f32>,
186
+ metallic : f32,
187
+ alpha_sq : f32,
188
+ f0 : vec3<f32>,
189
+ ) -> vec3<f32> {
190
+ let gx = cluster_uniform.grid.x;
191
+ let gy = cluster_uniform.grid.y;
192
+ let gz = cluster_uniform.grid.z;
193
+ let near = cluster_uniform.near_far_log.x;
194
+ let far = cluster_uniform.near_far_log.y;
195
+ let log_far = cluster_uniform.near_far_log.z;
196
+ var total_radiance = vec3<f32>(0.0);
197
+
198
+ #if STORAGE_BUFFER_AVAILABLE == true
199
+ let cluster_idx = ndc_position_to_cluster(ndc, view_z, gx, gy, gz, near, far, log_far);
200
+ let cluster_linear = cluster_idx.z * gy * gx + cluster_idx.y * gx + cluster_idx.x;
201
+ let grid_offset = cluster_linear * 2u;
202
+ let list_offset = cluster_grid[grid_offset];
203
+ let list_count = cluster_grid[grid_offset + 1u];
204
+ for (var i = 0u; i < list_count; i = i + 1u) {
205
+ let light = light_data[light_index_list[list_offset + i]];
206
+ total_radiance += evaluate_cluster_light(
207
+ light, world_pos, normal, view_dir, base_color, metallic, alpha_sq, f0,
208
+ );
209
+ }
210
+ #else
211
+ let light_count = min(cluster_uniform.grid.w, 128u);
212
+ for (var i = 0u; i < 128u; i = i + 1u) {
213
+ if (i >= light_count) {
214
+ break;
215
+ }
216
+ total_radiance += evaluate_cluster_light(
217
+ light_data_uniform[i], world_pos, normal, view_dir,
218
+ base_color, metallic, alpha_sq, f0,
219
+ );
220
+ }
221
+ #endif
222
+ return total_radiance;
223
+ }
224
+
225
+ #endif // CLUSTER_FORWARD_AVAILABLE
@@ -3,10 +3,12 @@
3
3
  // @forgeax/engine-shader - ibl-irradiance.wgsl
4
4
  // (feat-20260520-skylight-ibl-cubemap M3 / t44).
5
5
  //
6
- // Diffuse irradiance convolution. Per LearnOpenGL §6.2.2: hemisphere
7
- // Riemann sum (sampleDelta = 0.025) integrates the env cubemap to produce
8
- // the convolved irradiance cubemap consumed by sampleIblDiffuse() at
9
- // runtime.
6
+ // Diffuse irradiance convolution. Per LearnOpenGL §6.2.2: a bounded
7
+ // hemisphere Riemann sum (sampleDelta = 0.05) integrates the env cubemap to
8
+ // produce the convolved irradiance cubemap consumed by sampleIblDiffuse() at
9
+ // runtime. The budget is deliberately bounded for the rgba16float bake target
10
+ // so every backend completes the fragment without overflowing its shader
11
+ // work budget.
10
12
  //
11
13
  // @group(0) = per-face viewProj uniform.
12
14
  // @group(1) = env cubemap (texture_cube<f32>) + sampler. This is the same
@@ -36,7 +38,25 @@ struct CubemapFaceUniforms {
36
38
  @group(1) @binding(0) var envCube: texture_cube<f32>;
37
39
  @group(1) @binding(1) var envSamplerS: sampler;
38
40
 
39
- const IRRADIANCE_SAMPLE_DELTA: f32 = 0.025;
41
+ const IRRADIANCE_SAMPLE_DELTA: f32 = 0.05;
42
+
43
+ // The bake target is rgba16float, so a sample is admissible only when all of
44
+ // its lanes can be represented by that format. Equality catches NaN while
45
+ // the bound also rejects infinities before they enter the running sum.
46
+ fn irradianceFiniteScalar(value: f32) -> bool {
47
+ return value == value && abs(value) <= 65504.0;
48
+ }
49
+
50
+ fn irradianceFiniteVec3(value: vec3<f32>) -> bool {
51
+ return irradianceFiniteScalar(value.x) &&
52
+ irradianceFiniteScalar(value.y) &&
53
+ irradianceFiniteScalar(value.z);
54
+ }
55
+
56
+ fn irradianceSanitizeScalar(value: f32) -> f32 {
57
+ let bounded = clamp(value, 0.0, 65504.0);
58
+ return select(0.0, bounded, irradianceFiniteScalar(value));
59
+ }
40
60
 
41
61
  @vertex
42
62
  fn cubemap_vs(in0: CubemapVsIn) -> CubemapVsOut {
@@ -76,8 +96,18 @@ fn irradianceConvolve_fs(in0: CubemapVsOut) -> @location(0) vec4<f32> {
76
96
  let sampleColor = textureSampleLevel(
77
97
  envCube, envSamplerS, sampleVec, 0.0,
78
98
  ).rgb;
79
- irradiance += sampleColor * cos(theta) * sin(theta);
80
- nrSamples += 1.0;
99
+ // A malformed direction or backend sample must not poison the whole
100
+ // irradiance face. Skip only non-finite samples and keep the
101
+ // normalization count in lockstep with the accumulated radiance.
102
+ let sampleWeight = cos(theta) * sin(theta);
103
+ if (
104
+ irradianceFiniteVec3(sampleVec) &&
105
+ irradianceFiniteVec3(sampleColor) &&
106
+ irradianceFiniteScalar(sampleWeight)
107
+ ) {
108
+ irradiance += sampleColor * sampleWeight;
109
+ nrSamples += 1.0;
110
+ }
81
111
  theta += IRRADIANCE_SAMPLE_DELTA;
82
112
  }
83
113
  phi += IRRADIANCE_SAMPLE_DELTA;
@@ -88,5 +118,10 @@ fn irradianceConvolve_fs(in0: CubemapVsOut) -> @location(0) vec4<f32> {
88
118
  // consumes this payload directly; applying another Lambert divide would
89
119
  // darken diffuse IBL by PI.
90
120
  irradiance = PI * irradiance / max(nrSamples, 1.0);
91
- return vec4<f32>(irradiance, 1.0);
121
+ return vec4<f32>(
122
+ irradianceSanitizeScalar(irradiance.x),
123
+ irradianceSanitizeScalar(irradiance.y),
124
+ irradianceSanitizeScalar(irradiance.z),
125
+ 1.0,
126
+ );
92
127
  }
@@ -36,8 +36,10 @@ struct CubemapFaceUniforms {
36
36
  struct PrefilterUniforms {
37
37
  roughness: f32,
38
38
  faceSize: f32,
39
+ // Actual mip count of the source cube bound at group(1). The source cube is
40
+ // currently base-level-only; the value prevents sampling an unallocated mip.
41
+ sourceMipLevelCount: f32,
39
42
  // Keep the uniform block 16-byte aligned on WebGL2 downlevel backends.
40
- _pad0: f32,
41
43
  _pad1: f32,
42
44
  };
43
45
 
@@ -48,6 +50,7 @@ struct PrefilterUniforms {
48
50
  @group(1) @binding(1) var envSamplerS: sampler;
49
51
 
50
52
  const PREFILTER_SAMPLE_COUNT: u32 = 1024u;
53
+ const PREFILTER_MIP_LEVEL_COUNT: f32 = 5.0;
51
54
 
52
55
  @vertex
53
56
  fn cubemap_vs(in0: CubemapVsIn) -> CubemapVsOut {
@@ -78,16 +81,23 @@ fn prefilterEnv_fs(in0: CubemapVsOut) -> @location(0) vec4<f32> {
78
81
  let D0 = iblDGGX(max(dot(N, H), 0.0), roughness);
79
82
  let NdotH0 = max(dot(N, H), 0.0);
80
83
  let HdotV = max(dot(H, V), 0.0);
81
- let pdf = D0 * NdotH0 / (4.0 * HdotV) + 0.0001;
84
+ // A grazing half-vector can make HdotV exactly zero. Keep the PDF
85
+ // finite so the mip calculation cannot feed NaN into textureSampleLevel.
86
+ let pdf = D0 * NdotH0 / max(4.0 * HdotV, 0.0001) + 0.0001;
82
87
 
83
88
  let resolution: f32 = 512.0;
84
89
  let saTexel = 4.0 * PI / (6.0 * resolution * resolution);
85
90
  let saSample = 1.0 / (f32(PREFILTER_SAMPLE_COUNT) * pdf + 0.0001);
86
91
 
87
- let mipLevel = select(
92
+ let requestedMipLevel = min(select(
88
93
  0.5 * log2(saSample / saTexel),
89
94
  0.0,
90
95
  roughness == 0.0,
96
+ ), PREFILTER_MIP_LEVEL_COUNT - 1.0);
97
+ let mipLevel = clamp(
98
+ requestedMipLevel,
99
+ 0.0,
100
+ max(prefUniforms.sourceMipLevelCount - 1.0, 0.0),
91
101
  );
92
102
 
93
103
  prefilteredColor += textureSampleLevel(
@@ -30,7 +30,10 @@ const PI: f32 = 3.14159265;
30
30
 
31
31
  // Map a direction vector to equirectangular UV.
32
32
  fn sampleSphericalMap(v: vec3<f32>) -> vec2<f32> {
33
- let uv = vec2<f32>(atan2(v.z, v.x), asin(v.y));
33
+ // Cube-face rasterization can produce a direction whose normalized Y lane
34
+ // is a few ulps outside [-1, 1]. `asin` propagates that rounding error as
35
+ // NaN, poisoning the generated cubemap and every later IBL sample.
36
+ let uv = vec2<f32>(atan2(v.z, v.x), asin(clamp(v.y, -1.0, 1.0)));
34
37
  return uv * INV_ATAN + 0.5;
35
38
  }
36
39
 
@@ -87,7 +90,11 @@ fn importanceSampleGGX(Xi: vec2<f32>, N: vec3<f32>, roughness: f32) -> vec3<f32>
87
90
  let a = roughness * roughness;
88
91
 
89
92
  let phi = 2.0 * PI * Xi.x;
90
- let cosTheta = sqrt(max((1.0 - Xi.y) / (1.0 + (a * a - 1.0) * Xi.y), 0.0));
93
+ // The production Hammersley domain is i < N, so Xi.y stays below one.
94
+ // Retain a finite lower bound for callers that provide an endpoint sample
95
+ // directly; this is defensive input handling, not the production NaN fix.
96
+ let denominator = max(1.0 + (a * a - 1.0) * Xi.y, 1e-5);
97
+ let cosTheta = sqrt(max((1.0 - Xi.y) / denominator, 0.0));
91
98
  let sinTheta = sqrt(max(1.0 - cosTheta * cosTheta, 0.0));
92
99
 
93
100
  // GGX half-vector in tangent space.
package/src/index.ts CHANGED
@@ -68,9 +68,15 @@ export {
68
68
  DEFAULT_SPRITE_PARAM_SCHEMA,
69
69
  DEFAULT_STANDARD_PBR_PARAM_SCHEMA,
70
70
  DEFAULT_UNLIT_PARAM_SCHEMA,
71
+ STANDARD_BASE_PARAM_SCHEMA,
71
72
  STANDARD_PBR_ALPHA_CUTOFF_DEFAULT,
72
73
  STANDARD_PBR_ARTIFACT_RECEIPT,
73
74
  STANDARD_PBR_SKIN_ARTIFACT_RECEIPT,
75
+ STANDARD_PHYSICAL_LAYER_PARAM_SCHEMA,
76
+ STANDARD_PHYSICAL_TEXTURE_FIELDS,
77
+ STANDARD_PIPELINE_PARAM_SCHEMA,
78
+ type StandardPhysicalTextureField,
79
+ standardPhysicalTextureFields,
74
80
  } from './material-schemas.js';
75
81
  export {
76
82
  registerDefaultSpriteLit,
@@ -408,7 +408,9 @@ fn evalDirectionalNoShadow(
408
408
  F0 : vec3<f32>,
409
409
  ) -> vec3<f32> {
410
410
  let l = normalize(-view.lightDir);
411
- let h = normalize(viewDir + l);
411
+ let halfVector = viewDir + l;
412
+ let halfVectorLengthSquared = max(dot(halfVector, halfVector), 1e-8);
413
+ let h = halfVector * inverseSqrt(halfVectorLengthSquared);
412
414
  let nDotL = max(dot(normal, l), 0.0);
413
415
  let nDotV = max(dot(normal, viewDir), 1e-5);
414
416
  let nDotH = max(dot(normal, h), 0.0);
@@ -67,7 +67,14 @@ fn spotModifierFactors(
67
67
  ies = max(textureSampleLevel(iesProfileTexture, spotModifierSampler, iesUv, metadata.z, 0.0).r, 0.0);
68
68
  }
69
69
 
70
- if (metadata.w != 0xffffffffu && (metadata.y & PROJECTOR_FLAG) == 0u) {
70
+ // The shadow lane uses 0xffffffff as its no-shadow sentinel. That value
71
+ // contains PROJECTOR_FLAG bits, so test the sentinel before interpreting
72
+ // the lane as a projector marker; otherwise every non-shadow Cookie spot
73
+ // silently takes the projector exclusion path.
74
+ if (
75
+ metadata.w != 0xffffffffu &&
76
+ (metadata.y == 0xffffffffu || (metadata.y & PROJECTOR_FLAG) == 0u)
77
+ ) {
71
78
  if (depth <= 0.0) {
72
79
  cookie = vec3<f32>(0.0);
73
80
  } else {
@@ -0,0 +1,31 @@
1
+ #define_import_path forgeax_pbr::lighting_spot_projector
2
+
3
+ // Clustered Standard owns the light membership/decode, while this helper
4
+ // keeps projector projection and resource sampling in one module. Keeping the
5
+ // projection/sample pair out of standard-cluster avoids a naga-oil scope edge
6
+ // when the low-resource projector path is composed with the shared cluster.
7
+ #import forgeax_pbr::lighting_attenuation::{projectSpotUv}
8
+ #ifdef EXTENDED_LIGHTING_AVAILABLE
9
+ #import forgeax_view::common::{spotModifierSampler, cookieTexture}
10
+ #else
11
+ #import forgeax_view::common::{projectorTexture, projectorSampler}
12
+ #endif
13
+
14
+ fn sampleStandardSpotProjector(
15
+ lightViewProj : mat4x4<f32>,
16
+ world_pos : vec3<f32>,
17
+ metadata : vec4<u32>,
18
+ ) -> vec3<f32> {
19
+ let uv = projectSpotUv(lightViewProj, world_pos);
20
+ if (!(uv.x >= 0.0 && uv.x <= 1.0 && uv.y >= 0.0 && uv.y <= 1.0)) {
21
+ return vec3<f32>(1.0);
22
+ }
23
+ #ifdef EXTENDED_LIGHTING_AVAILABLE
24
+ if (metadata.w == 0xffffffffu) {
25
+ return vec3<f32>(1.0);
26
+ }
27
+ return textureSampleLevel(cookieTexture, spotModifierSampler, uv, metadata.w, 0.0).rgb;
28
+ #else
29
+ return textureSampleLevel(projectorTexture, projectorSampler, uv, 0.0).rgb;
30
+ #endif
31
+ }
@@ -58,7 +58,7 @@ const STANDARD_PBR_TEXTURES = [
58
58
  'baseColorTexture',
59
59
  'metallicRoughnessTexture',
60
60
  'normalTexture',
61
- 'specularTintTexture',
61
+ 'specularColorTexture',
62
62
  'emissiveTexture',
63
63
  'occlusionTexture',
64
64
  'transmissionTexture',
@@ -79,7 +79,7 @@ const STANDARD_PBR_NUMERIC_FIELDS = [
79
79
  'alphaCutoff',
80
80
  'clearcoat',
81
81
  'clearcoatRoughness',
82
- 'specularTint',
82
+ 'specularColor',
83
83
  'normalScale',
84
84
  'transmission',
85
85
  'ior',
@@ -0,0 +1,26 @@
1
+ #define_import_path forgeax_pbr::anisotropy
2
+
3
+ // Engine-owned anisotropy helpers. The root contract supplies strength and
4
+ // rotation; the map (when present) supplies tangent direction in RG and a
5
+ // strength multiplier in B. Keeping the frame operation here means the
6
+ // Standard evaluator and custom Surface share one deterministic seam.
7
+ fn evaluateAnisotropicNormal(
8
+ normal : vec3<f32>,
9
+ tangent : vec4<f32>,
10
+ strength : f32,
11
+ rotation : f32,
12
+ direction : vec2<f32>,
13
+ ) -> vec3<f32> {
14
+ let frameTangent = normalize(tangent.xyz);
15
+ let handedBitangent = normalize(cross(normal, frameTangent)) * select(-1.0, 1.0, tangent.w >= 0.0);
16
+ var mapDirection = vec2<f32>(1.0, 0.0);
17
+ if (dot(direction, direction) >= 1e-6) {
18
+ mapDirection = normalize(direction);
19
+ }
20
+ let mappedTangent = normalize(frameTangent * mapDirection.x + handedBitangent * mapDirection.y);
21
+ let mappedBitangent = normalize(cross(normal, mappedTangent)) * select(-1.0, 1.0, tangent.w >= 0.0);
22
+ let axis = normalize(mappedTangent * cos(rotation) + mappedBitangent * sin(rotation));
23
+ // A bounded bent normal preserves the zero-strength isotropic path while
24
+ // moving only the base specular highlight for non-zero anisotropy.
25
+ return normalize(normal + axis * clamp(strength, -0.999, 0.999) * 0.35);
26
+ }
@@ -0,0 +1,24 @@
1
+ #define_import_path forgeax_pbr::clearcoat
2
+
3
+ // Clearcoat is the terminal physical layer. Its Fresnel term owns both the
4
+ // attenuation of lower layers and the weight of the coat radiance.
5
+ // The root supplies clearcoatRoughness and clearcoatNormalScale after their
6
+ // texture projections; this module deliberately owns only layer energy.
7
+ fn evaluateClearcoatFresnel(viewCosine : f32, clearcoatFactor : f32) -> f32 {
8
+ let cosine = clamp(viewCosine, 0.0, 1.0);
9
+ let oneMinusCosine = 1.0 - cosine;
10
+ let dielectricFresnel = 0.04 + 0.96 * oneMinusCosine * oneMinusCosine *
11
+ oneMinusCosine * oneMinusCosine * oneMinusCosine;
12
+ return clamp(clearcoatFactor, 0.0, 1.0) * dielectricFresnel;
13
+ }
14
+
15
+ fn evaluateClearcoatLayer(
16
+ baseRadiance : vec3<f32>,
17
+ coatRadiance : vec3<f32>,
18
+ viewCosine : f32,
19
+ clearcoatFactor : f32,
20
+ ) -> vec3<f32> {
21
+ let fresnel = evaluateClearcoatFresnel(viewCosine, clearcoatFactor);
22
+ let attenuatedBase = baseRadiance * (1.0 - fresnel);
23
+ return attenuatedBase + coatRadiance * fresnel;
24
+ }
@@ -0,0 +1,23 @@
1
+ #define_import_path forgeax_pbr::iridescence
2
+
3
+ // Bounded RGB thin-film Fresnel approximation. Thickness is deliberately
4
+ // not clamped to min/max order: the glTF contract permits inverted bounds and
5
+ // the caller supplies the authored interval verbatim.
6
+ fn evaluateIridescenceFresnel(
7
+ baseF0 : vec3<f32>,
8
+ strength : f32,
9
+ filmIor : f32,
10
+ thicknessNanometres : f32,
11
+ ) -> vec3<f32> {
12
+ let safeStrength = clamp(strength, 0.0, 1.0);
13
+ let safeIor = max(filmIor, 1.0);
14
+ let base = clamp((safeIor - 1.0) / (safeIor + 1.0), 0.0, 1.0);
15
+ let phase = thicknessNanometres * 0.018;
16
+ let spectral = vec3<f32>(
17
+ 0.5 + 0.5 * cos(phase),
18
+ 0.5 + 0.5 * cos(phase + 2.0943952),
19
+ 0.5 + 0.5 * cos(phase + 4.1887903),
20
+ );
21
+ let film = clamp(vec3<f32>(base) * spectral, vec3<f32>(0.0), vec3<f32>(1.0));
22
+ return clamp(mix(baseF0, film, safeStrength), vec3<f32>(0.0), vec3<f32>(1.0));
23
+ }
@@ -0,0 +1,17 @@
1
+ #define_import_path forgeax_pbr::sheen
2
+
3
+ // Charlie-like grazing lobe approximation with explicit energy compensation.
4
+ // It is evaluated below clearcoat, so the caller attenuates this result when
5
+ // the top coat Fresnel is applied.
6
+ fn evaluateSheenLayer(
7
+ baseRadiance : vec3<f32>,
8
+ sheenColor : vec3<f32>,
9
+ sheenRoughness : f32,
10
+ viewCosine : f32,
11
+ ) -> vec3<f32> {
12
+ let roughness = clamp(sheenRoughness, 0.04, 1.0);
13
+ let grazing = pow(1.0 - clamp(viewCosine, 0.0, 1.0), 2.0);
14
+ let lobe = grazing * (1.0 - 0.5 * roughness);
15
+ let energy = clamp(max(max(sheenColor.r, sheenColor.g), sheenColor.b), 0.0, 1.0);
16
+ return baseRadiance * (1.0 - energy * lobe * 0.5) + sheenColor * lobe;
17
+ }