@forgeax/engine-shader 0.1.19 → 0.1.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +10 -0
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/ShaderRegistry.d.ts.map +1 -1
  4. package/dist/index.d.ts +2 -0
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.mjs +52 -1
  7. package/dist/index.mjs.map +1 -1
  8. package/package.json +4 -4
  9. package/src/ShaderRegistry.ts +65 -0
  10. package/src/__tests__/bloom-fxaa-tonemap.unit.test.ts +30 -4
  11. package/src/__tests__/default-standard-pbr-alpha.unit.test.ts +7 -0
  12. package/src/__tests__/default-standard-pbr-transmission.unit.test.ts +71 -22
  13. package/src/__tests__/fog-oracle.unit.test.ts +110 -114
  14. package/src/__tests__/fog-orthographic-ray.unit.test.ts +12 -37
  15. package/src/__tests__/fog-producer-matrix.integration.test.ts +16 -51
  16. package/src/__tests__/lighting-point-spot-volume.unit.test.ts +56 -0
  17. package/src/__tests__/lighting-punctual.unit.test.ts +32 -6
  18. package/src/__tests__/lighting-spot-volume-attenuation.unit.test.ts +26 -0
  19. package/src/__tests__/lighting-spot-volume.unit.test.ts +25 -0
  20. package/src/__tests__/material-derived-builtins.integration.test.ts +27 -0
  21. package/src/__tests__/reflection-probe-sampling.unit.test.ts +23 -0
  22. package/src/__tests__/scene-temporal.unit.test.ts +20 -0
  23. package/src/__tests__/shader.unit.test.ts +35 -13
  24. package/src/__tests__/shadow-pcss.unit.test.ts +131 -0
  25. package/src/__tests__/sprite-lit-shader.test.ts +24 -11
  26. package/src/__tests__/sprite-variants.unit.test.ts +2 -0
  27. package/src/__tests__/transmission-thickness-scale.unit.test.ts +7 -1
  28. package/src/__tests__/transparent-pbr.unit.test.ts +7 -0
  29. package/src/common.wgsl +72 -73
  30. package/src/default-standard-pbr-skin.wgsl +31 -89
  31. package/src/default-standard-pbr.wgsl +94 -146
  32. package/src/fxaa.wgsl +27 -6
  33. package/src/hdrp-deferred-lighting.wgsl +3 -27
  34. package/src/ibl-sampling.wgsl +54 -0
  35. package/src/index.ts +6 -0
  36. package/src/lighting-attenuation.wgsl +58 -0
  37. package/src/lighting-directional.wgsl +188 -21
  38. package/src/lighting-punctual.wgsl +94 -19
  39. package/src/msdf-text.wgsl +4 -23
  40. package/src/scene-temporal.wgsl +14 -8
  41. package/src/shadow-pcf.wgsl +75 -11
  42. package/src/skybox.wgsl +2 -7
  43. package/src/sprite-lit.wgsl +38 -73
  44. package/src/sprite.wgsl +3 -22
  45. package/src/{hdrp-cluster-forward.wgsl → standard-cluster.wgsl} +74 -23
  46. package/src/tonemap.wgsl +8 -3
  47. package/src/unlit.wgsl +2 -24
  48. package/src/volume/volume-composite.wgsl +113 -0
  49. package/src/volume/volume-inject.wgsl +261 -0
  50. package/src/volume/volume-integrate.wgsl +337 -0
  51. package/src/volume/volume-temporal.wgsl +70 -0
package/src/skybox.wgsl CHANGED
@@ -23,9 +23,8 @@
23
23
  // @binding(3) rotation : SkyboxRotation (UBO)
24
24
 
25
25
  #import forgeax_view::common::FullscreenOutput
26
- #import forgeax_view::common::{View, FogViewParams, FogRay}
26
+ #import forgeax_view::common::View
27
27
  #import forgeax_view::common::fullscreen_triangle
28
- #import forgeax_view::fog::{apply_fog}
29
28
  #import forgeax_pbr::ibl_shared::{inverseRotateEnvironment}
30
29
 
31
30
  @group(0) @binding(0) var cubemap : texture_cube<f32>;
@@ -67,9 +66,5 @@ fn skyboxDirection(uv : vec2<f32>) -> vec3<f32> {
67
66
  fn skybox_fs(in : FullscreenOutput) -> @location(0) vec4<f32> {
68
67
  let dir = skyboxDirection(in.uv);
69
68
  let color = textureSample(cubemap, cubemapSampler, dir).rgb;
70
- return apply_fog(
71
- view.fog,
72
- FogRay(view.cameraPos, dir, max(view.temporalProjection.y, 0.0)),
73
- vec4<f32>(color, 1.0),
74
- );
69
+ return vec4<f32>(color, 1.0);
75
70
  }
@@ -1,9 +1,12 @@
1
1
  #pragma variant_axis STORAGE_BUFFER_AVAILABLE
2
+ #pragma variant_axis CLUSTER_FORWARD_AVAILABLE
2
3
  #define_import_path forgeax_material::sprite-lit
3
4
 
4
- #import forgeax_view::common::{View, FogViewParams, FogRay, Mesh, InstanceData, PointLight, SpotLight, view, meshes, instances, pointLightsBuffer, spotLightsBuffer, sampleMaterialTexture}
5
- #import forgeax_scene_temporal::{packSceneTemporalV1}
6
- #import forgeax_view::fog::{apply_fog}
5
+ #import forgeax_view::common::{View, Mesh, InstanceData, view, meshes, instances, sampleMaterialTexture}
6
+ #import forgeax_scene_temporal::{packSceneTemporalV1, sceneViewZ}
7
+ #ifdef CLUSTER_FORWARD_AVAILABLE
8
+ #import forgeax_standard::cluster::{evaluateStandardClusterLights}
9
+ #endif
7
10
 
8
11
  // @forgeax/engine-shader - sprite-lit.wgsl
9
12
  // (tweak-20260701-sprite-lit-flat-default-drop-ndotl-for-2d).
@@ -27,7 +30,8 @@
27
30
  // - no shadow receiver / caster
28
31
  // - no Godot-style light_mode blends
29
32
  // - no IBL ambient
30
- // - no HDRP cluster-forward path
33
+ // - Standard clustered punctual lights are enabled by the
34
+ // CLUSTER_FORWARD_AVAILABLE variant.
31
35
  //
32
36
  // >>> AI user error self-recovery hint:
33
37
  // sprite-lit needs at least one light in scene; for unlit sprites use
@@ -39,8 +43,7 @@
39
43
  // pipelineState.defaultSampler + .defaultWhiteTextureView on the host side):
40
44
  //
41
45
  // @group(0) @binding(0) view uniform
42
- // @group(0) @binding(1) pointLightsBuffer storage / uniform (variant)
43
- // @group(0) @binding(2) spotLightsBuffer storage / uniform (variant)
46
+ // @group(0) @binding(1..2) Standard Cluster storage (selected variant)
44
47
  // @group(1) @binding(0) material uniform (sprite layout
45
48
  // colorTint /
46
49
  // region /
@@ -81,16 +84,17 @@ struct VsIn {
81
84
  @location(3) tangent : vec4<f32>,
82
85
  };
83
86
 
84
- // VsOut carries the atlas UV plus the per-fragment world-space position.
85
- // The world position is emitted from the vertex stage (already computed
86
- // there for `out.clip`), so PointLight / SpotLight attenuation gets an
87
- // exact per-fragment worldPos through interpolation. This replaces the
88
- // earlier fragment-side reconstruction path, which collapsed under
89
- // multi-instance draws and could not handle the 9-slice quad layout.
87
+ // VsOut carries the atlas UV plus the per-fragment world-space position and
88
+ // the Standard cluster coordinates produced by the vertex stage. Fragment
89
+ // `@builtin(position)` is framebuffer-space after rasterization, so it is not
90
+ // a valid source for NDC cluster lookup; keep the perspective divide here and
91
+ // interpolate the result alongside worldPos.
90
92
  struct VsOut {
91
93
  @builtin(position) clip : vec4<f32>,
92
94
  @location(0) uv_atlas : vec2<f32>,
93
95
  @location(1) worldPos : vec3<f32>,
96
+ @location(2) ndc : vec3<f32>,
97
+ @location(3) viewZ : f32,
94
98
  };
95
99
 
96
100
  struct SpriteVertex {
@@ -156,6 +160,11 @@ fn vs_main(in : VsIn, @builtin(instance_index) idx : u32, @builtin(vertex_index)
156
160
  out.clip = view.worldViewProj * world;
157
161
  out.uv_atlas = vertex.uvAtlas;
158
162
  out.worldPos = world.xyz;
163
+ let clipPos = out.clip;
164
+ out.ndc = clipPos.xyz / clipPos.w;
165
+ // Keep the cluster depth exactly aligned with the CPU binner for both
166
+ // perspective and off-axis orthographic projections.
167
+ out.viewZ = sceneViewZ(clipPos, view.temporalProjection);
159
168
  return out;
160
169
  }
161
170
 
@@ -175,79 +184,36 @@ fn spriteLitDirectional(albedo : vec3<f32>) -> vec3<f32> {
175
184
  return albedo * view.lightColor;
176
185
  }
177
186
 
178
- // Flat point-light contribution. Applies the KHR-lights-punctual smooth-
179
- // window range attenuation only. `attenuation` is 0 outside the light's
180
- // range (`invRangeSquared`) and 1 at the light center; the reciprocal-square
181
- // term gives the physical falloff. URP cap = 4 point lights per pass.
182
- fn spriteLitPoint(p : PointLight, worldPos : vec3<f32>, albedo : vec3<f32>) -> vec3<f32> {
183
- let toLight = p.position - worldPos;
184
- let dSquared = max(dot(toLight, toLight), 1e-4);
185
- let factor = 1.0 - (dSquared * p.invRangeSquared) * (dSquared * p.invRangeSquared);
186
- let attenuation = max(min(factor, 1.0), 0.0) / dSquared;
187
- return albedo * p.colorTimesIntensity * attenuation;
188
- }
189
-
190
- // Flat spot-light contribution. Same range attenuation as spriteLitPoint,
191
- // modulated by a smoothstep cone factor (host-side degree -> cosine
192
- // conversion). Spot direction only shapes the cone; it does not project
193
- // onto a surface normal. URP cap = 4 spot lights per pass.
194
- fn spriteLitSpot(s : SpotLight, worldPos : vec3<f32>, albedo : vec3<f32>) -> vec3<f32> {
195
- let toLight = s.position - worldPos;
196
- let dSquared = max(dot(toLight, toLight), 1e-4);
197
- let l = toLight / sqrt(dSquared);
198
- let factor = 1.0 - (dSquared * s.invRangeSquared) * (dSquared * s.invRangeSquared);
199
- let attenuation = max(min(factor, 1.0), 0.0) / dSquared;
200
- let cone = smoothstep(s.cosOuter, s.cosInner, dot(l, -s.direction));
201
- return albedo * s.colorTimesIntensity * attenuation * cone;
202
- }
203
-
204
187
  // Shared shading body used by both fs_main (LDR) and fs_main_hdr (HDR).
205
188
  // The two entry points differ only in their tail-end clamp + srgb encode
206
189
  // step (LDR clamp / HDR pass-through boundary).
207
- fn spriteLitShadeAccum(albedo : vec3<f32>, worldPos : vec3<f32>) -> vec3<f32> {
190
+ fn spriteLitShadeAccum(
191
+ albedo : vec3<f32>,
192
+ worldPos : vec3<f32>,
193
+ ndc : vec3<f32>,
194
+ viewZ : f32,
195
+ ) -> vec3<f32> {
208
196
  // Directional contribution (1 light, View UBO).
209
197
  var lit = spriteLitDirectional(albedo);
210
- // Point-light contribution (URP cap = 4; PointLightsArray.count).
211
- let pointCount = pointLightsBuffer.count;
212
- for (var i : u32 = 0u; i < pointCount; i = i + 1u) {
213
- let p = pointLightsBuffer.slots[i];
214
- lit = lit + spriteLitPoint(p, worldPos, albedo);
215
- }
216
- // Spot-light contribution (URP cap = 4).
217
- let spotCount = spotLightsBuffer.count;
218
- for (var i : u32 = 0u; i < spotCount; i = i + 1u) {
219
- let s = spotLightsBuffer.slots[i];
220
- lit = lit + spriteLitSpot(s, worldPos, albedo);
221
- }
198
+ #ifdef CLUSTER_FORWARD_AVAILABLE
199
+ let viewDir = normalize(view.cameraPos - worldPos);
200
+ lit = lit + evaluateStandardClusterLights(
201
+ ndc, viewZ, worldPos, vec3<f32>(0.0, 0.0, 1.0),
202
+ viewDir, albedo, 0.0, 1.0, vec3<f32>(0.04), true,
203
+ );
204
+ #endif
222
205
  return lit;
223
206
  }
224
207
 
225
- fn applySceneFog(viewParams : View, color : vec3<f32>, alpha : f32, worldPos : vec3<f32>) -> vec4<f32> {
226
- var origin = viewParams.cameraPos;
227
- var direction = normalize(worldPos - origin);
228
- var rayDistance = length(worldPos - origin);
229
- if (viewParams.temporalProjection.z >= 0.5) {
230
- let nearH = viewParams.inverseViewProj * vec4<f32>(0.0, 0.0, 0.0, 1.0);
231
- let farH = viewParams.inverseViewProj * vec4<f32>(0.0, 0.0, 1.0, 1.0);
232
- let nearPoint = nearH.xyz / nearH.w;
233
- let farPoint = farH.xyz / farH.w;
234
- direction = normalize(farPoint - nearPoint);
235
- origin = worldPos - direction * dot(worldPos - viewParams.cameraPos, direction);
236
- rayDistance = max(dot(worldPos - origin, direction), 0.0);
237
- }
238
- return apply_fog(viewParams.fog, FogRay(origin, direction, rayDistance), vec4<f32>(color, alpha));
239
- }
240
-
241
208
  @fragment
242
209
  fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
243
210
  let texel = sampleMaterialTexture(baseColorTexture, baseColorSampler, in.uv_atlas, material.baseColorTextureCoordinatesMetadata.zw);
244
211
  let albedo4 = texel * material.colorTint;
245
- let lit = spriteLitShadeAccum(albedo4.rgb, in.worldPos);
212
+ let lit = spriteLitShadeAccum(albedo4.rgb, in.worldPos, in.ndc, in.viewZ);
246
213
  // Strict clamp 0..1 before premultiplied alpha multiply keeps the LDR
247
214
  // path bounded even when a scene stacks many lights.
248
215
  let lit_rgba = clamp(vec4<f32>(lit, albedo4.a), vec4<f32>(0.0), vec4<f32>(1.0));
249
- let fogged = applySceneFog(view, lit_rgba.rgb, lit_rgba.a, in.worldPos);
250
- let premult = vec4<f32>(fogged.rgb * fogged.a, fogged.a);
216
+ let premult = vec4<f32>(lit_rgba.rgb * lit_rgba.a, lit_rgba.a);
251
217
  // LDR target is bgra8unorm; encode rgb via the sRGB transfer in-shader.
252
218
  return vec4<f32>(
253
219
  linear_to_srgb(premult.r),
@@ -261,13 +227,12 @@ fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
261
227
  fn fs_main_hdr(in : VsOut) -> @location(0) vec4<f32> {
262
228
  let texel = sampleMaterialTexture(baseColorTexture, baseColorSampler, in.uv_atlas, material.baseColorTextureCoordinatesMetadata.zw);
263
229
  let albedo4 = texel * material.colorTint;
264
- let lit = spriteLitShadeAccum(albedo4.rgb, in.worldPos);
230
+ let lit = spriteLitShadeAccum(albedo4.rgb, in.worldPos, in.ndc, in.viewZ);
265
231
  // HDR variant: do NOT clamp the lit output to [0, 1]; let the tonemap
266
232
  // pass absorb HDR values > 1. Alpha stays clamped because the premult
267
233
  // math requires alpha in [0, 1].
268
234
  let alpha = clamp(albedo4.a, 0.0, 1.0);
269
- let fogged = applySceneFog(view, lit, alpha, in.worldPos);
270
- return vec4<f32>(fogged.rgb * fogged.a, fogged.a);
235
+ return vec4<f32>(lit * alpha, alpha);
271
236
  }
272
237
 
273
238
  struct TemporalVsOut {
package/src/sprite.wgsl CHANGED
@@ -2,9 +2,8 @@
2
2
  #pragma variant_axis STORAGE_BUFFER_AVAILABLE
3
3
  #pragma variant_axis PER_INSTANCE_REGION
4
4
 
5
- #import forgeax_view::common::{View, FogViewParams, FogRay, Mesh, InstanceData, view, meshes, instances, sampleMaterialTexture}
5
+ #import forgeax_view::common::{View, Mesh, InstanceData, view, meshes, instances, sampleMaterialTexture}
6
6
  #import forgeax_scene_temporal::{packSceneTemporalV1}
7
- #import forgeax_view::fog::{apply_fog}
8
7
 
9
8
  // @forgeax/engine-shader - sprite.wgsl (feat-20260520-2d-sprite-layer-mvp /
10
9
  // M-3 / w19). 2D sprite material — third variant of the MaterialAsset
@@ -271,22 +270,6 @@ fn vs_main(in : VsIn, @builtin(instance_index) idx : u32, @builtin(vertex_index)
271
270
  return out;
272
271
  }
273
272
 
274
- fn applySceneFog(viewParams : View, color : vec3<f32>, alpha : f32, worldPos : vec3<f32>) -> vec4<f32> {
275
- var origin = viewParams.cameraPos;
276
- var direction = normalize(worldPos - origin);
277
- var rayDistance = length(worldPos - origin);
278
- if (viewParams.temporalProjection.z >= 0.5) {
279
- let nearH = viewParams.inverseViewProj * vec4<f32>(0.0, 0.0, 0.0, 1.0);
280
- let farH = viewParams.inverseViewProj * vec4<f32>(0.0, 0.0, 1.0, 1.0);
281
- let nearPoint = nearH.xyz / nearH.w;
282
- let farPoint = farH.xyz / farH.w;
283
- direction = normalize(farPoint - nearPoint);
284
- origin = worldPos - direction * dot(worldPos - viewParams.cameraPos, direction);
285
- rayDistance = max(dot(worldPos - origin, direction), 0.0);
286
- }
287
- return apply_fog(viewParams.fog, FogRay(origin, direction, rayDistance), vec4<f32>(color, alpha));
288
- }
289
-
290
273
  // linear_to_srgb: per-channel IEC 61966-2-1 transfer function used by the
291
274
  // LDR fragment entry to encode linear premultiplied RGB into the bgra8unorm
292
275
  // swap-chain storage. The bgra8unorm target is not hardware-sRGB-encoded
@@ -304,8 +287,7 @@ fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
304
287
  let rgba = clamp(texel * material.colorTint, vec4<f32>(0.0), vec4<f32>(1.0));
305
288
  // Premultiplied alpha: rgb pre-multiplied by alpha for srcFactor='one' /
306
289
  // dstFactor='one-minus-src-alpha' blend.
307
- let fogged = applySceneFog(view, rgba.rgb, rgba.a, in.worldPos);
308
- let premult = vec4<f32>(fogged.rgb * fogged.a, fogged.a);
290
+ let premult = vec4<f32>(rgba.rgb * rgba.a, rgba.a);
309
291
  // LDR target is bgra8unorm (blendable per WebGPU spec); hardware does not
310
292
  // sRGB-encode bgra8unorm, so apply the transfer function in the shader.
311
293
  // Only RGB channels are encoded; alpha is linear throughout the blend.
@@ -324,8 +306,7 @@ fn fs_main_hdr(in : VsOut) -> @location(0) vec4<f32> {
324
306
  let texel = sampleMaterialTexture(baseColorTexture, baseColorSampler, in.uv_atlas, material.baseColorTextureCoordinatesMetadata.zw);
325
307
  // R6 mitigation: strict clamp 0..1 before premultiplied alpha multiply.
326
308
  let rgba = clamp(texel * material.colorTint, vec4<f32>(0.0), vec4<f32>(1.0));
327
- let fogged = applySceneFog(view, rgba.rgb, rgba.a, in.worldPos);
328
- return vec4<f32>(fogged.rgb * fogged.a, fogged.a);
309
+ return vec4<f32>(rgba.rgb * rgba.a, rgba.a);
329
310
  }
330
311
 
331
312
  struct TemporalVsOut {
@@ -1,13 +1,17 @@
1
- // hdrp-cluster-forward.wgsl — HDRP cluster-forward punctual light evaluation.
1
+ // standard-cluster.wgsl — Standard clustered punctual light evaluation.
2
2
  // feat-20260608-cluster-lighting M4 / w17.
3
3
  //
4
4
  // The cluster module owns membership and raw payload decoding only. Punctual
5
5
  // BRDF, range, cone, PCF, and fail-open behavior live in lighting-punctual.
6
6
 
7
- #define_import_path forgeax_hdrp::cluster_forward
7
+ #define_import_path forgeax_standard::cluster
8
8
 
9
- #import forgeax_pbr::lighting_punctual::{evalPoint, evalSpot, evalSpotShadowed}
9
+ #import forgeax_pbr::lighting_punctual::{evalPoint, evalPointFlat, evalSpot, evalSpotFlat, evalSpotShadowed}
10
+ #import forgeax_pbr::lighting_attenuation::{projectSpotUv}
10
11
  #import forgeax_view::common::{view}
12
+ #ifdef PROJECTOR_AVAILABLE
13
+ #import forgeax_view::common::{projectorTexture, projectorSampler}
14
+ #endif
11
15
  #ifdef POINT_SHADOW_AVAILABLE
12
16
  #import forgeax_pbr::lighting_punctual::{evalPointShadowed}
13
17
  #endif
@@ -16,6 +20,11 @@
16
20
 
17
21
  const KIND_POINT: u32 = 0u;
18
22
  const KIND_SPOT: u32 = 1u;
23
+ // LightSlot.kind_and_shadow.y uses one high bit as the accepted projector
24
+ // marker. The remaining bits are the shadow-atlas tile (0..3), with zero as
25
+ // the explicit View-UBO lane for projector-only spots.
26
+ const PROJECTOR_FLAG: i32 = 0x40000000;
27
+ const TILE_MASK: i32 = 0x3fffffff;
19
28
 
20
29
  // LightSlot is the byte-frozen 64B std430/std140 transport contract.
21
30
  // [0..2] position, [3] invRangeSquared
@@ -37,13 +46,9 @@ struct ClusterUniform {
37
46
  near_far_log : vec4<f32>,
38
47
  };
39
48
 
40
- #if STORAGE_BUFFER_AVAILABLE == true
41
49
  @group(2) @binding(3) var<storage, read> light_data : array<LightSlot, 256>;
42
50
  @group(2) @binding(4) var<storage, read> cluster_grid : array<u32>;
43
51
  @group(2) @binding(5) var<storage, read> light_index_list : array<u32>;
44
- #else
45
- @group(2) @binding(3) var<uniform> light_data_uniform : array<LightSlot, 128>;
46
- #endif
47
52
  @group(2) @binding(6) var<uniform> cluster_uniform : ClusterUniform;
48
53
 
49
54
  fn get_ssao_intensity() -> f32 {
@@ -84,6 +89,26 @@ fn ndc_position_to_cluster(
84
89
  return vec3(cx, cy, cz);
85
90
  }
86
91
 
92
+ // Surface SpotLight projector sampling is owned by the same clustered light
93
+ // evaluator as the analytic cone/range contribution. The host marks the
94
+ // selected projector in the LightSlot flag and writes its matrix to the
95
+ // corresponding View lane (lane 0 for projector-only); no second
96
+ // material-local sampling loop or resource owner is allowed.
97
+ fn sampleStandardSpotProjector(
98
+ lightViewProj : mat4x4<f32>,
99
+ world_pos : vec3<f32>,
100
+ ) -> vec3<f32> {
101
+ #ifdef PROJECTOR_AVAILABLE
102
+ let uv = projectSpotUv(lightViewProj, world_pos);
103
+ if (!(uv.x >= 0.0 && uv.x <= 1.0 && uv.y >= 0.0 && uv.y <= 1.0)) {
104
+ return vec3<f32>(1.0);
105
+ }
106
+ return textureSampleLevel(projectorTexture, projectorSampler, uv, 0.0).rgb;
107
+ #else
108
+ return vec3<f32>(1.0);
109
+ #endif
110
+ }
111
+
87
112
  // Decode one kind first, then delegate the complete evaluation to the shared
88
113
  // punctual owner. Unknown kinds are explicitly zero contribution.
89
114
  fn evaluate_cluster_light(
@@ -95,9 +120,16 @@ fn evaluate_cluster_light(
95
120
  metallic : f32,
96
121
  alpha_sq : f32,
97
122
  f0 : vec3<f32>,
123
+ flat_2d : bool,
98
124
  ) -> vec3<f32> {
99
125
  let kind = light.kind_and_shadow.x;
100
126
  if (kind == KIND_POINT) {
127
+ if (flat_2d) {
128
+ return evalPointFlat(
129
+ light.position.xyz, light.color.xyz, light.position.w,
130
+ world_pos, base_color,
131
+ );
132
+ }
101
133
  #ifdef POINT_SHADOW_AVAILABLE
102
134
  let layer = bitcast<i32>(light.kind_and_shadow.y);
103
135
  if (layer >= 0) {
@@ -117,8 +149,36 @@ fn evaluate_cluster_light(
117
149
  );
118
150
  }
119
151
  if (kind == KIND_SPOT) {
120
- let tile = bitcast<i32>(light.kind_and_shadow.y);
121
- if (tile >= 0) {
152
+ if (flat_2d) {
153
+ return evalSpotFlat(
154
+ light.position.xyz, light.direction.xyz, light.color.xyz,
155
+ light.color.w, light.direction.w, light.position.w,
156
+ world_pos, base_color,
157
+ );
158
+ }
159
+ let encoded_tile = bitcast<i32>(light.kind_and_shadow.y);
160
+ // The unshadowed sentinel is -1. A signed bitwise test against the
161
+ // projector flag would misclassify that sentinel because -1 has every bit
162
+ // set, so admit the flag only from the non-negative encoded domain.
163
+ if (encoded_tile >= 0 && (encoded_tile & PROJECTOR_FLAG) != 0) {
164
+ let tile = encoded_tile & TILE_MASK;
165
+ if (tile < 4) {
166
+ let projector = sampleStandardSpotProjector(view.spotLightViewProj[tile], world_pos);
167
+ return evalSpotShadowed(
168
+ light.position.xyz, light.direction.xyz, light.color.xyz,
169
+ // LightSlot packs cosInner in color.w and cosOuter in direction.w;
170
+ // keep the evaluator's named cone order intact at the decode boundary.
171
+ light.color.w, light.direction.w, light.position.w,
172
+ world_pos, normal, view_dir, base_color, metallic, alpha_sq, f0,
173
+ view.spotLightViewProj[tile], tile, 0.005, 0.05,
174
+ bitcast<f32>(light.kind_and_shadow.w),
175
+ bitcast<f32>(light.kind_and_shadow.z),
176
+ ) * projector;
177
+ }
178
+ return vec3<f32>(0.0);
179
+ }
180
+ if (encoded_tile >= 0 && encoded_tile < 4) {
181
+ let tile = encoded_tile;
122
182
  return evalSpotShadowed(
123
183
  light.position.xyz, light.direction.xyz, light.color.xyz,
124
184
  // LightSlot packs cosInner in color.w and cosOuter in direction.w;
@@ -126,6 +186,8 @@ fn evaluate_cluster_light(
126
186
  light.color.w, light.direction.w, light.position.w,
127
187
  world_pos, normal, view_dir, base_color, metallic, alpha_sq, f0,
128
188
  view.spotLightViewProj[tile], tile, 0.005, 0.05,
189
+ bitcast<f32>(light.kind_and_shadow.w),
190
+ bitcast<f32>(light.kind_and_shadow.z),
129
191
  );
130
192
  }
131
193
  return evalSpot(
@@ -137,7 +199,7 @@ fn evaluate_cluster_light(
137
199
  return vec3<f32>(0.0);
138
200
  }
139
201
 
140
- fn evaluate_cluster_lights(
202
+ fn evaluateStandardClusterLights(
141
203
  ndc : vec3<f32>,
142
204
  view_z : f32,
143
205
  world_pos : vec3<f32>,
@@ -147,6 +209,7 @@ fn evaluate_cluster_lights(
147
209
  metallic : f32,
148
210
  alpha_sq : f32,
149
211
  f0 : vec3<f32>,
212
+ flat_2d : bool,
150
213
  ) -> vec3<f32> {
151
214
  let gx = cluster_uniform.grid.x;
152
215
  let gy = cluster_uniform.grid.y;
@@ -156,7 +219,6 @@ fn evaluate_cluster_lights(
156
219
  let log_far = cluster_uniform.near_far_log.z;
157
220
  var total_radiance = vec3<f32>(0.0);
158
221
 
159
- #if STORAGE_BUFFER_AVAILABLE == true
160
222
  let cluster_idx = ndc_position_to_cluster(ndc, view_z, gx, gy, gz, near, far, log_far);
161
223
  let cluster_linear = cluster_idx.z * gy * gx + cluster_idx.y * gx + cluster_idx.x;
162
224
  let grid_offset = cluster_linear * 2u;
@@ -166,20 +228,9 @@ fn evaluate_cluster_lights(
166
228
  let light = light_data[light_index_list[list_offset + i]];
167
229
  total_radiance += evaluate_cluster_light(
168
230
  light, world_pos, normal, view_dir, base_color, metallic, alpha_sq, f0,
231
+ flat_2d,
169
232
  );
170
233
  }
171
- #else
172
- let light_count = min(cluster_uniform.grid.w, 128u);
173
- for (var i = 0u; i < 128u; i = i + 1u) {
174
- if (i >= light_count) {
175
- break;
176
- }
177
- total_radiance += evaluate_cluster_light(
178
- light_data_uniform[i], world_pos, normal, view_dir,
179
- base_color, metallic, alpha_sq, f0,
180
- );
181
- }
182
- #endif
183
234
  return total_radiance;
184
235
  }
185
236
 
package/src/tonemap.wgsl CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
  #import forgeax_view::common::FullscreenOutput
11
11
  #import forgeax_view::common::fullscreen_triangle
12
- #import forgeax_view::common::linearToSrgbOetf
12
+ #import forgeax_view::common::{linearToSrgbOetf, ditherUnorm8}
13
13
 
14
14
  const TONEMAP_LUMINANCE_EPSILON : f32 = 1e-5;
15
15
 
@@ -17,7 +17,7 @@ struct TonemapParams {
17
17
  exposure : f32,
18
18
  whitePoint : f32,
19
19
  mode : u32,
20
- pad1 : f32,
20
+ ditherEnabled : f32,
21
21
  };
22
22
 
23
23
  @group(1) @binding(0) var hdr : texture_2d<f32>;
@@ -171,5 +171,10 @@ fn fs_main(in : FullscreenOutput) -> @location(0) vec4<f32> {
171
171
  }
172
172
  // Every public mode reaches exactly one shared sRGB OETF.
173
173
  let encoded = linearToSrgbOetf(mapped);
174
- return vec4<f32>(encoded, source.a);
174
+ let output = select(
175
+ encoded,
176
+ ditherUnorm8(encoded, in.position.xy),
177
+ params.ditherEnabled > 0.5,
178
+ );
179
+ return vec4<f32>(output, source.a);
175
180
  }
package/src/unlit.wgsl CHANGED
@@ -1,7 +1,6 @@
1
1
  #define_import_path forgeax_material::unlit
2
- #import forgeax_view::common::{View, FogViewParams, FogRay, Mesh, InstanceData, view, meshes, instances, sampleMaterialTextureLinear}
2
+ #import forgeax_view::common::{View, Mesh, InstanceData, view, meshes, instances, sampleMaterialTextureLinear}
3
3
  #import forgeax_scene_temporal::{packSceneTemporalV1}
4
- #import forgeax_view::fog::{apply_fog}
5
4
 
6
5
  #pragma variant_axis STORAGE_BUFFER_AVAILABLE
7
6
  #pragma variant_axis VERTEX_COLOR_AVAILABLE
@@ -101,22 +100,6 @@ fn materialVertexColor(in : VsOut) -> vec4<f32> {
101
100
  #endif
102
101
  }
103
102
 
104
- fn applySceneFog(viewParams : View, color : vec3<f32>, alpha : f32, worldPos : vec3<f32>) -> vec4<f32> {
105
- var origin = viewParams.cameraPos;
106
- var direction = normalize(worldPos - origin);
107
- var rayDistance = length(worldPos - origin);
108
- if (viewParams.temporalProjection.z >= 0.5) {
109
- let nearH = viewParams.inverseViewProj * vec4<f32>(0.0, 0.0, 0.0, 1.0);
110
- let farH = viewParams.inverseViewProj * vec4<f32>(0.0, 0.0, 1.0, 1.0);
111
- let nearPoint = nearH.xyz / nearH.w;
112
- let farPoint = farH.xyz / farH.w;
113
- direction = normalize(farPoint - nearPoint);
114
- origin = worldPos - direction * dot(worldPos - viewParams.cameraPos, direction);
115
- rayDistance = max(dot(worldPos - origin, direction), 0.0);
116
- }
117
- return apply_fog(viewParams.fog, FogRay(origin, direction, rayDistance), vec4<f32>(color, alpha));
118
- }
119
-
120
103
  @fragment
121
104
  fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
122
105
  let texSample = sampleMaterialTextureLinear(baseColorTexture, baseColorSampler, in.uv, material.baseColorTextureCoordinatesMetadata.zw);
@@ -125,12 +108,7 @@ fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
125
108
  if (material.alphaCutoff > 0.0 && alpha < material.alphaCutoff) {
126
109
  discard;
127
110
  }
128
- return applySceneFog(
129
- view,
130
- material.baseColor.rgb * texSample.rgb * vertexColor.rgb,
131
- alpha,
132
- in.worldPos,
133
- );
111
+ return vec4<f32>(material.baseColor.rgb * texSample.rgb * vertexColor.rgb, alpha);
134
112
  }
135
113
 
136
114
  struct TemporalVsOut {
@@ -0,0 +1,113 @@
1
+ #define_import_path forgeax_view::volume_composite
2
+ // This is a view-UBO projection, not a second matrix source. The fields and
3
+ // padding preserve common::View camera/inverse-projection byte offsets.
4
+ struct VolumeDepthView {
5
+ _prefix : array<vec4<f32>, 6>,
6
+ cameraPos : vec4<f32>,
7
+ _lightViewProjA : mat4x4<f32>,
8
+ inverseViewProj : mat4x4<f32>,
9
+ };
10
+
11
+ struct VolumeOutput {
12
+ @builtin(position) position : vec4<f32>,
13
+ @location(0) uv : vec2<f32>,
14
+ };
15
+
16
+ @group(0) @binding(0) var resolved_volume : texture_2d<f32>;
17
+ @group(0) @binding(1) var volume_sampler : sampler;
18
+ @group(0) @binding(2) var scene_depth : texture_depth_2d;
19
+ @group(0) @binding(3) var<uniform> volume_view : VolumeDepthView;
20
+
21
+ @vertex
22
+ fn volume_vs(@builtin(vertex_index) index : u32) -> VolumeOutput {
23
+ var positions = array<vec2<f32>, 3>(
24
+ vec2<f32>(-1.0, -1.0),
25
+ vec2<f32>(3.0, -1.0),
26
+ vec2<f32>(-1.0, 3.0),
27
+ );
28
+ var output : VolumeOutput;
29
+ output.position = vec4<f32>(positions[index], 0.0, 1.0);
30
+ output.uv = vec2<f32>(
31
+ positions[index].x * 0.5 + 0.5,
32
+ 0.5 - positions[index].y * 0.5,
33
+ );
34
+ return output;
35
+ }
36
+
37
+ fn composite_resolved_volume(sample : vec4<f32>) -> vec4<f32> {
38
+ let transmittance = clamp(sample.a, 0.0, 1.0);
39
+ return vec4<f32>(sample.rgb, 1.0 - transmittance);
40
+ }
41
+
42
+ fn linear_scene_depth(uv : vec2<f32>) -> f32 {
43
+ let dimensions = max(vec2<f32>(textureDimensions(scene_depth)), vec2<f32>(1.0));
44
+ let pixel = vec2<i32>(clamp(uv * dimensions, vec2<f32>(0.0), dimensions - vec2<f32>(1.0)));
45
+ let device_depth = textureLoad(scene_depth, pixel, 0);
46
+ // WebGPU's framebuffer Y is opposite the view UBO's NDC Y.
47
+ let clip = vec4<f32>(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, device_depth, 1.0);
48
+ let world = volume_view.inverseViewProj * clip;
49
+ let world_position = world.xyz / max(abs(world.w), 1e-5);
50
+ return distance(world_position, volume_view.cameraPos.xyz);
51
+ }
52
+
53
+ fn radiance_luma(sample : vec4<f32>) -> f32 {
54
+ return max(dot(sample.rgb, vec3<f32>(0.2126, 0.7152, 0.0722)), 0.0);
55
+ }
56
+
57
+ fn radiance_luma_weight(center : vec4<f32>, neighbor : vec4<f32>) -> f32 {
58
+ let center_luma = radiance_luma(center);
59
+ let neighbor_luma = radiance_luma(neighbor);
60
+ let relative_luma = abs(neighbor_luma - center_luma) /
61
+ max(max(neighbor_luma, center_luma), 1e-4);
62
+ return exp(-relative_luma * 8.0);
63
+ }
64
+
65
+ fn edge_aware_resolved_volume(uv : vec2<f32>) -> vec4<f32> {
66
+ let dimensions = max(vec2<f32>(textureDimensions(resolved_volume)), vec2<f32>(1.0));
67
+ let texel = 1.0 / dimensions;
68
+ let center = textureSampleLevel(resolved_volume, volume_sampler, uv, 0.0);
69
+ let center_depth = linear_scene_depth(uv);
70
+ var accumulated = center * 4.0;
71
+ var total_weight = 4.0;
72
+ let offsets = array<vec2<f32>, 8>(
73
+ vec2<f32>(texel.x, 0.0),
74
+ vec2<f32>(-texel.x, 0.0),
75
+ vec2<f32>(0.0, texel.y),
76
+ vec2<f32>(0.0, -texel.y),
77
+ vec2<f32>(texel.x, texel.y),
78
+ vec2<f32>(-texel.x, texel.y),
79
+ vec2<f32>(texel.x, -texel.y),
80
+ vec2<f32>(-texel.x, -texel.y),
81
+ );
82
+ let spatial_weights = array<f32, 8>(2.0, 2.0, 2.0, 2.0, 1.0, 1.0, 1.0, 1.0);
83
+ for (var index = 0u; index < 8u; index += 1u) {
84
+ let neighbor_uv = clamp(uv + offsets[index], vec2<f32>(0.0), vec2<f32>(1.0));
85
+ let neighbor = textureSampleLevel(
86
+ resolved_volume,
87
+ volume_sampler,
88
+ neighbor_uv,
89
+ 0.0,
90
+ );
91
+ let neighbor_depth = linear_scene_depth(neighbor_uv);
92
+ // Relative view-depth rejection keeps the silhouette transition local;
93
+ // a fixed world-unit threshold creates halos at far occluders.
94
+ let depth_weight = select(0.0, exp(-abs(neighbor_depth - center_depth) * 0.08),
95
+ abs(neighbor_depth - center_depth) <= max(center_depth * 0.08, 0.25));
96
+ // Transmittance is the available edge signal: retain hard depth/occluder
97
+ // boundaries while reconstructing smooth low-frequency scattering.
98
+ let edge_weight = spatial_weights[index] * depth_weight * exp(-abs(neighbor.a - center.a) * 6.0) *
99
+ radiance_luma_weight(center, neighbor);
100
+ accumulated += neighbor * edge_weight;
101
+ total_weight += edge_weight;
102
+ }
103
+ return accumulated / max(total_weight, 1e-5);
104
+ }
105
+
106
+ @fragment
107
+ fn volume_fs(input : VolumeOutput) -> @location(0) vec4<f32> {
108
+ // Injection zeroes samples behind the observed scene depth. Sampling the
109
+ // cumulative far slice therefore applies the full ray integral while the
110
+ // depth test remains owned by the injection path.
111
+ let sample = edge_aware_resolved_volume(input.uv);
112
+ return composite_resolved_volume(sample);
113
+ }