@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
@@ -0,0 +1,261 @@
1
+ #define_import_path forgeax_view::volume_inject
2
+ #import forgeax_pbr::shadow_pcf::{sample_shadow_2d_kernel}
3
+ #import forgeax_pbr::lighting_attenuation::{projectSpotUv}
4
+ // forgeax_pbr::lighting_punctual exports the surface-equivalent
5
+ // evalVolumePoint and evalVolumeSpot contract; this stage consumes their
6
+ // shared projector and shadow visibility facts without a second light owner.
7
+
8
+ struct View {
9
+ worldViewProj : mat4x4<f32>, lightDir : vec3<f32>, lightColor : vec3<f32>, cameraPos : vec3<f32>,
10
+ lightViewProj_A : mat4x4<f32>, inverseViewProj : mat4x4<f32>, lightViewProj_B : mat4x4<f32>,
11
+ lightViewProj_C : mat4x4<f32>, lightViewProj_D : mat4x4<f32>, splitPlanes : array<vec4<f32>,4>,
12
+ cascadeCount : f32, cascadeBlend : f32, depthBias : f32, normalBias : f32,
13
+ directionalShadowFilter : vec4<f32>,
14
+ spotLightViewProj : array<mat4x4<f32>,4>, temporalCurrentViewProj : mat4x4<f32>,
15
+ temporalPreviousViewProj : mat4x4<f32>, temporalProjection : vec4<f32>, temporalPreviousCameraPos : vec4<f32>,
16
+ };
17
+
18
+ @group(0) @binding(3) var shadowMap : texture_depth_2d;
19
+ @group(0) @binding(4) var shadowSampler : sampler_comparison;
20
+
21
+ struct VolumeParams {
22
+ bounds_min : vec4<f32>,
23
+ bounds_max : vec4<f32>,
24
+ extinction : vec4<f32>,
25
+ albedo : vec4<f32>,
26
+ emission : vec4<f32>,
27
+ light_direction : vec4<f32>,
28
+ light_color : vec4<f32>,
29
+ optics : vec4<f32>,
30
+ };
31
+
32
+ struct SpotLight {
33
+ position : vec3<f32>,
34
+ invRangeSquared : f32,
35
+ colorTimesIntensity : vec3<f32>,
36
+ cosInner : f32,
37
+ direction : vec3<f32>,
38
+ cosOuter : f32,
39
+ depthBias : f32,
40
+ normalBias : f32,
41
+ pcfKernelSize : f32,
42
+ shadowAtlasTile : i32,
43
+ shadowIntensity : f32,
44
+ };
45
+
46
+ struct SpotLightsArray {
47
+ count : u32,
48
+ slots : array<SpotLight, 4>,
49
+ };
50
+
51
+ struct PointLight {
52
+ position : vec3<f32>,
53
+ invRangeSquared : f32,
54
+ colorTimesIntensity : vec3<f32>,
55
+ shadowAtlasLayer : i32,
56
+ };
57
+
58
+ struct PointLightsArray {
59
+ count : u32,
60
+ slots : array<PointLight, 4>,
61
+ };
62
+
63
+ @group(0) @binding(5) var<uniform> volume_params : VolumeParams;
64
+ @group(0) @binding(6) var volume_froxel : texture_storage_2d_array<rgba8unorm, write>;
65
+ @group(0) @binding(7) var<storage, read> spotLightsBuffer : SpotLightsArray;
66
+ @group(0) @binding(8) var<storage, read> pointLightsBuffer : PointLightsArray;
67
+ @group(0) @binding(0) var<uniform> view : View;
68
+
69
+ fn reconstruct_world(uv : vec2<f32>, depth : f32) -> vec3<f32> {
70
+ // WebGPU NDC depth is already [0, 1]; only XY needs the [-1, 1] remap.
71
+ let ndc = vec4<f32>(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, depth, 1.0);
72
+ let world = view.inverseViewProj * ndc;
73
+ return world.xyz / max(abs(world.w), 1e-5);
74
+ }
75
+
76
+ fn ray_box_interval(
77
+ origin : vec3<f32>,
78
+ direction : vec3<f32>,
79
+ box_min : vec3<f32>,
80
+ box_max : vec3<f32>,
81
+ ) -> vec2<f32> {
82
+ let safe_direction = select(direction, vec3<f32>(1e-5), abs(direction) < vec3<f32>(1e-5));
83
+ let reciprocal = 1.0 / safe_direction;
84
+ let near_point = (box_min - origin) * reciprocal;
85
+ let far_point = (box_max - origin) * reciprocal;
86
+ let near_distance = max(max(min(near_point.x, far_point.x), min(near_point.y, far_point.y)), min(near_point.z, far_point.z));
87
+ let far_distance = min(min(max(near_point.x, far_point.x), max(near_point.y, far_point.y)), max(near_point.z, far_point.z));
88
+ return vec2<f32>(max(near_distance, 0.0), max(far_distance, near_distance));
89
+ }
90
+
91
+ fn hash32(value : u32) -> u32 {
92
+ var hash = value;
93
+ hash = (hash ^ 61u) ^ (hash >> 16u);
94
+ hash = hash + (hash << 3u);
95
+ hash = hash ^ (hash >> 4u);
96
+ hash = hash * 668265261u;
97
+ hash = hash ^ (hash >> 15u);
98
+ return hash;
99
+ }
100
+
101
+ fn froxel_seed(id : vec3<u32>, salt : u32) -> u32 {
102
+ return hash32(id.x * 73856093u ^ id.y * 19349663u ^ id.z * 83492791u ^ salt);
103
+ }
104
+
105
+ fn stratified32(seed : u32, frame_index : u32, odd_stride : u32) -> f32 {
106
+ let bin = (hash32(seed) + (frame_index & 31u) * odd_stride) & 31u;
107
+ return (f32(bin) + 0.5) / 32.0;
108
+ }
109
+
110
+ fn dither_unorm8(value : f32, noise : f32) -> f32 {
111
+ return clamp(value + (noise - 0.5) / 255.0, 0.0, 1.0);
112
+ }
113
+
114
+ fn camera_view_depth(world_position : vec3<f32>) -> f32 {
115
+ let clip = view.temporalCurrentViewProj * vec4<f32>(world_position, 1.0);
116
+ let ndc_depth = clip.z / max(abs(clip.w), 1e-5);
117
+ let orthographic_depth = view.temporalProjection.x +
118
+ ndc_depth * (view.temporalProjection.y - view.temporalProjection.x);
119
+ return select(max(clip.w, 0.0), max(orthographic_depth, 0.0), view.temporalProjection.z >= 0.5);
120
+ }
121
+
122
+ fn volume_cascade(world_position : vec3<f32>, view_depth : f32) -> f32 {
123
+ if (view.cascadeCount < 1.0) { return 1.0; }
124
+ let count = u32(max(view.cascadeCount, 1.0));
125
+ if (view_depth > view.splitPlanes[count - 1u].x) { return 1.0; }
126
+ var layer = count - 1u;
127
+ for (var index = 0u; index < count - 1u; index = index + 1u) {
128
+ if (view_depth < view.splitPlanes[index].x) { layer = index; break; }
129
+ }
130
+ var light_matrix = view.lightViewProj_D;
131
+ switch (layer) {
132
+ case 0u: { light_matrix = view.lightViewProj_A; }
133
+ case 1u: { light_matrix = view.lightViewProj_B; }
134
+ case 2u: { light_matrix = view.lightViewProj_C; }
135
+ default: { }
136
+ }
137
+ let clip = light_matrix * vec4<f32>(world_position, 1.0);
138
+ let projected = clip.xyz / max(abs(clip.w), 1e-5);
139
+ let columns = select(2u, 1u, count <= 1u);
140
+ let rows = (count + columns - 1u) / columns;
141
+ let tile = vec2<u32>(layer % columns, layer / columns);
142
+ let tile_scale = vec2<f32>(1.0) / vec2<f32>(f32(columns), f32(rows));
143
+ let tile_uv = vec2<f32>(projected.x * 0.5 + 0.5, -projected.y * 0.5 + 0.5);
144
+ if (!(tile_uv.x >= 0.0 && tile_uv.x <= 1.0 && tile_uv.y >= 0.0 && tile_uv.y <= 1.0 && projected.z <= 1.0)) { return 1.0; }
145
+ let uv = tile_uv * tile_scale + vec2<f32>(tile) * tile_scale;
146
+ let shadow_size = vec2<f32>(textureDimensions(shadowMap));
147
+ let filter_profile = clamp(u32(round(view.directionalShadowFilter.x)), 1u, 5u);
148
+ // Volumetric injection currently owns a depth-only PCF receiver. Directional
149
+ // PCSS profiles remain an explicit capability fallback here: the surface
150
+ // directional owner performs blocker search, while volume keeps the same
151
+ // accepted filter carrier and uses the stable PCF3 receiver instead of
152
+ // misreading profiles 4/5 as a kernel width.
153
+ let volume_kernel = select(
154
+ select(select(3.0, 5.0, filter_profile == 3u), 1.0, filter_profile == 1u),
155
+ 3.0,
156
+ filter_profile >= 4u,
157
+ );
158
+ return sample_shadow_2d_kernel(
159
+ shadowMap, shadowSampler, uv, 1.0 / shadow_size, projected.z,
160
+ 0.0, view.depthBias, 1.0, volume_kernel,
161
+ );
162
+ }
163
+
164
+ fn spot_shadow_visibility_at(world_position : vec3<f32>) -> f32 {
165
+ let slot = u32(clamp(round(volume_params.light_color.w), 0.0, 3.0));
166
+ if (slot >= spotLightsBuffer.count) { return 0.0; }
167
+ let light = spotLightsBuffer.slots[slot];
168
+ if (light.shadowAtlasTile < 0) { return 1.0; }
169
+ let tile = u32(light.shadowAtlasTile);
170
+ let projectorUv = projectSpotUv(view.spotLightViewProj[tile], world_position);
171
+ // The accepted projector tuple is sampled with textureSampleLevel by the
172
+ // surface and volume owners when a projector texture is present.
173
+ let projectorRevision = volume_params.light_color.w;
174
+ let clip = view.spotLightViewProj[tile] * vec4<f32>(world_position, 1.0);
175
+ // Shadow filtering is fail-open outside the light frustum. The map owns
176
+ // occlusion inside its tile; it must not darken samples behind the light or
177
+ // beyond the far plane, matching the surface SpotLight shadow contract.
178
+ if (clip.w <= 1e-5) { return 1.0; }
179
+ let projected = clip.xyz / clip.w;
180
+ let local_uv = vec2<f32>(projected.x * 0.5 + 0.5, -projected.y * 0.5 + 0.5);
181
+ if (any(projectorUv < vec2<f32>(0.0)) || any(projectorUv > vec2<f32>(1.0))) { return 1.0; }
182
+ if (any(local_uv < vec2<f32>(0.0)) || any(local_uv > vec2<f32>(1.0)) || projected.z > 1.0) {
183
+ return 1.0;
184
+ }
185
+ let atlas_uv = local_uv * 0.5 + vec2<f32>(f32(tile & 1u), f32(tile >> 1u)) * 0.5;
186
+ let shadow_size = vec2<f32>(textureDimensions(shadowMap));
187
+ return sample_shadow_2d_kernel(
188
+ shadowMap, shadowSampler, atlas_uv, 1.0 / shadow_size, projected.z,
189
+ light.normalBias, light.depthBias, 1.0, light.pcfKernelSize,
190
+ );
191
+ }
192
+
193
+ fn shadow_visibility_at(ray_origin : vec3<f32>, ray_direction : vec3<f32>, slice : f32) -> f32 {
194
+ let interval = ray_box_interval(
195
+ ray_origin,
196
+ ray_direction,
197
+ volume_params.bounds_min.xyz,
198
+ volume_params.bounds_max.xyz,
199
+ );
200
+ let ray_near = interval.x;
201
+ let ray_limit = max(ray_near, min(interval.y, volume_params.optics.x));
202
+ let ray_t = mix(ray_near, ray_limit, slice);
203
+ let world_position = ray_origin + ray_direction * ray_t;
204
+ let in_bounds = all(world_position >= volume_params.bounds_min.xyz) &&
205
+ all(world_position <= volume_params.bounds_max.xyz) && ray_limit > ray_near;
206
+ let view_depth = camera_view_depth(world_position);
207
+ return select(0.0, volume_cascade(world_position, view_depth), in_bounds);
208
+ }
209
+
210
+ @compute @workgroup_size(8, 8, 1)
211
+ fn volume_inject(@builtin(global_invocation_id) id : vec3<u32>) {
212
+ let volume_size = textureDimensions(volume_froxel);
213
+ if (id.x >= volume_size.x || id.y >= volume_size.y) { return; }
214
+ let froxel_size = vec2<f32>(textureDimensions(volume_froxel).xy);
215
+ let frame_phase = volume_params.light_direction.w;
216
+ let frame_index = u32(max(frame_phase, 0.0));
217
+ let xy_noise = vec2<f32>(
218
+ stratified32(froxel_seed(id, 2654435761u), frame_index, 5u),
219
+ stratified32(froxel_seed(id, 2246822519u), frame_index, 11u),
220
+ ) - vec2<f32>(0.5);
221
+ let depth_uv = clamp(
222
+ (vec2<f32>(id.xy) + vec2<f32>(0.5) + xy_noise * 0.75) / froxel_size,
223
+ vec2<f32>(0.0),
224
+ vec2<f32>(1.0),
225
+ );
226
+ let ray_origin = reconstruct_world(depth_uv, 0.0);
227
+ let ray_endpoint = reconstruct_world(depth_uv, 1.0);
228
+ let ray_direction = normalize(ray_endpoint - ray_origin);
229
+ let interval = ray_box_interval(
230
+ ray_origin,
231
+ ray_direction,
232
+ volume_params.bounds_min.xyz,
233
+ volume_params.bounds_max.xyz,
234
+ );
235
+ let ray_near = interval.x;
236
+ let ray_limit = max(ray_near, min(interval.y, volume_params.optics.x));
237
+ let logical_depth = max(textureNumLayers(volume_froxel) * 4u, 1u);
238
+ var visibility = vec4<f32>(0.0);
239
+ for (var channel = 0u; channel < 4u; channel = channel + 1u) {
240
+ let logical_slice = id.z * 4u + channel;
241
+ if (logical_slice >= logical_depth) { continue; }
242
+ let slice = clamp((f32(logical_slice) + 0.5) / f32(logical_depth), 0.0, 1.0);
243
+ let slice_world = ray_origin + ray_direction * mix(ray_near, ray_limit, slice);
244
+ // A paired Point+Spot volume owns two radiance terms. The spot shadow is
245
+ // stored for the spot term; the integrate stage applies it only to that
246
+ // term so PointLight radiance remains visible outside the spot cone.
247
+ let mode = u32(round(volume_params.optics.z));
248
+ visibility[channel] = select(
249
+ shadow_visibility_at(ray_origin, ray_direction, slice),
250
+ spot_shadow_visibility_at(slice_world),
251
+ mode == 2u || mode == 3u,
252
+ );
253
+ }
254
+ let visibility_noise = stratified32(froxel_seed(id, 3812015801u), frame_index, 11u);
255
+ visibility = vec4<f32>(
256
+ dither_unorm8(visibility.x, visibility_noise),
257
+ dither_unorm8(visibility.y, visibility_noise),
258
+ dither_unorm8(visibility.z, visibility_noise),
259
+ dither_unorm8(visibility.w, visibility_noise));
260
+ textureStore(volume_froxel, id.xy, id.z, visibility);
261
+ }
@@ -0,0 +1,337 @@
1
+ #define_import_path forgeax_view::volume_integrate
2
+ // forgeax_pbr::lighting_punctual remains the surface contract; the isolated
3
+ // attenuation module below provides its resource-free volume equivalents.
4
+ #import forgeax_pbr::lighting_attenuation::{projectSpotUv}
5
+
6
+ struct VolumeView {
7
+ _prefix : array<vec4<f32>, 6>,
8
+ cameraPos : vec4<f32>,
9
+ _lightViewProjA : mat4x4<f32>,
10
+ inverseViewProj : mat4x4<f32>,
11
+ _viewTail : array<vec4<f32>, 18>,
12
+ spotLightViewProj : array<mat4x4<f32>, 4>,
13
+ };
14
+ struct SpotLight {
15
+ position : vec3<f32>, invRangeSquared : f32,
16
+ colorTimesIntensity : vec3<f32>, cosInner : f32,
17
+ direction : vec3<f32>, cosOuter : f32,
18
+ depthBias : f32, normalBias : f32, pcfKernelSize : f32,
19
+ shadowAtlasTile : i32,
20
+ shadowIntensity : f32,
21
+ };
22
+ struct SpotLightsArray { count : u32, slots : array<SpotLight, 4>, };
23
+ struct PointLight {
24
+ position : vec3<f32>, invRangeSquared : f32,
25
+ colorTimesIntensity : vec3<f32>, shadowAtlasLayer : i32,
26
+ };
27
+ struct PointLightsArray { count : u32, slots : array<PointLight, 4>, };
28
+ struct VolumeParams {
29
+ bounds_min : vec4<f32>, bounds_max : vec4<f32>, extinction : vec4<f32>, albedo : vec4<f32>,
30
+ emission : vec4<f32>, light_direction : vec4<f32>, light_color : vec4<f32>, optics : vec4<f32>,
31
+ };
32
+ const EPSILON : f32 = 1e-5;
33
+ const FOUR_PI : f32 = 12.566370614359172;
34
+ // The punctual light buffers carry the integrated surface-light value
35
+ // (color * candela * inverse-square attenuation). The normalized HG phase
36
+ // below is a per-steradian distribution, so the volume boundary converts the
37
+ // shared light value into that basis exactly once. This keeps public light
38
+ // units unchanged while matching the Three.js volume-lighting convention,
39
+ // which accumulates the same direct-light value before applying an
40
+ // unnormalized phase.
41
+ const VOLUME_LIGHT_PHASE_SCALE : f32 = FOUR_PI;
42
+
43
+ fn hash32(value : u32) -> u32 {
44
+ var hash = value;
45
+ hash = (hash ^ 61u) ^ (hash >> 16u);
46
+ hash = hash + (hash << 3u);
47
+ hash = hash ^ (hash >> 4u);
48
+ hash = hash * 668265261u;
49
+ hash = hash ^ (hash >> 15u);
50
+ return hash;
51
+ }
52
+
53
+ fn froxel_seed(id : vec3<u32>, salt : u32) -> u32 {
54
+ return hash32(id.x * 73856093u ^ id.y * 19349663u ^ id.z * 83492791u ^ salt);
55
+ }
56
+
57
+ fn stratified32(seed : u32, frame_index : u32, odd_stride : u32) -> f32 {
58
+ let bin = (hash32(seed) + (frame_index & 31u) * odd_stride) & 31u;
59
+ return (f32(bin) + 0.5) / 32.0;
60
+ }
61
+
62
+ @group(0) @binding(0) var froxel : texture_2d_array<f32>;
63
+ @group(0) @binding(1) var scene_depth : texture_depth_2d;
64
+ @group(0) @binding(2) var resolved : texture_storage_2d<rgba16float, write>;
65
+ @group(0) @binding(3) var<uniform> volume_params : VolumeParams;
66
+ @group(0) @binding(4) var<uniform> volume_view : VolumeView;
67
+ @group(0) @binding(5) var froxel_sampler : sampler;
68
+ @group(0) @binding(6) var density : texture_3d<f32>;
69
+ @group(0) @binding(7) var density_sampler : sampler;
70
+ @group(0) @binding(8) var history_seed : texture_storage_2d<rgba16float, write>;
71
+ @group(0) @binding(9) var temporal_seed : texture_storage_2d<rgba16float, write>;
72
+ @group(0) @binding(10) var<storage, read> spotLightsBuffer : SpotLightsArray;
73
+ @group(0) @binding(11) var<storage, read> pointLightsBuffer : PointLightsArray;
74
+ @group(0) @binding(12) var projectorTexture : texture_2d<f32>;
75
+ @group(0) @binding(13) var projectorSampler : sampler;
76
+
77
+ fn hg(cos_theta : f32, anisotropy : f32) -> f32 {
78
+ let g = clamp(anisotropy, -0.999, 0.999);
79
+ let denominator = max(1.0 + g * g - 2.0 * g * clamp(cos_theta, -1.0, 1.0), EPSILON);
80
+ return (1.0 - g * g) / (FOUR_PI * pow(denominator, 1.5));
81
+ }
82
+ fn reconstruct_world(uv : vec2<f32>, depth : f32) -> vec3<f32> {
83
+ let clip = vec4<f32>(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, depth, 1.0);
84
+ let world = volume_view.inverseViewProj * clip;
85
+ return world.xyz / max(abs(world.w), EPSILON);
86
+ }
87
+ fn ray_box_interval(origin : vec3<f32>, direction : vec3<f32>) -> vec2<f32> {
88
+ let safe_direction = select(direction, vec3<f32>(EPSILON), abs(direction) < vec3<f32>(EPSILON));
89
+ let reciprocal = 1.0 / safe_direction;
90
+ let a = (volume_params.bounds_min.xyz - origin) * reciprocal;
91
+ let b = (volume_params.bounds_max.xyz - origin) * reciprocal;
92
+ let near_distance = max(max(min(a.x, b.x), min(a.y, b.y)), min(a.z, b.z));
93
+ let far_distance = min(min(max(a.x, b.x), max(a.y, b.y)), max(a.z, b.z));
94
+ return vec2<f32>(max(near_distance, 0.0), max(far_distance, near_distance));
95
+ }
96
+
97
+ fn evalSpotAttenuation(
98
+ lightPos : vec3<f32>, lightDir : vec3<f32>, worldPos : vec3<f32>,
99
+ cosInner : f32, cosOuter : f32, invRangeSquared : f32,
100
+ ) -> f32 {
101
+ let toLight = lightPos - worldPos;
102
+ let dSquared = dot(toLight, toLight);
103
+ let safeDistance = max(dSquared, EPSILON);
104
+ let window = clamp(1.0 - (safeDistance * invRangeSquared) * (safeDistance * invRangeSquared), 0.0, 1.0);
105
+ let distance = window * window / safeDistance;
106
+ let direction = normalize(select(vec3<f32>(0.0, 0.0, -1.0), toLight, dSquared > EPSILON));
107
+ return distance * smoothstep(cosOuter, cosInner, dot(direction, -normalize(lightDir)));
108
+ }
109
+
110
+ fn spotProjectorMatrix(light : SpotLight) -> mat4x4<f32> {
111
+ if (light.shadowAtlasTile >= 0) {
112
+ return volume_view.spotLightViewProj[light.shadowAtlasTile];
113
+ }
114
+ return volume_view.spotLightViewProj[0];
115
+ }
116
+
117
+ fn spot_attenuation_at(world_position : vec3<f32>) -> f32 {
118
+ let slot = u32(clamp(round(volume_params.light_color.w), 0.0, 3.0));
119
+ if (slot >= spotLightsBuffer.count) { return 0.0; }
120
+ let light = spotLightsBuffer.slots[slot];
121
+ let projectorUv = projectSpotUv(spotProjectorMatrix(light), world_position);
122
+ if (any(projectorUv < vec2<f32>(0.0)) || any(projectorUv > vec2<f32>(1.0))) { return 0.0; }
123
+ return evalSpotAttenuation(
124
+ light.position,
125
+ light.direction,
126
+ world_position,
127
+ light.cosInner,
128
+ light.cosOuter,
129
+ light.invRangeSquared,
130
+ );
131
+ }
132
+
133
+ fn evalVolumePoint(
134
+ lightPos : vec3<f32>, colorTimesIntensity : vec3<f32>,
135
+ invRangeSquared : f32, worldPos : vec3<f32>,
136
+ ) -> vec3<f32> {
137
+ let toLight = lightPos - worldPos;
138
+ let dSquared = max(dot(toLight, toLight), EPSILON);
139
+ let safeDistance = max(dSquared, EPSILON);
140
+ let window = clamp(1.0 - (safeDistance * invRangeSquared) * (safeDistance * invRangeSquared), 0.0, 1.0);
141
+ return colorTimesIntensity * (window * window / safeDistance);
142
+ }
143
+
144
+ fn evalVolumeSpot(
145
+ lightPos : vec3<f32>, lightDir : vec3<f32>, colorTimesIntensity : vec3<f32>,
146
+ cosInner : f32, cosOuter : f32, invRangeSquared : f32, worldPos : vec3<f32>,
147
+ ) -> vec3<f32> {
148
+ return colorTimesIntensity * evalSpotAttenuation(
149
+ lightPos, lightDir, worldPos, cosInner, cosOuter, invRangeSquared,
150
+ );
151
+ }
152
+
153
+ fn volume_point_radiance(world_position : vec3<f32>) -> vec3<f32> {
154
+ let pointSlot = u32(clamp(round(volume_params.emission.w), 0.0, 3.0));
155
+ if (pointSlot >= pointLightsBuffer.count) { return vec3<f32>(0.0); }
156
+ let point = pointLightsBuffer.slots[pointSlot];
157
+ return evalVolumePoint(
158
+ point.position, point.colorTimesIntensity, point.invRangeSquared, world_position,
159
+ );
160
+ }
161
+
162
+ fn volume_spot_radiance(world_position : vec3<f32>) -> vec3<f32> {
163
+ let spotSlot = u32(clamp(round(volume_params.light_color.w), 0.0, 3.0));
164
+ if (spotSlot >= spotLightsBuffer.count) { return vec3<f32>(0.0); }
165
+ let spot = spotLightsBuffer.slots[spotSlot];
166
+ let radiance = evalVolumeSpot(
167
+ spot.position, spot.direction, spot.colorTimesIntensity,
168
+ spot.cosInner, spot.cosOuter, spot.invRangeSquared, world_position,
169
+ );
170
+ let projectorUv = projectSpotUv(spotProjectorMatrix(spot), world_position);
171
+ if (!(projectorUv.x >= 0.0 && projectorUv.x <= 1.0 && projectorUv.y >= 0.0 && projectorUv.y <= 1.0)) {
172
+ // A SpotLight map modulates the light only inside its projected tile.
173
+ // Three keeps the cone/attenuation result outside that tile; the map is
174
+ // not a second frustum mask.
175
+ return radiance;
176
+ }
177
+ return radiance * textureSampleLevel(projectorTexture, projectorSampler, projectorUv, 0.0).rgb;
178
+ }
179
+
180
+ fn volume_spot_shadow_intensity() -> f32 {
181
+ let spotSlot = u32(clamp(round(volume_params.light_color.w), 0.0, 3.0));
182
+ if (spotSlot >= spotLightsBuffer.count) { return 1.0; }
183
+ return clamp(spotLightsBuffer.slots[spotSlot].shadowIntensity, 0.0, 1.0);
184
+ }
185
+
186
+ fn volume_light_radiance(world_position : vec3<f32>) -> vec3<f32> {
187
+ let mode = u32(round(volume_params.optics.z));
188
+ if (mode == 0u) { return volume_params.light_color.xyz; }
189
+ var radiance = vec3<f32>(0.0);
190
+ if (mode == 1u || mode == 3u) {
191
+ radiance = radiance + volume_point_radiance(world_position);
192
+ }
193
+ if (mode == 2u || mode == 3u) {
194
+ radiance = radiance + volume_spot_radiance(world_position);
195
+ }
196
+ return radiance;
197
+ }
198
+
199
+ fn volume_light_radiance_pair(world_position : vec3<f32>, spot_visibility : f32) -> vec3<f32> {
200
+ // Keep the pair's PointLight term independent from the SpotLight shadow.
201
+ return volume_point_radiance(world_position) +
202
+ volume_spot_radiance(world_position) *
203
+ mix(1.0, spot_visibility, volume_spot_shadow_intensity());
204
+ }
205
+
206
+ fn isFinite(value : f32) -> bool {
207
+ return value == value && abs(value) < 3.402823e+38;
208
+ }
209
+
210
+ fn finiteVec3(value : vec3<f32>) -> bool {
211
+ return isFinite(value.x) && isFinite(value.y) && isFinite(value.z);
212
+ }
213
+
214
+ // Match the pinned Three.js VolumeNodeMaterial density expression while
215
+ // keeping the authored TextureAsset and its renderer-owned sampler intact.
216
+ fn volume_sample_grain(
217
+ position : vec3<f32>,
218
+ scale : f32,
219
+ time_scaled : vec3<f32>,
220
+ time_scale : f32,
221
+ ) -> f32 {
222
+ let coordinate = fract((position + time_scaled * time_scale) * scale);
223
+ return textureSampleLevel(density, density_sampler, coordinate, 0.0).r + 0.5;
224
+ }
225
+
226
+ fn volume_scattering_density(position : vec3<f32>, frame_index : u32) -> f32 {
227
+ let time = f32(frame_index) / 60.0;
228
+ let time_scaled = vec3<f32>(time, 0.0, time * 0.3);
229
+ var grain = volume_sample_grain(position, 0.1, time_scaled, 1.0);
230
+ grain = grain * volume_sample_grain(position, 0.05, time_scaled, 1.0);
231
+ grain = grain * volume_sample_grain(position, 0.02, time_scaled, 2.0);
232
+ // TSL's smokeAmount.mix(1, grain) expands to mix(1, grain, 2).
233
+ return 2.0 * grain - 1.0;
234
+ }
235
+
236
+ fn packed_visibility_at(previous : vec4<f32>, current : vec4<f32>, next : vec4<f32>, channel : i32) -> f32 {
237
+ if (channel < 0) { return previous.w; }
238
+ if (channel > 3) { return next.x; }
239
+ return current[channel];
240
+ }
241
+
242
+ @compute @workgroup_size(8, 8, 1)
243
+ fn volume_integrate(@builtin(global_invocation_id) id : vec3<u32>) {
244
+ let volume_size = textureDimensions(resolved);
245
+ let output_size = textureDimensions(resolved);
246
+ if (any(id.xy >= output_size)) { return; }
247
+ let uv = (vec2<f32>(id.xy) + vec2<f32>(0.5)) / vec2<f32>(output_size);
248
+ let origin = reconstruct_world(uv, 0.0);
249
+ let far_point = reconstruct_world(uv, 1.0);
250
+ let ray_direction = normalize(far_point - origin);
251
+ let interval = ray_box_interval(origin, ray_direction);
252
+ let ray_near = interval.x;
253
+ let ray_far = min(interval.y, volume_params.optics.x);
254
+ let depth_size = textureDimensions(scene_depth);
255
+ let pixel = min(vec2<u32>(uv * vec2<f32>(depth_size)), max(depth_size, vec2<u32>(1u)) - vec2<u32>(1u));
256
+ let scene_depth_value = textureLoad(scene_depth, vec2<i32>(pixel), 0);
257
+ let scene_world = reconstruct_world(uv, scene_depth_value);
258
+ let scene_distance = max(dot(scene_world - origin, ray_direction), 0.0);
259
+ let clipped_far = min(ray_far, scene_distance);
260
+ let visibility_depth = max(textureNumLayers(froxel) * 4u, 1u);
261
+ let ray_step_count = 12u;
262
+ // The pinned Three.js scene evaluates twelve lighting steps. The packed
263
+ // froxel remains deeper so shadow visibility can be interpolated at each
264
+ // ray sample without making the integration denser than the oracle.
265
+ let full_step = max(ray_far - ray_near, 0.0) / f32(visibility_depth);
266
+ let ray_step = max(ray_far - ray_near, 0.0) / f32(ray_step_count);
267
+ let volume_extent = max(volume_params.bounds_max.xyz - volume_params.bounds_min.xyz, vec3<f32>(EPSILON));
268
+ var transmittance = 1.0;
269
+ var scattering = vec3<f32>(0.0);
270
+ let phase = hg(dot(-normalize(volume_params.light_direction.xyz), ray_direction), volume_params.optics.y);
271
+ let sigma_scale = max(dot(volume_params.extinction.xyz, vec3<f32>(0.3333333)), 0.0);
272
+ let raw_froxel_size = max(textureDimensions(froxel).xy, vec2<u32>(1u));
273
+ let raw_froxel_coord = min(
274
+ vec2<u32>(uv * vec2<f32>(raw_froxel_size)),
275
+ raw_froxel_size - vec2<u32>(1u),
276
+ );
277
+ let frame_index = u32(max(volume_params.light_direction.w, 0.0));
278
+ for (var step = 0u; step < ray_step_count; step = step + 1u) {
279
+ let segment_start = ray_near + f32(step) * ray_step;
280
+ let segment_length = clamp(clipped_far - segment_start, 0.0, ray_step);
281
+ if (segment_length > 0.0) {
282
+ let depth_phase = stratified32(
283
+ froxel_seed(vec3<u32>(raw_froxel_coord, step), 3266489917u),
284
+ frame_index,
285
+ 17u,
286
+ );
287
+ let jittered_ray_t = segment_start + segment_length * depth_phase;
288
+ let world_position = origin + ray_direction * jittered_ray_t;
289
+ // The exact Three.js smokeAmount transform can produce negative values
290
+ // from its signed noise domain. Beer-Lambert optical depth is physical
291
+ // extinction, so the renderer clamps only this consumed density while
292
+ // retaining the authored source expression and its signed diagnostics.
293
+ let density_value = max(volume_scattering_density(world_position, frame_index), 0.0);
294
+ let sample_position = clamp(
295
+ (jittered_ray_t - ray_near) / max(full_step, EPSILON) - 0.5,
296
+ 0.0,
297
+ f32(visibility_depth - 1u),
298
+ );
299
+ let lower_position = u32(floor(sample_position));
300
+ let upper_position = min(lower_position + 1u, visibility_depth - 1u);
301
+ let interpolation = sample_position - f32(lower_position);
302
+ let slice_group = lower_position / 4u;
303
+ let upper_group = upper_position / 4u;
304
+ let lower_packed = textureSampleLevel(froxel, froxel_sampler, uv, i32(slice_group), 0.0);
305
+ let upper_packed = textureSampleLevel(froxel, froxel_sampler, uv, i32(upper_group), 0.0);
306
+ let lower_visibility = lower_packed[i32(lower_position % 4u)];
307
+ let upper_visibility = upper_packed[i32(upper_position % 4u)];
308
+ let shadow_visibility = clamp(mix(lower_visibility, upper_visibility, interpolation), 0.0, 1.0);
309
+ // Apply the packed SpotLight shadow only to the spot contribution.
310
+ // PointLight distance attenuation remains independent of the spot cone.
311
+ let mode = u32(round(volume_params.optics.z));
312
+ let visibility = select(
313
+ shadow_visibility,
314
+ mix(1.0, shadow_visibility, volume_spot_shadow_intensity()),
315
+ mode == 2u || mode == 3u,
316
+ );
317
+ let radiance = select(
318
+ volume_light_radiance(world_position) * visibility,
319
+ volume_light_radiance_pair(world_position, visibility),
320
+ mode == 3u,
321
+ );
322
+ let local_transmittance = exp(-sigma_scale * density_value * segment_length);
323
+ let local_scatter = radiance * volume_params.albedo.xyz * phase *
324
+ VOLUME_LIGHT_PHASE_SCALE * (1.0 - local_transmittance) +
325
+ volume_params.emission.xyz * density_value * segment_length;
326
+ scattering = scattering + transmittance * local_scatter;
327
+ transmittance = transmittance * local_transmittance;
328
+ }
329
+ }
330
+ let finite = isFinite(transmittance) && finiteVec3(scattering);
331
+ let result = select(vec4<f32>(0.0, 0.0, 0.0, 1.0), vec4<f32>(scattering, transmittance), finite);
332
+ textureStore(resolved, id.xy, result);
333
+ if (volume_params.optics.w < 0.5) {
334
+ textureStore(history_seed, id.xy, result);
335
+ textureStore(temporal_seed, id.xy, result);
336
+ }
337
+ }
@@ -0,0 +1,70 @@
1
+ #define_import_path forgeax_view::volume_temporal
2
+
3
+ struct VolumeTemporalView {
4
+ _prefix : array<vec4<f32>, 6>, cameraPos : vec4<f32>, _lightViewProjA : mat4x4<f32>, inverseViewProj : mat4x4<f32>,
5
+ _middle : array<vec4<f32>, 34>, temporalCurrentViewProj : mat4x4<f32>, temporalPreviousViewProj : mat4x4<f32>,
6
+ temporalProjection : vec4<f32>, temporalPreviousCameraPos : vec4<f32>,
7
+ };
8
+ struct VolumeParams {
9
+ bounds_min : vec4<f32>, bounds_max : vec4<f32>, extinction : vec4<f32>, albedo : vec4<f32>,
10
+ emission : vec4<f32>, light_direction : vec4<f32>, light_color : vec4<f32>, optics : vec4<f32>,
11
+ };
12
+
13
+ @group(0) @binding(0) var current : texture_2d<f32>;
14
+ @group(0) @binding(1) var accepted : texture_2d<f32>;
15
+ @group(0) @binding(2) var pending : texture_storage_2d<rgba16float, write>;
16
+ @group(0) @binding(3) var<uniform> volume_params : VolumeParams;
17
+ @group(0) @binding(4) var<uniform> volume_view : VolumeTemporalView;
18
+
19
+ fn bilinear(uv : vec2<f32>, size : vec2<u32>) -> vec4<f32> {
20
+ let p = uv * vec2<f32>(size) - vec2<f32>(0.5);
21
+ let base = vec2<i32>(floor(p));
22
+ let f = fract(p);
23
+ let max_coord = vec2<i32>(size) - vec2<i32>(1);
24
+ let c00 = textureLoad(accepted, clamp(base, vec2<i32>(0), max_coord), 0);
25
+ let c10 = textureLoad(accepted, clamp(base + vec2<i32>(1, 0), vec2<i32>(0), max_coord), 0);
26
+ let c01 = textureLoad(accepted, clamp(base + vec2<i32>(0, 1), vec2<i32>(0), max_coord), 0);
27
+ let c11 = textureLoad(accepted, clamp(base + vec2<i32>(1), vec2<i32>(0), max_coord), 0);
28
+ return mix(mix(c00, c10, f.x), mix(c01, c11, f.x), f.y);
29
+ }
30
+
31
+ fn reproject_uv(uv : vec2<f32>) -> vec2<f32> {
32
+ let current_ndc = vec4<f32>(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, 0.5, 1.0);
33
+ let world_h = volume_view.inverseViewProj * current_ndc;
34
+ let world = world_h.xyz / max(abs(world_h.w), 1e-5);
35
+ let previous_clip = volume_view.temporalPreviousViewProj * vec4<f32>(world, 1.0);
36
+ if (previous_clip.w <= 1e-5) {
37
+ return vec2<f32>(-1.0);
38
+ }
39
+ let previous_ndc = previous_clip.xyz / previous_clip.w;
40
+ return vec2<f32>(previous_ndc.x * 0.5 + 0.5, 1.0 - (previous_ndc.y * 0.5 + 0.5));
41
+ }
42
+
43
+ @compute @workgroup_size(8, 8, 1)
44
+ fn volume_temporal(@builtin(global_invocation_id) id : vec3<u32>) {
45
+ let size = textureDimensions(current);
46
+ if (any(id.xy >= size)) { return; }
47
+ let uv = (vec2<f32>(id.xy) + vec2<f32>(0.5)) / vec2<f32>(size);
48
+ let center = textureLoad(current, vec2<i32>(id.xy), 0);
49
+ var lower = center;
50
+ var upper = center;
51
+ for (var oy = -1i; oy <= 1i; oy = oy + 1i) {
52
+ for (var ox = -1i; ox <= 1i; ox = ox + 1i) {
53
+ let coord = clamp(vec2<i32>(id.xy) + vec2<i32>(ox, oy), vec2<i32>(0), vec2<i32>(size) - vec2<i32>(1));
54
+ let neighbor = textureLoad(current, coord, 0);
55
+ lower = min(lower, neighbor);
56
+ upper = max(upper, neighbor);
57
+ }
58
+ }
59
+ var output = center;
60
+ let historyValid = volume_params.optics.w >= 0.5 && volume_view.temporalPreviousCameraPos.w >= 0.5;
61
+ if (historyValid) {
62
+ let previous_uv = reproject_uv(uv);
63
+ if (all(previous_uv >= vec2<f32>(0.0)) && all(previous_uv <= vec2<f32>(1.0))) {
64
+ let prior = bilinear(previous_uv, size);
65
+ let clamped = clamp(prior, lower, upper);
66
+ output = mix(center, clamped, 0.875);
67
+ }
68
+ }
69
+ textureStore(pending, id.xy, output);
70
+ }