@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.
- package/dist/.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/src/__tests__/default-standard-pbr-transmission.unit.test.ts +23 -19
- package/src/__tests__/fog-oracle.unit.test.ts +110 -114
- package/src/__tests__/fog-orthographic-ray.unit.test.ts +12 -37
- package/src/__tests__/fog-producer-matrix.integration.test.ts +16 -51
- package/src/__tests__/lighting-point-spot-volume.unit.test.ts +48 -0
- package/src/__tests__/lighting-spot-volume-attenuation.unit.test.ts +26 -0
- package/src/__tests__/lighting-spot-volume.unit.test.ts +25 -0
- package/src/__tests__/shader.unit.test.ts +1 -0
- package/src/__tests__/sprite-variants.unit.test.ts +1 -0
- package/src/common.wgsl +30 -61
- package/src/default-standard-pbr-skin.wgsl +2 -19
- package/src/default-standard-pbr.wgsl +50 -31
- package/src/hdrp-cluster-forward.wgsl +41 -3
- package/src/hdrp-deferred-lighting.wgsl +3 -27
- package/src/lighting-attenuation.wgsl +58 -0
- package/src/lighting-directional.wgsl +31 -3
- package/src/lighting-punctual.wgsl +57 -12
- package/src/msdf-text.wgsl +4 -23
- package/src/shadow-pcf.wgsl +31 -10
- package/src/skybox.wgsl +2 -7
- package/src/sprite-lit.wgsl +3 -22
- package/src/sprite.wgsl +3 -22
- package/src/unlit.wgsl +2 -24
- package/src/volume/volume-composite.wgsl +113 -0
- package/src/volume/volume-inject.wgsl +249 -0
- package/src/volume/volume-integrate.wgsl +342 -0
- package/src/volume/volume-temporal.wgsl +70 -0
|
@@ -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
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
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('
|
|
56
|
-
const
|
|
57
|
-
expect(
|
|
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
|
+
});
|
|
@@ -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
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
//
|
|
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
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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,
|
|
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
|
|
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,
|
|
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.
|
|
791
|
-
//
|
|
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,
|
|
798
|
-
view.spotLightViewProj[s.shadowAtlasTile], s.shadowAtlasTile,
|
|
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,
|
|
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
|
|
846
|
+
return vec4<f32>(color, alpha);
|
|
828
847
|
}
|
|
829
848
|
|
|
830
849
|
// ── G-buffer output struct (feat-20260612-hdrp-deferred-shading M2 / w12) ──
|