@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.
- package/README.md +10 -0
- package/dist/.tsbuildinfo +1 -1
- package/dist/ShaderRegistry.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +52 -1
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -4
- package/src/ShaderRegistry.ts +65 -0
- package/src/__tests__/bloom-fxaa-tonemap.unit.test.ts +30 -4
- package/src/__tests__/default-standard-pbr-alpha.unit.test.ts +7 -0
- package/src/__tests__/default-standard-pbr-transmission.unit.test.ts +71 -22
- 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 +56 -0
- package/src/__tests__/lighting-punctual.unit.test.ts +32 -6
- 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__/material-derived-builtins.integration.test.ts +27 -0
- package/src/__tests__/reflection-probe-sampling.unit.test.ts +23 -0
- package/src/__tests__/scene-temporal.unit.test.ts +20 -0
- package/src/__tests__/shader.unit.test.ts +35 -13
- package/src/__tests__/shadow-pcss.unit.test.ts +131 -0
- package/src/__tests__/sprite-lit-shader.test.ts +24 -11
- package/src/__tests__/sprite-variants.unit.test.ts +2 -0
- package/src/__tests__/transmission-thickness-scale.unit.test.ts +7 -1
- package/src/__tests__/transparent-pbr.unit.test.ts +7 -0
- package/src/common.wgsl +72 -73
- package/src/default-standard-pbr-skin.wgsl +31 -89
- package/src/default-standard-pbr.wgsl +94 -146
- package/src/fxaa.wgsl +27 -6
- package/src/hdrp-deferred-lighting.wgsl +3 -27
- package/src/ibl-sampling.wgsl +54 -0
- package/src/index.ts +6 -0
- package/src/lighting-attenuation.wgsl +58 -0
- package/src/lighting-directional.wgsl +188 -21
- package/src/lighting-punctual.wgsl +94 -19
- package/src/msdf-text.wgsl +4 -23
- package/src/scene-temporal.wgsl +14 -8
- package/src/shadow-pcf.wgsl +75 -11
- package/src/skybox.wgsl +2 -7
- package/src/sprite-lit.wgsl +38 -73
- package/src/sprite.wgsl +3 -22
- package/src/{hdrp-cluster-forward.wgsl → standard-cluster.wgsl} +74 -23
- package/src/tonemap.wgsl +8 -3
- package/src/unlit.wgsl +2 -24
- package/src/volume/volume-composite.wgsl +113 -0
- package/src/volume/volume-inject.wgsl +261 -0
- package/src/volume/volume-integrate.wgsl +337 -0
- package/src/volume/volume-temporal.wgsl +70 -0
|
@@ -37,28 +37,77 @@ describe('default Standard PBR transmission manifest contract', () => {
|
|
|
37
37
|
const transmissionVariants = standard.variants.filter(
|
|
38
38
|
(variant) => 'TRANSMISSION_AVAILABLE' in variant.defines,
|
|
39
39
|
);
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
40
|
+
// Cluster-forward + storage-fallback is not a valid ABI pair: the
|
|
41
|
+
// clustered Standard group(2) requires storage buffers. The producer
|
|
42
|
+
// emits the six-axis Cartesian product minus those sixteen impossible
|
|
43
|
+
// combinations, i.e. 48 exact variants.
|
|
44
|
+
expect(transmissionVariants).toHaveLength(48);
|
|
45
|
+
expect(new Set(transmissionVariants.map((variant) => variant.definesKey)).size).toBe(48);
|
|
46
|
+
// Projector sampling is owned by the shared Cluster module. The
|
|
47
|
+
// non-cluster capability rows intentionally have no projector consumer
|
|
48
|
+
// after the direct-light/fallback cut, so their true/false projector keys
|
|
49
|
+
// alias one source instead of carrying duplicate WGSL. The 16 non-cluster
|
|
50
|
+
// pairs therefore collapse 48 logical keys to 32 source identities.
|
|
51
|
+
expect(new Set(transmissionVariants.map((variant) => variant.composedWgsl)).size).toBe(32);
|
|
52
|
+
const nonClusterPairs = transmissionVariants.filter(
|
|
53
|
+
(variant) => variant.defines.CLUSTER_FORWARD_AVAILABLE === false,
|
|
54
|
+
);
|
|
55
|
+
for (const variant of nonClusterPairs) {
|
|
56
|
+
const projectorTwin = nonClusterPairs.find(
|
|
57
|
+
(candidate) =>
|
|
58
|
+
candidate.defines.STORAGE_BUFFER_AVAILABLE === variant.defines.STORAGE_BUFFER_AVAILABLE &&
|
|
59
|
+
candidate.defines.TRANSMISSION_AVAILABLE === variant.defines.TRANSMISSION_AVAILABLE &&
|
|
60
|
+
candidate.defines.VERTEX_COLOR_AVAILABLE === variant.defines.VERTEX_COLOR_AVAILABLE &&
|
|
61
|
+
candidate.defines.DIRECTIONAL_PCSS_AVAILABLE ===
|
|
62
|
+
variant.defines.DIRECTIONAL_PCSS_AVAILABLE &&
|
|
63
|
+
candidate.defines.PROJECTOR_AVAILABLE !== variant.defines.PROJECTOR_AVAILABLE,
|
|
64
|
+
);
|
|
65
|
+
expect(projectorTwin?.composedWgsl).toBe(variant.composedWgsl);
|
|
66
|
+
}
|
|
67
|
+
expect(
|
|
68
|
+
transmissionVariants.every(
|
|
69
|
+
(variant) =>
|
|
70
|
+
!(
|
|
71
|
+
variant.defines.CLUSTER_FORWARD_AVAILABLE === true &&
|
|
72
|
+
variant.defines.STORAGE_BUFFER_AVAILABLE === false
|
|
73
|
+
),
|
|
74
|
+
),
|
|
75
|
+
).toBe(true);
|
|
76
|
+
for (const clusterForwardAvailable of [false, true]) {
|
|
77
|
+
for (const storageBufferAvailable of [false, true]) {
|
|
78
|
+
if (clusterForwardAvailable && !storageBufferAvailable) continue;
|
|
79
|
+
for (const vertexColorAvailable of [false, true]) {
|
|
80
|
+
for (const directionalPcssAvailable of [false, true]) {
|
|
81
|
+
for (const transmissionAvailable of [false, true]) {
|
|
82
|
+
for (const projectorAvailable of [false, true]) {
|
|
83
|
+
const expectedKeyEntries = [
|
|
84
|
+
`CLUSTER_FORWARD_AVAILABLE=${clusterForwardAvailable}`,
|
|
85
|
+
`DIRECTIONAL_PCSS_AVAILABLE=${directionalPcssAvailable}`,
|
|
86
|
+
`PROJECTOR_AVAILABLE=${projectorAvailable}`,
|
|
87
|
+
`STORAGE_BUFFER_AVAILABLE=${storageBufferAvailable}`,
|
|
88
|
+
`TRANSMISSION_AVAILABLE=${transmissionAvailable}`,
|
|
89
|
+
`VERTEX_COLOR_AVAILABLE=${vertexColorAvailable}`,
|
|
90
|
+
];
|
|
91
|
+
const expectedKey = expectedKeyEntries.every((entry) => entry.endsWith('=true'))
|
|
92
|
+
? ''
|
|
93
|
+
: expectedKeyEntries.join('+');
|
|
94
|
+
expect(transmissionVariants).toContainEqual(
|
|
95
|
+
expect.objectContaining({
|
|
96
|
+
definesKey: expectedKey,
|
|
97
|
+
defines: expect.objectContaining({
|
|
98
|
+
CLUSTER_FORWARD_AVAILABLE: clusterForwardAvailable,
|
|
99
|
+
DIRECTIONAL_PCSS_AVAILABLE: directionalPcssAvailable,
|
|
100
|
+
PROJECTOR_AVAILABLE: projectorAvailable,
|
|
101
|
+
STORAGE_BUFFER_AVAILABLE: storageBufferAvailable,
|
|
102
|
+
TRANSMISSION_AVAILABLE: transmissionAvailable,
|
|
103
|
+
VERTEX_COLOR_AVAILABLE: vertexColorAvailable,
|
|
104
|
+
}),
|
|
105
|
+
}),
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
62
111
|
}
|
|
63
112
|
}
|
|
64
113
|
});
|
|
@@ -2,131 +2,127 @@ import { readFileSync } from 'node:fs';
|
|
|
2
2
|
import { fileURLToPath } from 'node:url';
|
|
3
3
|
import { describe, expect, it } from 'vitest';
|
|
4
4
|
|
|
5
|
-
const
|
|
6
|
-
|
|
5
|
+
const integrateWgsl = readFileSync(
|
|
6
|
+
fileURLToPath(new URL('../volume/volume-integrate.wgsl', import.meta.url)),
|
|
7
|
+
'utf8',
|
|
8
|
+
);
|
|
9
|
+
const compositeWgsl = readFileSync(
|
|
10
|
+
fileURLToPath(new URL('../volume/volume-composite.wgsl', import.meta.url)),
|
|
11
|
+
'utf8',
|
|
12
|
+
);
|
|
13
|
+
const temporalWgsl = readFileSync(
|
|
14
|
+
fileURLToPath(new URL('../volume/volume-temporal.wgsl', import.meta.url)),
|
|
15
|
+
'utf8',
|
|
16
|
+
);
|
|
17
|
+
const injectWgsl = readFileSync(
|
|
18
|
+
fileURLToPath(new URL('../volume/volume-inject.wgsl', import.meta.url)),
|
|
19
|
+
'utf8',
|
|
20
|
+
);
|
|
7
21
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
readonly maxOpacity: number;
|
|
16
|
-
readonly expectedOpacity: number;
|
|
22
|
+
function hash32(value: number): number {
|
|
23
|
+
let hash = value >>> 0;
|
|
24
|
+
hash = (hash ^ 61 ^ (hash >>> 16)) >>> 0;
|
|
25
|
+
hash = (hash + (hash << 3)) >>> 0;
|
|
26
|
+
hash = (hash ^ (hash >>> 4)) >>> 0;
|
|
27
|
+
hash = Math.imul(hash, 668265261) >>> 0;
|
|
28
|
+
return (hash ^ (hash >>> 15)) >>> 0;
|
|
17
29
|
}
|
|
18
30
|
|
|
19
|
-
function
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
if (Math.abs(q) < 1e-6) return rho0 * input.distance;
|
|
23
|
-
return (rho0 * (1 - Math.exp(-q * input.distance))) / q;
|
|
31
|
+
function stratified32(seed: number, frame: number, stride: number): number {
|
|
32
|
+
const bin = (hash32(seed) + (frame & 31) * stride) & 31;
|
|
33
|
+
return (bin + 0.5) / 32;
|
|
24
34
|
}
|
|
25
35
|
|
|
26
|
-
function
|
|
27
|
-
return
|
|
36
|
+
function ditheredByte(value: number, noise: number): number {
|
|
37
|
+
return Math.round(Math.min(1, Math.max(0, value + (noise - 0.5) / 255)) * 255);
|
|
28
38
|
}
|
|
29
39
|
|
|
30
|
-
|
|
31
|
-
{
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
expectedOpacity: 0.6321205588,
|
|
40
|
-
},
|
|
41
|
-
{
|
|
42
|
-
name: 'horizontal-height',
|
|
43
|
-
originY: 2,
|
|
44
|
-
directionY: 0,
|
|
45
|
-
distance: 5,
|
|
46
|
-
density: 0.2,
|
|
47
|
-
heightFalloff: 0.5,
|
|
48
|
-
maxOpacity: 1,
|
|
49
|
-
expectedOpacity: 0.3077993724,
|
|
50
|
-
},
|
|
51
|
-
{
|
|
52
|
-
name: 'upward-height',
|
|
53
|
-
originY: 0,
|
|
54
|
-
directionY: 1,
|
|
55
|
-
distance: 4,
|
|
56
|
-
density: 0.2,
|
|
57
|
-
heightFalloff: 0.5,
|
|
58
|
-
maxOpacity: 1,
|
|
59
|
-
expectedOpacity: 0.2923926197,
|
|
60
|
-
},
|
|
61
|
-
{
|
|
62
|
-
name: 'downward-height',
|
|
63
|
-
originY: 0,
|
|
64
|
-
directionY: -1,
|
|
65
|
-
distance: 4,
|
|
66
|
-
density: 0.2,
|
|
67
|
-
heightFalloff: 0.5,
|
|
68
|
-
maxOpacity: 1,
|
|
69
|
-
expectedOpacity: 0.9223561116,
|
|
70
|
-
},
|
|
71
|
-
{
|
|
72
|
-
name: 'small-q-limit',
|
|
73
|
-
originY: 1.5,
|
|
74
|
-
directionY: 0.5,
|
|
75
|
-
distance: 7,
|
|
76
|
-
density: 0.03,
|
|
77
|
-
heightFalloff: 1e-12,
|
|
78
|
-
maxOpacity: 1,
|
|
79
|
-
expectedOpacity: 0.189415754,
|
|
80
|
-
},
|
|
81
|
-
{
|
|
82
|
-
name: 'max-opacity',
|
|
83
|
-
originY: 0,
|
|
84
|
-
directionY: 0,
|
|
85
|
-
distance: 10,
|
|
86
|
-
density: 0.5,
|
|
87
|
-
heightFalloff: 0,
|
|
88
|
-
maxOpacity: 0.25,
|
|
89
|
-
expectedOpacity: 0.2483155133,
|
|
90
|
-
},
|
|
91
|
-
];
|
|
40
|
+
describe('Volumetric optics shader ownership', () => {
|
|
41
|
+
it('uses filterable history with exact reprojection and deterministic source jitter', () => {
|
|
42
|
+
expect(temporalWgsl).toContain('var accepted : texture_2d<f32>');
|
|
43
|
+
expect(temporalWgsl).toContain('fn bilinear');
|
|
44
|
+
expect(temporalWgsl).toContain('textureLoad(accepted');
|
|
45
|
+
expect(temporalWgsl).toContain('0.875');
|
|
46
|
+
expect(temporalWgsl).not.toContain('temporal_jitter');
|
|
47
|
+
expect(temporalWgsl).toContain('let prior = bilinear(previous_uv, size);');
|
|
48
|
+
});
|
|
92
49
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
50
|
+
it('weights equal-alpha neighborhood samples by relative radiance', () => {
|
|
51
|
+
expect(compositeWgsl).toContain('radiance_luma_weight');
|
|
52
|
+
expect(compositeWgsl).toContain('relative_luma');
|
|
53
|
+
const center = 1;
|
|
54
|
+
const darkNeighbor = 0.02;
|
|
55
|
+
const equalAlpha = 0.5;
|
|
56
|
+
const relativeLuma = Math.abs(darkNeighbor - center) / Math.max(center, darkNeighbor, 1e-4);
|
|
57
|
+
const weight = Math.exp(-relativeLuma * 8) * Math.exp(-Math.abs(equalAlpha - equalAlpha) * 24);
|
|
58
|
+
expect(weight).toBeLessThan(0.01);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('keeps optical depth and phase in the volume integration module', () => {
|
|
62
|
+
expect(integrateWgsl).toContain('#define_import_path forgeax_view::volume_integrate');
|
|
63
|
+
expect(integrateWgsl).toContain('fn hg');
|
|
64
|
+
expect(integrateWgsl).toContain('FOUR_PI');
|
|
65
|
+
expect(integrateWgsl).toContain('textureStore(resolved');
|
|
66
|
+
expect(integrateWgsl).not.toContain('apply_fog');
|
|
67
|
+
expect(integrateWgsl).toContain('full_step');
|
|
68
|
+
expect(integrateWgsl).toContain('let segment_start');
|
|
69
|
+
expect(integrateWgsl).toContain('let segment_length');
|
|
70
|
+
expect(integrateWgsl).toContain('sigma_scale * density_value * segment_length');
|
|
71
|
+
expect(integrateWgsl).toContain('scene_distance');
|
|
72
|
+
expect(injectWgsl).not.toContain('scene_depth');
|
|
73
|
+
expect(injectWgsl).not.toContain('textureStore(volume_history');
|
|
74
|
+
expect(injectWgsl).not.toContain('textureStore(volume_temporal');
|
|
109
75
|
});
|
|
110
76
|
|
|
111
|
-
it('
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
const mixed = scene.map((value, index) =>
|
|
116
|
-
index === 3 ? value : (1 - opacity) * value + opacity * (fog[index] ?? 0),
|
|
117
|
-
);
|
|
118
|
-
expect(mixed).toEqual([1.625, 3.375, 6.75, 0.37]);
|
|
77
|
+
it('composites the prepared integrated volume exactly once', () => {
|
|
78
|
+
expect(compositeWgsl).toContain('#define_import_path forgeax_view::volume_composite');
|
|
79
|
+
expect(compositeWgsl).toContain('resolved_volume');
|
|
80
|
+
expect(compositeWgsl).not.toContain('apply_fog');
|
|
119
81
|
});
|
|
120
82
|
|
|
121
|
-
it('
|
|
122
|
-
expect(
|
|
123
|
-
expect(
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
expect(
|
|
128
|
-
expect(
|
|
129
|
-
expect(
|
|
130
|
-
|
|
83
|
+
it('maps the fullscreen composite UV into the view-space Y convention', () => {
|
|
84
|
+
expect(compositeWgsl).toContain('positions[index].x * 0.5 + 0.5');
|
|
85
|
+
expect(compositeWgsl).toContain('0.5 - positions[index].y * 0.5');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('selects volume cascades from the uploaded camera view-depth contract', () => {
|
|
89
|
+
expect(injectWgsl).toContain('view.temporalCurrentViewProj');
|
|
90
|
+
expect(injectWgsl).toContain('view.temporalProjection');
|
|
91
|
+
expect(injectWgsl).not.toContain('distance(world_position, view.cameraPos)');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('uses bounded 32-phase stratification and unbiased unorm dithering', () => {
|
|
95
|
+
expect(injectWgsl).toContain('fn hash32');
|
|
96
|
+
expect(injectWgsl).toContain('fn stratified32');
|
|
97
|
+
expect(injectWgsl).toContain('fn dither_unorm8');
|
|
98
|
+
expect(injectWgsl).not.toContain('interleaved_gradient_noise');
|
|
99
|
+
expect(injectWgsl).toContain('frame_index & 31u');
|
|
100
|
+
|
|
101
|
+
const seeds = Array.from({ length: 4096 }, (_, index) => hash32(index + 17));
|
|
102
|
+
for (const value of [0.1, 0.25, 0.5, 0.9]) {
|
|
103
|
+
for (let frame = 0; frame < 32; frame += 1) {
|
|
104
|
+
const mean =
|
|
105
|
+
seeds.reduce(
|
|
106
|
+
(sum, seed) => sum + ditheredByte(value, stratified32(seed, frame, 5)) / 255,
|
|
107
|
+
0,
|
|
108
|
+
) / seeds.length;
|
|
109
|
+
expect(Math.abs(mean - value)).toBeLessThanOrEqual(1 / (32 * 255));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
for (let frame = 0; frame < 32; frame += 1) {
|
|
114
|
+
expect(seeds.every((seed) => ditheredByte(0, stratified32(seed, frame, 5)) === 0)).toBe(true);
|
|
115
|
+
expect(seeds.every((seed) => ditheredByte(1, stratified32(seed, frame, 5)) === 255)).toBe(
|
|
116
|
+
true,
|
|
117
|
+
);
|
|
118
|
+
expect(
|
|
119
|
+
seeds.every((seed) => ditheredByte(96 / 255, stratified32(seed, frame, 5)) === 96),
|
|
120
|
+
).toBe(true);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const changed = seeds.filter(
|
|
124
|
+
(seed) => stratified32(seed, 0, 5) !== stratified32(seed, 1, 5),
|
|
125
|
+
).length;
|
|
126
|
+
expect(changed / seeds.length).toBeGreaterThanOrEqual(0.5);
|
|
131
127
|
});
|
|
132
128
|
});
|
|
@@ -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,56 @@
|
|
|
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 volume View aligned with the directional filter carrier', () => {
|
|
32
|
+
expect(inject).toContain('directionalShadowFilter : vec4<f32>');
|
|
33
|
+
expect(inject).toContain('normalBias : f32,\n directionalShadowFilter');
|
|
34
|
+
expect(inject).not.toContain('view.pcfKernelSize');
|
|
35
|
+
expect(inject).toContain('filter_profile >= 4u');
|
|
36
|
+
expect(inject).toContain('stable PCF3 receiver');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('keeps the punctual volume path finite and monotonic at range boundaries', () => {
|
|
40
|
+
expect(punctual).toMatch(/max\([^\n]*1e-4/);
|
|
41
|
+
expect(attenuation).toMatch(/clamp\(1\.0 - \(safeDistance \* invRangeSquared\)/);
|
|
42
|
+
expect(attenuation).toContain('return distance * cone');
|
|
43
|
+
expect(integrate).toContain('isFinite');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('uses the pinned Three density grain and smoke amount mapping', () => {
|
|
47
|
+
expect(integrate).toContain('fn volume_sample_grain(');
|
|
48
|
+
expect(integrate).toContain('fract((position + time_scaled * time_scale) * scale)');
|
|
49
|
+
expect(integrate).toContain('volume_sample_grain(position, 0.1, time_scaled, 1.0)');
|
|
50
|
+
expect(integrate).toContain('volume_sample_grain(position, 0.05, time_scaled, 1.0)');
|
|
51
|
+
expect(integrate).toContain('volume_sample_grain(position, 0.02, time_scaled, 2.0)');
|
|
52
|
+
expect(integrate).toContain('return 2.0 * grain - 1.0;');
|
|
53
|
+
expect(integrate).toContain('volume_scattering_density(world_position, frame_index)');
|
|
54
|
+
expect(integrate).not.toContain('density_uv');
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -4,11 +4,15 @@ import { fileURLToPath } from 'node:url';
|
|
|
4
4
|
import { describe, expect, it } from 'vitest';
|
|
5
5
|
|
|
6
6
|
const shaderPath = join(dirname(fileURLToPath(import.meta.url)), '../lighting-punctual.wgsl');
|
|
7
|
-
const
|
|
7
|
+
const standardClusterShaderPath = join(
|
|
8
8
|
dirname(fileURLToPath(import.meta.url)),
|
|
9
|
-
'../
|
|
9
|
+
'../standard-cluster.wgsl',
|
|
10
10
|
);
|
|
11
11
|
const pbrShaderPath = join(dirname(fileURLToPath(import.meta.url)), '../default-standard-pbr.wgsl');
|
|
12
|
+
const directionalShaderPath = join(
|
|
13
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
14
|
+
'../lighting-directional.wgsl',
|
|
15
|
+
);
|
|
12
16
|
|
|
13
17
|
describe('direct punctual lighting shader contract', () => {
|
|
14
18
|
it('exposes independent point and spot paths with explicit range and cone inputs', async () => {
|
|
@@ -16,6 +20,8 @@ describe('direct punctual lighting shader contract', () => {
|
|
|
16
20
|
|
|
17
21
|
expect(source).toContain('fn evalPoint(');
|
|
18
22
|
expect(source).toContain('fn evalSpot(');
|
|
23
|
+
expect(source).toContain('fn evalPointFlat(');
|
|
24
|
+
expect(source).toContain('fn evalSpotFlat(');
|
|
19
25
|
expect(source).toContain('invRangeSquared');
|
|
20
26
|
expect(source).toContain('cosInner');
|
|
21
27
|
expect(source).toContain('cosOuter');
|
|
@@ -37,7 +43,7 @@ describe('direct punctual lighting shader contract', () => {
|
|
|
37
43
|
});
|
|
38
44
|
|
|
39
45
|
it('keeps cluster punctual evaluation in the shared lighting owner', async () => {
|
|
40
|
-
const source = await readFile(
|
|
46
|
+
const source = await readFile(standardClusterShaderPath, 'utf8');
|
|
41
47
|
|
|
42
48
|
expect(source).toContain('kind_and_shadow');
|
|
43
49
|
expect(source).toContain('evalPoint');
|
|
@@ -49,18 +55,38 @@ describe('direct punctual lighting shader contract', () => {
|
|
|
49
55
|
});
|
|
50
56
|
|
|
51
57
|
it('decodes HDRP spot cone lanes in the shared evaluator order', async () => {
|
|
52
|
-
const source = await readFile(
|
|
58
|
+
const source = await readFile(standardClusterShaderPath, 'utf8');
|
|
53
59
|
|
|
54
60
|
expect(source).toContain('light.color.w, light.direction.w, light.position.w');
|
|
55
61
|
expect(source).not.toContain('light.direction.w, light.color.w, light.position.w');
|
|
56
62
|
});
|
|
57
63
|
|
|
64
|
+
it('keeps the unshadowed spot sentinel out of the projector flag branch', async () => {
|
|
65
|
+
const source = await readFile(standardClusterShaderPath, 'utf8');
|
|
66
|
+
|
|
67
|
+
expect(source).toContain('if (encoded_tile >= 0 && (encoded_tile & PROJECTOR_FLAG) != 0)');
|
|
68
|
+
});
|
|
69
|
+
|
|
58
70
|
it('passes precomputed base and clearcoat roughness/F0 facts to cluster lights', async () => {
|
|
59
71
|
const source = await readFile(pbrShaderPath, 'utf8');
|
|
60
72
|
|
|
61
|
-
expect(source).
|
|
62
|
-
|
|
73
|
+
expect(source).toMatch(
|
|
74
|
+
/evaluateStandardClusterLights\(\s*in\.ndc\.xyz,\s*in\.viewZ,\s*in\.worldPos,\s*n,\s*v,\s*diffuseAlbedo,\s*metallic,\s*a,\s*f0,\s*false,?\s*\)/u,
|
|
63
75
|
);
|
|
64
76
|
expect(source).toContain('coatAlpha, vec3<f32>(0.04)');
|
|
65
77
|
});
|
|
78
|
+
|
|
79
|
+
it('requires one shared Directional PCSS orchestration owner', async () => {
|
|
80
|
+
const source = await readFile(directionalShaderPath, 'utf8');
|
|
81
|
+
|
|
82
|
+
expect(source).toContain('PCSS_MEDIUM_RAW_TAPS');
|
|
83
|
+
expect(source).toContain('PCSS_MEDIUM_COMPARE_TAPS');
|
|
84
|
+
expect(source).toContain('PCSS_HIGH_RAW_TAPS');
|
|
85
|
+
expect(source).toContain('PCSS_HIGH_COMPARE_TAPS');
|
|
86
|
+
expect(source).toContain('fn _samplePcssForCascade');
|
|
87
|
+
expect(source).toContain('if (blockerCount == 0u)');
|
|
88
|
+
expect(source).toContain('shadow_load_raw_depth');
|
|
89
|
+
expect(source).toContain('shadow_sample_compare');
|
|
90
|
+
expect(source).not.toMatch(/frameIndex|frame_index|taaJitter|taa_jitter|jitter/);
|
|
91
|
+
});
|
|
66
92
|
});
|