@forgeax/engine-shader 0.1.19 → 0.1.20

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.
@@ -9,54 +9,29 @@ const shaderFiles = [
9
9
  'sprite.wgsl',
10
10
  'sprite-lit.wgsl',
11
11
  'msdf-text.wgsl',
12
+ 'skybox.wgsl',
12
13
  ] as const;
13
14
 
14
15
  function source(name: (typeof shaderFiles)[number]): string {
15
16
  return readFileSync(fileURLToPath(new URL(`../${name}`, import.meta.url)), 'utf8');
16
17
  }
17
18
 
18
- function subtract(a: readonly number[], b: readonly number[]): number[] {
19
- return a.map((value, index) => value - (b[index] ?? 0));
20
- }
21
-
22
- function dot(a: readonly number[], b: readonly number[]): number {
23
- return a.reduce((sum, value, index) => sum + value * (b[index] ?? 0), 0);
24
- }
25
-
26
- function scale(a: readonly number[], scalar: number): number[] {
27
- return a.map((value) => value * scalar);
28
- }
29
-
30
- describe('Fog orthographic fragment ray contract', () => {
31
- it('derives the camera-plane origin from the current fragment for every built-in material', () => {
19
+ describe('Built-in fragment volume handoff', () => {
20
+ it('does not reconstruct or apply a private analytic fog ray', () => {
32
21
  for (const name of shaderFiles) {
33
22
  const shader = source(name);
34
- expect(shader, name).toContain(
35
- 'worldPos - direction * dot(worldPos - viewParams.cameraPos, direction)',
36
- );
37
- expect(shader, name).not.toContain('origin = nearPoint;');
38
- expect(shader.match(/applySceneFog\(/g)?.length, name).toBeGreaterThanOrEqual(2);
23
+ expect(shader, name).not.toContain('applySceneFog(');
24
+ expect(shader, name).not.toContain('apply_fog(');
25
+ expect(shader, name).not.toContain('forgeax_view::fog');
39
26
  }
40
27
  });
41
28
 
42
- it('keeps a tilted orthographic camera pixel on its own ray rather than the center ray', () => {
43
- const camera = [4, 2, -3];
44
- const direction = [0.3, -0.8, 0.5];
45
- const length = Math.hypot(...direction);
46
- const unitDirection = direction.map((value) => value / length);
47
- const lateral = [1, 0, -0.6];
48
- const fragment = camera
49
- .map((value, index) => value + (lateral[index] ?? 0))
50
- .map((value, index) => value + (unitDirection[index] ?? 0) * 7);
51
- const origin = subtract(
52
- fragment,
53
- scale(unitDirection, dot(subtract(fragment, camera), unitDirection)),
29
+ it('keeps the volume composite as the shared post-lighting owner', () => {
30
+ const volume = readFileSync(
31
+ fileURLToPath(new URL('../volume/volume-composite.wgsl', import.meta.url)),
32
+ 'utf8',
54
33
  );
55
-
56
- expect(origin[0]).toBeCloseTo(5, 8);
57
- expect(origin[1]).toBeCloseTo(2, 8);
58
- expect(origin[2]).toBeCloseTo(-3.6, 8);
59
- expect(dot(subtract(fragment, origin), unitDirection)).toBeCloseTo(7, 8);
60
- expect(dot(subtract(origin, camera), unitDirection)).toBeCloseTo(0, 8);
34
+ expect(volume).toContain('resolved_volume');
35
+ expect(volume).toContain('composite_resolved_volume');
61
36
  });
62
37
  });
@@ -10,68 +10,33 @@ const SHADER_FILES = [
10
10
  'sprite-lit.wgsl',
11
11
  'msdf-text.wgsl',
12
12
  'skybox.wgsl',
13
+ 'hdrp-deferred-lighting.wgsl',
13
14
  ] as const;
14
15
 
15
16
  const shaderSource = (file: string): string =>
16
17
  readFileSync(fileURLToPath(new URL(`../${file}`, import.meta.url)), 'utf8');
17
18
 
18
- const count = (source: string, pattern: RegExp): number => source.match(pattern)?.length ?? 0;
19
-
20
- function producerContract(source: string): boolean {
21
- return (
22
- /#import\s+forgeax_view::common::\{[^}]*\bFogRay\b[^}]*\}/.test(source) &&
23
- /#import\s+forgeax_view::fog::\{[^}]*\bapply_fog\b[^}]*\}/.test(source) &&
24
- count(source, /\bapply_fog\s*\(/g) === 1 &&
25
- count(source, /\bFogRay\s*\(/g) >= 1 &&
26
- (source.includes('view.fog') || source.includes('viewParams.fog'))
27
- );
28
- }
29
-
30
- function body(source: string, entry: string, nextEntry: string): string {
31
- const start = source.indexOf(entry);
32
- const end = nextEntry === '' ? -1 : source.indexOf(nextEntry, start + entry.length);
33
- return source.slice(start, end === -1 ? source.length : end);
34
- }
35
-
36
- describe('built-in Fog producer matrix', () => {
37
- it('requires one shared Fog module call and a real FogRay for every built-in output', () => {
19
+ describe('Built-in volume producer matrix', () => {
20
+ it('removes every analytic fog producer from built-in outputs', () => {
38
21
  for (const file of SHADER_FILES) {
39
22
  const source = shaderSource(file);
40
- expect(producerContract(source), file).toBe(true);
23
+ expect(source, file).not.toContain('apply_fog');
24
+ expect(source, file).not.toContain('applySceneFog');
25
+ expect(source, file).not.toContain('forgeax_view::fog');
26
+ expect(source, file).not.toContain('FogRay');
41
27
  }
42
28
  });
43
29
 
44
- it('keeps deferred G-buffer and temporal/reactive outputs free of Fog', () => {
45
- const pbr = shaderSource('default-standard-pbr.wgsl');
46
- const gbuffer = body(pbr, 'fn fs_gbuffer', 'struct TemporalVsOut');
47
- const temporal = body(pbr, 'fn fs_temporal', '');
48
-
49
- expect(gbuffer).not.toContain('applySceneFog(');
50
- expect(gbuffer).not.toContain('apply_fog(');
51
- expect(temporal).not.toContain('applySceneFog(');
52
- expect(temporal).not.toContain('apply_fog(');
30
+ it('keeps the volume module as the sole optical integration owner', () => {
31
+ const composite = shaderSource('volume/volume-composite.wgsl');
32
+ expect(shaderSource('volume/volume-integrate.wgsl')).toContain('fn hg');
33
+ expect(shaderSource('volume/volume-integrate.wgsl')).toContain('FOUR_PI');
34
+ expect(composite).toContain('resolved_volume');
53
35
  });
54
36
 
55
- it('turns deletion, duplication, and non-scene output coverage into red verdicts', () => {
56
- const source = shaderSource('default-standard-pbr.wgsl');
57
- expect(producerContract(source)).toBe(true);
58
-
59
- const deleted = source.replace(/#import\s+forgeax_view::fog::\{[^\n]+\}\n?/, '');
60
- expect(producerContract(deleted)).toBe(false);
61
-
62
- const duplicated = `${source}\n${source.match(/\bapply_fog\s*\([\s\S]*?\);/)?.[0] ?? ''}`;
63
- expect(producerContract(duplicated)).toBe(false);
64
-
65
- const gbufferStart = source.indexOf('fn fs_gbuffer');
66
- const gbufferFogged = `${source.slice(0, gbufferStart)}${source
67
- .slice(gbufferStart)
68
- .replace(
69
- 'let baseUv =',
70
- 'let _falsify = applySceneFog(vec3<f32>(1.0), 1.0, vec3<f32>(0.0));\n let baseUv =',
71
- )}`;
72
- expect(gbufferFogged).not.toBe(source);
73
- expect(body(gbufferFogged, 'fn fs_gbuffer', 'struct TemporalVsOut')).toContain(
74
- 'applySceneFog(',
75
- );
37
+ it('keeps a single producer matrix without fallback fog entry points', () => {
38
+ const sources = SHADER_FILES.map(shaderSource).join('\n');
39
+ expect(sources.match(/apply_fog|applySceneFog|FogRay/g)).toBeNull();
40
+ expect(shaderSource('volume/volume-integrate.wgsl')).toContain('local_scatter');
76
41
  });
77
42
  });
@@ -0,0 +1,48 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ const punctual = readFileSync(new URL('../lighting-punctual.wgsl', import.meta.url), 'utf8');
5
+ const attenuation = readFileSync(new URL('../lighting-attenuation.wgsl', import.meta.url), 'utf8');
6
+ const inject = readFileSync(new URL('../volume/volume-inject.wgsl', import.meta.url), 'utf8');
7
+ const integrate = readFileSync(new URL('../volume/volume-integrate.wgsl', import.meta.url), 'utf8');
8
+
9
+ describe('shared punctual optics for volumetric fog', () => {
10
+ it('publishes one finite Point and Spot volume evaluator', () => {
11
+ expect(punctual).toContain('fn evalVolumePoint(');
12
+ expect(punctual).toContain('fn evalVolumeSpot(');
13
+ expect(punctual).toContain('evalDistanceAttenuation');
14
+ expect(punctual).toContain('evalSpotAttenuation');
15
+ expect(punctual).toContain('max(dSquared, 1e-4)');
16
+ expect(attenuation).toContain('smoothstep(cosOuter, cosInner');
17
+ expect(attenuation).toContain('safeDistance');
18
+ });
19
+
20
+ it('uses the shared evaluators from the fixed inject and integrate stages', () => {
21
+ expect(inject).toContain('forgeax_pbr::lighting_punctual');
22
+ expect(integrate).toContain('forgeax_pbr::lighting_punctual');
23
+ expect(inject).toContain('evalVolumePoint');
24
+ expect(inject).toContain('evalVolumeSpot');
25
+ expect(integrate).toContain('evalVolumePoint');
26
+ expect(integrate).toContain('evalVolumeSpot');
27
+ expect(inject).toContain('pointLightsBuffer');
28
+ expect(inject).toContain('spotLightsBuffer');
29
+ });
30
+
31
+ it('keeps the punctual volume path finite and monotonic at range boundaries', () => {
32
+ expect(punctual).toMatch(/max\([^\n]*1e-4/);
33
+ expect(attenuation).toMatch(/clamp\(1\.0 - \(safeDistance \* invRangeSquared\)/);
34
+ expect(attenuation).toContain('return distance * cone');
35
+ expect(integrate).toContain('isFinite');
36
+ });
37
+
38
+ it('uses the pinned Three density grain and smoke amount mapping', () => {
39
+ expect(integrate).toContain('fn volume_sample_grain(');
40
+ expect(integrate).toContain('fract((position + time_scaled * time_scale) * scale)');
41
+ expect(integrate).toContain('volume_sample_grain(position, 0.1, time_scaled, 1.0)');
42
+ expect(integrate).toContain('volume_sample_grain(position, 0.05, time_scaled, 1.0)');
43
+ expect(integrate).toContain('volume_sample_grain(position, 0.02, time_scaled, 2.0)');
44
+ expect(integrate).toContain('return 2.0 * grain - 1.0;');
45
+ expect(integrate).toContain('volume_scattering_density(world_position, frame_index)');
46
+ expect(integrate).not.toContain('density_uv');
47
+ });
48
+ });
@@ -0,0 +1,26 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ const volumeInject = readFileSync(new URL('../volume/volume-inject.wgsl', import.meta.url), 'utf8');
5
+ const volumeIntegrate = readFileSync(
6
+ new URL('../volume/volume-integrate.wgsl', import.meta.url),
7
+ 'utf8',
8
+ );
9
+
10
+ describe('volume Spot attenuation precision', () => {
11
+ it('keeps the shared finite-range attenuation monotonic', () => {
12
+ expect(volumeInject).not.toContain('evalSpotAttenuation');
13
+ expect(volumeInject).not.toContain('attenuation * cone_distance');
14
+ expect(volumeInject).toContain('spot_shadow_visibility_at');
15
+ expect(volumeIntegrate).toContain('evalSpotAttenuation');
16
+ expect(volumeIntegrate).toContain('spot_attenuation_at');
17
+ expect(volumeIntegrate).toContain('light.invRangeSquared');
18
+ // The paired Point+Spot owner applies visibility only to the Spot term;
19
+ // the Point radiance remains independent of the spot shadow. Keep the
20
+ // assertion at the semantic call boundary so WGSL formatting changes do
21
+ // not turn this contract test into a stale substring check.
22
+ expect(volumeIntegrate).toContain('volume_light_radiance_pair(world_position, visibility)');
23
+ expect(volumeIntegrate).toContain('volume_spot_radiance(world_position) *');
24
+ expect(volumeIntegrate).toContain('mix(1.0, spot_visibility, volume_spot_shadow_intensity())');
25
+ });
26
+ });
@@ -0,0 +1,25 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ const attenuation = readFileSync(new URL('../lighting-attenuation.wgsl', import.meta.url), 'utf8');
5
+ const punctual = readFileSync(new URL('../lighting-punctual.wgsl', import.meta.url), 'utf8');
6
+
7
+ describe('shared Spot volume optics', () => {
8
+ it('uses finite squared distance attenuation and a smooth cosine cone', () => {
9
+ expect(attenuation).toContain('evalSpotAttenuation');
10
+ expect(attenuation).toContain('invRangeSquared');
11
+ expect(attenuation).toContain('smoothstep(cosOuter, cosInner');
12
+ });
13
+
14
+ it('is consumed by the surface punctual owner', () => {
15
+ expect(punctual).toContain('forgeax_pbr::lighting_attenuation');
16
+ expect(punctual).toContain('evalSpotAttenuation');
17
+ });
18
+
19
+ it('keeps invalid range and cone inputs finite at the shared boundary', () => {
20
+ expect(attenuation).toContain('max(dSquared, 1e-4)');
21
+ expect(attenuation).toContain('clamp');
22
+ expect(attenuation).toContain('cosInner');
23
+ expect(attenuation).toContain('cosOuter');
24
+ });
25
+ });
@@ -805,6 +805,7 @@ import {
805
805
  '#pragma variant_axis CLUSTER_FORWARD_AVAILABLE',
806
806
  '#pragma variant_axis VERTEX_COLOR_AVAILABLE',
807
807
  '#pragma variant_axis TRANSMISSION_AVAILABLE',
808
+ '#pragma variant_axis PROJECTOR_AVAILABLE',
808
809
  ]);
809
810
  });
810
811
 
@@ -118,6 +118,7 @@ describe('w6 (b) -- pbr / unlit / sprite-adjacent shaders DO NOT pick up PER_INS
118
118
  '#pragma variant_axis CLUSTER_FORWARD_AVAILABLE',
119
119
  '#pragma variant_axis VERTEX_COLOR_AVAILABLE',
120
120
  '#pragma variant_axis TRANSMISSION_AVAILABLE',
121
+ '#pragma variant_axis PROJECTOR_AVAILABLE',
121
122
  ]);
122
123
  });
123
124
 
package/src/common.wgsl CHANGED
@@ -76,8 +76,7 @@ fn linearToSrgbOetf(color : vec3<f32>) -> vec3<f32> {
76
76
  // array fold (feat-20260625 w25) removes the standalone @group(0) binding 9
77
77
  // uniform buffer so the WebGL2 fallback fragment uniform-buffer count returns
78
78
  // to 11 (GLES 3.0 max).
79
- // temporal projection facts and Fog payload append after the spot matrix;
80
- // total = 960 B.
79
+ // temporal projection facts append after the spot matrix; total = 960 B.
81
80
  //
82
81
  // Field order must stay byte-for-byte identical to every prior release
83
82
  // (charter P4 consistent abstraction); new fields append at the tail.
@@ -128,19 +127,6 @@ fn linearToSrgbOetf(color : vec3<f32>) -> vec3<f32> {
128
127
  // 960 (renderer-factory.ts) and writes the full 240 f32
129
128
  // payload (render-system-record.ts). Lane N = the spot with `shadowAtlasTile
130
129
  // === N` (cap = 4); `evalSpotShadowed` reads `view.spotLightViewProj[tile]`.
131
- struct FogViewParams {
132
- color : vec3<f32>,
133
- density : f32,
134
- heightFalloff : f32,
135
- maxOpacity : f32,
136
- };
137
-
138
- struct FogRay {
139
- origin : vec3<f32>,
140
- direction : vec3<f32>,
141
- distance : f32,
142
- };
143
-
144
130
  struct View {
145
131
  worldViewProj : mat4x4<f32>,
146
132
  lightDir : vec3<f32>,
@@ -167,7 +153,9 @@ struct View {
167
153
  temporalPreviousViewProj : mat4x4<f32>,
168
154
  // x = near, y = far, z = 1 for orthographic and 0 for perspective.
169
155
  temporalProjection : vec4<f32>,
170
- fog : FogViewParams,
156
+ // Accepted camera origin at the previous submitted frame. This occupies
157
+ // the existing tail padding and keeps temporal reprojection on one SSOT.
158
+ temporalPreviousCameraPos : vec4<f32>,
171
159
  };
172
160
 
173
161
  // Per-instance mesh slot (feat-20260518-pbr-direct-lighting-mvp M2 / w8.5,
@@ -202,7 +190,7 @@ struct Mesh {
202
190
  // color * intensity so the shader avoids the per-fragment mul)
203
191
  // [ 7 ] pad f32 = 0
204
192
  //
205
- // SpotLight (64 B / 16 floats):
193
+ // SpotLight (80 B / 20 floats):
206
194
  // [ 0..2 ] position vec3<f32>
207
195
  // [ 3 ] invRangeSquared f32
208
196
  // [ 4..6 ] colorTimesIntensity vec3<f32>
@@ -211,9 +199,11 @@ struct Mesh {
211
199
  // [ 8..10] direction vec3<f32> (raw outgoing vector; shader reads
212
200
  // via dot(L, -direction) for cone angle test)
213
201
  // [ 11 ] cosOuter f32
214
- // [12..14] spotPad vec3<f32> = 0 (std430 vec4-alignment padding)
202
+ // [12] depthBias f32, [13] normalBias f32, [14] pcfKernelSize f32
215
203
  // [ 15 ] shadowAtlasTile i32 (sentinel -1 = unassigned/clipped,
216
204
  // 0..3 = spot atlas tile index; feat-20260625 M2/M3 D-4)
205
+ // [ 16 ] shadowIntensity f32 (fraction of sampled shadow to apply)
206
+ // [17..19] alignment tail padding
217
207
  //
218
208
  // WGSL std430 alignment audit: vec3<f32> alignof=16 / sizeof=12. The
219
209
  // f32 lane wedged immediately after each vec3 fills the 4 B remainder
@@ -221,10 +211,10 @@ struct Mesh {
221
211
  // prev.size, this.alignof) - for f32 alignof=4 the round-up is a
222
212
  // no-op, so position[3] / color[3] / direction[3] sit at byte
223
213
  // offsets 12 / 28 / 44 with zero internal padding). PointLight stays
224
- // 32 B; SpotLight grows to 64 B (feat-20260625 M2/M3 D-4): the trailing
225
- // `spotPad: vec3<f32>` + `shadowAtlasTile: i32` form one vec4-aligned block
226
- // at bytes 48..64, with `shadowAtlasTile` at byte 60. These struct sizes
227
- // match the host packers (packPointLight 32 B / packSpotLight 64 B) exactly.
214
+ // 32 B; SpotLight grows from 64 B (feat-20260625 M2/M3 D-4) for the three
215
+ // receiver controls plus `shadowAtlasTile: i32`, then to 80 B for the
216
+ // parity-only `shadowIntensity` lane at byte 64. These struct sizes match the
217
+ // host packers (packPointLight 32 B / packSpotLight 80 B) exactly.
228
218
  //
229
219
  // Header `count: u32` lives in a wrapper struct with the trailing
230
220
  // runtime-sized array. Array-of-vec4-aligned-struct alignof = 16, so
@@ -255,19 +245,14 @@ struct SpotLight {
255
245
  direction : vec3<f32>,
256
246
  cosOuter : f32,
257
247
  // feat-20260625-spot-light-shadow-mapping M3 / w13 (plan-strategy D-4):
258
- // The prior 8-lane (48 B) SpotLight had no spare pad lane (unlike PointLight
259
- // which repurposed `pointPadW`), so the clip-signal field needs a fresh
260
- // vec4-aligned block. `spotPad` fills bytes 48..60 (host packSpotLight slots
261
- // 12..14 stay zero); `shadowAtlasTile` (i32 sentinel, -1 = no shadow /
262
- // clipped, 0..3 = spot atlas tile) lands at byte 60 (host slot 15, written
263
- // through an Int32Array view). The fragment path gates on
264
- // `shadowAtlasTile >= 0` (evalSpotShadowed vs evalSpot). Field names are
265
- // unique across the composed module surface so naga_oil writeback
266
- // substitution does not collide with prior `pad0` members. (naga_oil
267
- // reserves the `__` identifier prefix, so the pad lane is `spotPad`, not
268
- // `__pad`.)
269
- spotPad : vec3<f32>,
248
+ // The trailing vec4 transports receiver controls and the tile identity.
249
+ // Host packSpotLight writes slots 12..14 from the same snapshot; slot 15 is
250
+ // the i32 shadow tile sentinel and slot 16 carries shadowIntensity.
251
+ depthBias : f32,
252
+ normalBias : f32,
253
+ pcfKernelSize : f32,
270
254
  shadowAtlasTile : i32,
255
+ shadowIntensity : f32,
271
256
  };
272
257
 
273
258
  struct PointLightsArray {
@@ -282,32 +267,6 @@ struct SpotLightsArray {
282
267
 
283
268
  @group(0) @binding(0) var<uniform> view : View;
284
269
 
285
- // Shared screen-space ray reconstruction for scene Fog producers. Keeping the
286
- // inverse-view and projection branch here prevents each producer from growing
287
- // a second Fog formula or drifting from the View ABI.
288
- fn fogWorldPoint(ndc : vec3<f32>) -> vec3<f32> {
289
- let homogeneous = view.inverseViewProj * vec4<f32>(ndc, 1.0);
290
- let divisor = select(1.0, homogeneous.w, abs(homogeneous.w) > 0.000001);
291
- return homogeneous.xyz / divisor;
292
- }
293
-
294
- fn fogRayFromNdc(ndc : vec3<f32>) -> FogRay {
295
- let worldPosition = fogWorldPoint(ndc);
296
- let nearPosition = fogWorldPoint(vec3<f32>(ndc.xy, 0.0));
297
- let farPosition = fogWorldPoint(vec3<f32>(ndc.xy, 1.0));
298
- let perspective = view.temporalProjection.z < 0.5;
299
- let perspectiveVector = worldPosition - view.cameraPos;
300
- let orthographicVector = farPosition - nearPosition;
301
- let direction = normalize(select(orthographicVector, perspectiveVector, perspective));
302
- let origin = select(nearPosition, view.cameraPos, perspective);
303
- let rayDistance = select(
304
- max(dot(worldPosition - nearPosition, direction), 0.0),
305
- length(perspectiveVector),
306
- perspective,
307
- );
308
- return FogRay(origin, direction, rayDistance);
309
- }
310
-
311
270
  #if STORAGE_BUFFER_AVAILABLE == true
312
271
  @group(0) @binding(1) var<storage, read> pointLightsBuffer : PointLightsArray;
313
272
  @group(0) @binding(2) var<storage, read> spotLightsBuffer : SpotLightsArray;
@@ -421,7 +380,17 @@ struct ShadowCasterCascade {
421
380
  // — folded there in feat-20260625 w25 (scope-amend) to keep the WebGL2 fallback
422
381
  // fragment uniform-buffer count <= 11; binding 8 is the last view-BG binding.
423
382
  @group(0) @binding(8) var spotShadowMap : texture_depth_2d;
424
-
383
+ // Optional authored SpotLight projector. Surface and volume bind the same
384
+ // accepted TextureAsset view and linear-clamp sampler; an unprojected spot
385
+ // receives the renderer-owned white fallback. The declaration is capability
386
+ // gated because the complete HDRP PBR view/material ABI reaches the WebGPU
387
+ // minimum of 16 sampled textures without this optional cookie. Devices that
388
+ // cannot expose the 17th sampled texture select the matching white-projector
389
+ // shader variant and omit these resources from the view BGL.
390
+ #ifdef PROJECTOR_AVAILABLE
391
+ @group(0) @binding(11) var projectorTexture : texture_2d<f32>;
392
+ @group(0) @binding(12) var projectorSampler : sampler;
393
+ #endif
425
394
  #if STORAGE_BUFFER_AVAILABLE == true
426
395
  @group(2) @binding(0) var<storage, read> meshes : array<Mesh>;
427
396
  #else
@@ -1,11 +1,10 @@
1
- #import forgeax_view::common::{View, FogViewParams, FogRay, Mesh, InstanceData, view, meshes, instances, PointLight, SpotLight, pointLightsBuffer, spotLightsBuffer, shadowMap, shadowSampler, sampleMaterialTexture}
1
+ #import forgeax_view::common::{View, Mesh, InstanceData, view, meshes, instances, PointLight, SpotLight, pointLightsBuffer, spotLightsBuffer, shadowMap, shadowSampler, sampleMaterialTexture}
2
2
  #import forgeax_pbr::temporal::{projectPbrSceneTemporal}
3
3
  #import forgeax_pbr::brdf::{f_schlick, v_smith, d_ggx}
4
4
  #import forgeax_pbr::ibl_sampling::{sampleIblDiffuse, sampleIblSpecular}
5
5
  #import forgeax_pbr::tbn::{decodeTangentSpaceNormalRg, scaleTangentSpaceNormal, applyTBN}
6
6
  #import forgeax_pbr::lighting_directional::{evalDirectionalNoShadow, evalDirectionalShadowFactor}
7
7
  #import forgeax_pbr::lighting_punctual::{evalPoint, evalSpot}
8
- #import forgeax_view::fog::{apply_fog}
9
8
  #ifdef POINT_SHADOW_AVAILABLE
10
9
  #import forgeax_pbr::lighting_punctual::{evalPointShadowed}
11
10
  #import forgeax_view::common::{shadowParams}
@@ -230,22 +229,6 @@ fn pick_channel(rgba : vec4<f32>, channelIndex : u32) -> f32 {
230
229
  }
231
230
  }
232
231
 
233
- fn applySceneFog(viewParams : View, color : vec3<f32>, alpha : f32, worldPos : vec3<f32>) -> vec4<f32> {
234
- var origin = viewParams.cameraPos;
235
- var direction = normalize(worldPos - origin);
236
- var rayDistance = length(worldPos - origin);
237
- if (viewParams.temporalProjection.z >= 0.5) {
238
- let nearH = viewParams.inverseViewProj * vec4<f32>(0.0, 0.0, 0.0, 1.0);
239
- let farH = viewParams.inverseViewProj * vec4<f32>(0.0, 0.0, 1.0, 1.0);
240
- let nearPoint = nearH.xyz / nearH.w;
241
- let farPoint = farH.xyz / farH.w;
242
- direction = normalize(farPoint - nearPoint);
243
- origin = worldPos - direction * dot(worldPos - viewParams.cameraPos, direction);
244
- rayDistance = max(dot(worldPos - origin, direction), 0.0);
245
- }
246
- return apply_fog(viewParams.fog, FogRay(origin, direction, rayDistance), vec4<f32>(color, alpha));
247
- }
248
-
249
232
  @vertex
250
233
  fn vs_main(in : VsIn, @builtin(instance_index) idx : u32) -> VsOut {
251
234
  // 4-bone weighted skinning (plan-strategy D-3 / D-3a).
@@ -473,7 +456,7 @@ fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
473
456
  );
474
457
  }
475
458
  }
476
- return applySceneFog(view, color, alpha, in.worldPos);
459
+ return vec4<f32>(color, alpha);
477
460
  }
478
461
 
479
462
  struct TemporalVsOut {
@@ -1,16 +1,36 @@
1
1
  #define_import_path forgeax_material::standard
2
- #import forgeax_view::common::{View, FogViewParams, FogRay, Mesh, InstanceData, view, meshes, instances, PointLight, SpotLight, pointLightsBuffer, spotLightsBuffer, shadowMap, shadowSampler, sampleMaterialTexture}
2
+ #import forgeax_view::common::{View, Mesh, InstanceData, view, meshes, instances, PointLight, SpotLight, pointLightsBuffer, spotLightsBuffer, shadowMap, shadowSampler, sampleMaterialTexture}
3
+ #ifdef PROJECTOR_AVAILABLE
4
+ #import forgeax_view::common::{projectorTexture, projectorSampler}
5
+ #endif
3
6
  #import forgeax_pbr::temporal::{projectPbrSceneTemporal}
4
7
  #import forgeax_pbr::brdf::{f_schlick, v_smith, d_ggx}
5
8
  #import forgeax_pbr::ibl_sampling::{sampleIblDiffuse, sampleIblSpecular}
6
9
  #import forgeax_pbr::tbn::{decodeTangentSpaceNormalRg, scaleTangentSpaceNormal, applyTBN}
7
10
  #import forgeax_pbr::lighting_directional::{evalDirectionalNoShadow, evalDirectionalShadowFactor}
8
- #import forgeax_pbr::lighting_punctual::{evalPoint, evalSpot, evalSpotShadowed}
9
- #import forgeax_view::fog::{apply_fog}
11
+ #import forgeax_pbr::lighting_punctual::{evalPoint, evalSpot, evalSpotShadowed, projectSpotUv}
10
12
  #ifdef POINT_SHADOW_AVAILABLE
11
13
  #import forgeax_pbr::lighting_punctual::{evalPointShadowed}
12
14
  #import forgeax_view::common::{shadowParams}
13
15
  #endif
16
+
17
+ fn sampleSpotProjector(lightViewProj : mat4x4<f32>, worldPos : vec3<f32>) -> vec3<f32> {
18
+ #ifdef PROJECTOR_AVAILABLE
19
+ let uv = projectSpotUv(lightViewProj, worldPos);
20
+ if (!(uv.x >= 0.0 && uv.x <= 1.0 && uv.y >= 0.0 && uv.y <= 1.0)) {
21
+ // Three's SpotLight.map is a modulation inside the projected light
22
+ // frustum, not a second cone mask. Outside the map the original spot
23
+ // radiance remains intact; the cone/visibility terms already own whether
24
+ // the light reaches the fragment.
25
+ return vec3<f32>(1.0);
26
+ }
27
+ return textureSampleLevel(projectorTexture, projectorSampler, uv, 0.0).rgb;
28
+ #else
29
+ // Keep the analytic SpotLight contribution intact on adapters whose
30
+ // fragment-stage sampled-texture budget cannot carry the optional cookie.
31
+ return vec3<f32>(1.0);
32
+ #endif
33
+ }
14
34
  #ifdef CLUSTER_FORWARD_AVAILABLE
15
35
  #import forgeax_hdrp::cluster_forward::{evaluate_cluster_lights, get_ssao_intensity}
16
36
  #endif
@@ -19,6 +39,7 @@
19
39
  #pragma variant_axis CLUSTER_FORWARD_AVAILABLE
20
40
  #pragma variant_axis VERTEX_COLOR_AVAILABLE
21
41
  #pragma variant_axis TRANSMISSION_AVAILABLE
42
+ #pragma variant_axis PROJECTOR_AVAILABLE
22
43
 
23
44
  // @forgeax/engine-shader - default-standard-pbr.wgsl
24
45
  // (feat-20260523-shader-template-instance-split M5 / T04).
@@ -289,25 +310,6 @@ fn pick_channel(rgba : vec4<f32>, channelIndex : u32) -> f32 {
289
310
  }
290
311
  }
291
312
 
292
- fn applySceneFog(viewParams : View, color : vec3<f32>, alpha : f32, worldPos : vec3<f32>) -> vec4<f32> {
293
- var origin = viewParams.cameraPos;
294
- var direction = normalize(worldPos - origin);
295
- var rayDistance = length(worldPos - origin);
296
- if (viewParams.temporalProjection.z >= 0.5) {
297
- let nearH = viewParams.inverseViewProj * vec4<f32>(0.0, 0.0, 0.0, 1.0);
298
- let farH = viewParams.inverseViewProj * vec4<f32>(0.0, 0.0, 1.0, 1.0);
299
- let nearPoint = nearH.xyz / nearH.w;
300
- let farPoint = farH.xyz / farH.w;
301
- direction = normalize(farPoint - nearPoint);
302
- // Orthographic rays are parallel, but their origins differ per fragment.
303
- // Project the current fragment onto the camera plane instead of reusing
304
- // the center NDC ray origin for every pixel.
305
- origin = worldPos - direction * dot(worldPos - viewParams.cameraPos, direction);
306
- rayDistance = max(dot(worldPos - origin, direction), 0.0);
307
- }
308
- return apply_fog(viewParams.fog, FogRay(origin, direction, rayDistance), vec4<f32>(color, alpha));
309
- }
310
-
311
313
  // Light evaluators live in forgeax_pbr::lighting_directional +
312
314
  // forgeax_pbr::lighting_punctual (M5 / T02). evalDirectional consumes the
313
315
  // host's view UBO + shadowMap (LO 3.1.3 slope-scaled-bias 3x3 PCF;
@@ -778,6 +780,9 @@ fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
778
780
  #endif
779
781
  }
780
782
  let spotCount = spotLightsBuffer.count;
783
+ // Surface and volume consume the same projectSpotUv / projectorRevision
784
+ // tuple; an accepted projector is sampled with textureSampleLevel.
785
+ const PROJECTOR_ONLY_TILE : i32 = -2;
781
786
  for (var i: u32 = 0u; i < spotCount; i = i + 1u) {
782
787
  let s = spotLightsBuffer.slots[i];
783
788
  // feat-20260625-spot-light-shadow-mapping M3 / w15 (plan-strategy D-1
@@ -787,23 +792,37 @@ fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
787
792
  // per-spot perspective matrix from the View UBO `view.spotLightViewProj`
788
793
  // array (lane N = shadowAtlasTile N; folded from the standalone binding 9
789
794
  // into the View UBO in feat-20260625 w25 for WebGL2 uniform-buffer budget);
790
- // the unshadowed path stays on evalSpot. bias hardcoded 0.005 / 0.05 aligns
791
- // with the DirectionalLight defaults (D-6); the OOB/NaN gate lives inside
792
- // evalSpotShadowed, not here.
795
+ // the unshadowed path stays on evalSpot. Receiver controls come from the
796
+ // same packed SpotLight snapshot used by the volume path.
793
797
  if (s.shadowAtlasTile >= 0) {
798
+ let projector = sampleSpotProjector(view.spotLightViewProj[s.shadowAtlasTile], in.worldPos);
794
799
  color = color + evalSpotShadowed(
795
800
  s.position, s.direction, s.colorTimesIntensity,
796
801
  s.cosInner, s.cosOuter, s.invRangeSquared,
797
- in.worldPos, n, v, diffuseAlbedo, metallic, a, f0,
798
- view.spotLightViewProj[s.shadowAtlasTile], s.shadowAtlasTile, 0.005, 0.05,
799
- );
802
+ in.worldPos, n, v, albedo, metallic, a, f0,
803
+ view.spotLightViewProj[s.shadowAtlasTile], s.shadowAtlasTile, s.depthBias, s.normalBias, s.pcfKernelSize, s.shadowIntensity,
804
+ ) * projector;
800
805
  if (material.clearcoat != 0.0) {
801
806
  color = color + material.clearcoat * evalSpotShadowed(
802
807
  s.position, s.direction, s.colorTimesIntensity,
803
808
  s.cosInner, s.cosOuter, s.invRangeSquared,
804
809
  in.worldPos, n, v, vec3<f32>(0.0), 1.0, coatAlpha, vec3<f32>(0.04),
805
- view.spotLightViewProj[s.shadowAtlasTile], s.shadowAtlasTile, 0.005, 0.05,
806
- );
810
+ view.spotLightViewProj[s.shadowAtlasTile], s.shadowAtlasTile, s.depthBias, s.normalBias, s.pcfKernelSize, s.shadowIntensity,
811
+ ) * projector;
812
+ }
813
+ } else if (s.shadowAtlasTile == PROJECTOR_ONLY_TILE) {
814
+ let projector = sampleSpotProjector(view.spotLightViewProj[0], in.worldPos);
815
+ color = color + evalSpot(
816
+ s.position, s.direction, s.colorTimesIntensity,
817
+ s.cosInner, s.cosOuter, s.invRangeSquared,
818
+ in.worldPos, n, v, albedo, metallic, a, f0,
819
+ ) * projector;
820
+ if (material.clearcoat != 0.0) {
821
+ color = color + material.clearcoat * evalSpot(
822
+ s.position, s.direction, s.colorTimesIntensity,
823
+ s.cosInner, s.cosOuter, s.invRangeSquared,
824
+ in.worldPos, n, v, vec3<f32>(0.0), 1.0, coatAlpha, vec3<f32>(0.04),
825
+ ) * projector;
807
826
  }
808
827
  } else {
809
828
  color = color + evalSpot(
@@ -824,7 +843,7 @@ fn fs_main(in : VsOut) -> @location(0) vec4<f32> {
824
843
  let emissiveUv = transformedMaterialUv(material.emissiveTextureCoordinatesTransform, material.emissiveTextureCoordinatesMetadata, in);
825
844
  let emissiveSample = sampleMaterialTexture(emissiveTexture, emissiveSampler, emissiveUv, material.emissiveTextureCoordinatesMetadata.zw).rgb;
826
845
  color = color + material.emissive * material.emissiveIntensity * emissiveSample;
827
- return applySceneFog(view, color, alpha, in.worldPos);
846
+ return vec4<f32>(color, alpha);
828
847
  }
829
848
 
830
849
  // ── G-buffer output struct (feat-20260612-hdrp-deferred-shading M2 / w12) ──