@forgeax/engine-shader 0.1.7 → 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/README.md +34 -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 +53 -2
- package/dist/index.mjs.map +1 -1
- package/dist/material-schemas.d.ts.map +1 -1
- package/package.json +6 -3
- package/src/ShaderRegistry.ts +50 -0
- package/src/__tests__/bloom-fxaa-tonemap.unit.test.ts +86 -12
- package/src/__tests__/default-standard-pbr-transmission.unit.test.ts +185 -0
- 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-punctual.unit.test.ts +1 -1
- 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-builtins.unit.test.ts +38 -1
- package/src/__tests__/material-contract.unit.test.ts +123 -0
- package/src/__tests__/motion-blur.unit.test.ts +44 -0
- package/src/__tests__/scene-temporal.unit.test.ts +23 -5
- package/src/__tests__/shader-manifest-transmission.unit.test.ts +75 -0
- package/src/__tests__/shader.unit.test.ts +44 -3
- package/src/__tests__/sprite-variants.unit.test.ts +20 -0
- package/src/__tests__/taa-resolve.unit.test.ts +75 -3
- package/src/__tests__/transmission-thickness-scale.unit.test.ts +19 -0
- package/src/common.wgsl +34 -65
- package/src/default-standard-pbr-skin.wgsl +29 -28
- package/src/default-standard-pbr.wgsl +257 -57
- package/src/fxaa.wgsl +5 -6
- package/src/hdrp-cluster-forward.wgsl +41 -3
- package/src/hdrp-deferred-lighting.wgsl +3 -27
- package/src/index.ts +3 -0
- package/src/lighting-attenuation.wgsl +58 -0
- package/src/lighting-directional.wgsl +31 -3
- package/src/lighting-punctual.wgsl +57 -12
- package/src/material-schemas.ts +7 -0
- package/src/motion-blur.wgsl +77 -0
- package/src/msdf-text.wgsl +4 -23
- package/src/pbr-temporal.wgsl +15 -2
- package/src/scene-temporal.wgsl +54 -0
- package/src/shadow-pcf.wgsl +31 -10
- package/src/shadow_caster.wgsl +27 -2
- package/src/skybox.wgsl +2 -7
- package/src/sprite-lit.wgsl +7 -25
- package/src/sprite.wgsl +7 -25
- package/src/taa-resolve.wgsl +76 -81
- package/src/tonemap.wgsl +7 -9
- package/src/unlit.wgsl +7 -28
- 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
- package/dist/__tests__/atmosphere-background.test.d.ts +0 -2
- package/dist/__tests__/atmosphere-background.test.d.ts.map +0 -1
- package/dist/__tests__/atmosphere-cubemap-orientation.test.d.ts +0 -2
- package/dist/__tests__/atmosphere-cubemap-orientation.test.d.ts.map +0 -1
- package/dist/__tests__/atmosphere-preetham-numeric.test.d.ts +0 -2
- package/dist/__tests__/atmosphere-preetham-numeric.test.d.ts.map +0 -1
- package/dist/__tests__/atmosphere-preetham-red.test.d.ts +0 -2
- package/dist/__tests__/atmosphere-preetham-red.test.d.ts.map +0 -1
- package/dist/__tests__/atmosphere-preetham.test.d.ts +0 -2
- package/dist/__tests__/atmosphere-preetham.test.d.ts.map +0 -1
- package/dist/__tests__/atmosphere-three-r184-reference.test.d.ts +0 -2
- package/dist/__tests__/atmosphere-three-r184-reference.test.d.ts.map +0 -1
- package/dist/__tests__/bloom-fxaa-tonemap.unit.test.d.ts +0 -2
- package/dist/__tests__/bloom-fxaa-tonemap.unit.test.d.ts.map +0 -1
- package/dist/__tests__/builtin-texture-sampling-contract.test.d.ts +0 -2
- package/dist/__tests__/builtin-texture-sampling-contract.test.d.ts.map +0 -1
- package/dist/__tests__/default-standard-pbr-alpha.unit.test.d.ts +0 -2
- package/dist/__tests__/default-standard-pbr-alpha.unit.test.d.ts.map +0 -1
- package/dist/__tests__/deferred-lighting-ssao.test.d.ts +0 -2
- package/dist/__tests__/deferred-lighting-ssao.test.d.ts.map +0 -1
- package/dist/__tests__/fog-oracle.unit.test.d.ts +0 -2
- package/dist/__tests__/fog-oracle.unit.test.d.ts.map +0 -1
- package/dist/__tests__/fog-orthographic-ray.unit.test.d.ts +0 -2
- package/dist/__tests__/fog-orthographic-ray.unit.test.d.ts.map +0 -1
- package/dist/__tests__/fog-producer-matrix.integration.test.d.ts +0 -2
- package/dist/__tests__/fog-producer-matrix.integration.test.d.ts.map +0 -1
- package/dist/__tests__/hdrp-ssao-compile.test.d.ts +0 -2
- package/dist/__tests__/hdrp-ssao-compile.test.d.ts.map +0 -1
- package/dist/__tests__/ibl-irradiance.unit.test.d.ts +0 -2
- package/dist/__tests__/ibl-irradiance.unit.test.d.ts.map +0 -1
- package/dist/__tests__/ibl-sampling.unit.test.d.ts +0 -2
- package/dist/__tests__/ibl-sampling.unit.test.d.ts.map +0 -1
- package/dist/__tests__/lighting-punctual.unit.test.d.ts +0 -2
- package/dist/__tests__/lighting-punctual.unit.test.d.ts.map +0 -1
- package/dist/__tests__/material-artifact-dedup.unit.test.d.ts +0 -2
- package/dist/__tests__/material-artifact-dedup.unit.test.d.ts.map +0 -1
- package/dist/__tests__/material-builtins.unit.test.d.ts +0 -2
- package/dist/__tests__/material-builtins.unit.test.d.ts.map +0 -1
- package/dist/__tests__/material-contract.unit.test.d.ts +0 -2
- package/dist/__tests__/material-contract.unit.test.d.ts.map +0 -1
- package/dist/__tests__/material-derived-builtins.integration.test.d.ts +0 -2
- package/dist/__tests__/material-derived-builtins.integration.test.d.ts.map +0 -1
- package/dist/__tests__/points-lines-shader.unit.test.d.ts +0 -2
- package/dist/__tests__/points-lines-shader.unit.test.d.ts.map +0 -1
- package/dist/__tests__/public-surface.unit.test.d.ts +0 -2
- package/dist/__tests__/public-surface.unit.test.d.ts.map +0 -1
- package/dist/__tests__/scene-temporal.unit.test.d.ts +0 -2
- package/dist/__tests__/scene-temporal.unit.test.d.ts.map +0 -1
- package/dist/__tests__/shader-registry-transaction.unit.test.d.ts +0 -2
- package/dist/__tests__/shader-registry-transaction.unit.test.d.ts.map +0 -1
- package/dist/__tests__/shader.unit.test.d.ts +0 -2
- package/dist/__tests__/shader.unit.test.d.ts.map +0 -1
- package/dist/__tests__/sprite-lit-shader.test.d.ts +0 -2
- package/dist/__tests__/sprite-lit-shader.test.d.ts.map +0 -1
- package/dist/__tests__/sprite-variants.unit.test.d.ts +0 -2
- package/dist/__tests__/sprite-variants.unit.test.d.ts.map +0 -1
- package/dist/__tests__/taa-resolve.unit.test.d.ts +0 -2
- package/dist/__tests__/taa-resolve.unit.test.d.ts.map +0 -1
- package/dist/__tests__/transparent-pbr.unit.test.d.ts +0 -2
- package/dist/__tests__/transparent-pbr.unit.test.d.ts.map +0 -1
- package/dist/__tests__/vertex-color-variant.unit.test.d.ts +0 -2
- package/dist/__tests__/vertex-color-variant.unit.test.d.ts.map +0 -1
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { ok, type ShaderModule } from '@forgeax/engine-rhi';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { findVariantByKey, ShaderRegistry, type ShaderRegistryDevice } from '../index.js';
|
|
4
|
+
|
|
5
|
+
type EngineShaderManifest = Awaited<
|
|
6
|
+
ReturnType<typeof import('@forgeax/engine-vite-plugin-shader').buildEngineShaderManifest>
|
|
7
|
+
>;
|
|
8
|
+
|
|
9
|
+
function dataUrl(payload: unknown): string {
|
|
10
|
+
return `data:application/json,${encodeURIComponent(JSON.stringify(payload))}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function device(): ShaderRegistryDevice {
|
|
14
|
+
return {
|
|
15
|
+
createShaderModule(): ReturnType<ShaderRegistryDevice['createShaderModule']> {
|
|
16
|
+
return ok({} as ShaderModule);
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function engineManifest(): Promise<EngineShaderManifest> {
|
|
22
|
+
const { buildEngineShaderManifest } = await import('@forgeax/engine-vite-plugin-shader');
|
|
23
|
+
return buildEngineShaderManifest();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('default Standard PBR transmission manifest contract', () => {
|
|
27
|
+
it('publishes both TRANSMISSION_AVAILABLE variants from the cooked producer', {
|
|
28
|
+
timeout: 20_000,
|
|
29
|
+
}, async () => {
|
|
30
|
+
const manifest = await engineManifest();
|
|
31
|
+
const standard = manifest.materialShaders.find(
|
|
32
|
+
(entry) => entry.identifier === 'forgeax::default-standard-pbr',
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
expect(standard).toBeDefined();
|
|
36
|
+
if (standard === undefined) return;
|
|
37
|
+
const transmissionVariants = standard.variants.filter(
|
|
38
|
+
(variant) => 'TRANSMISSION_AVAILABLE' in variant.defines,
|
|
39
|
+
);
|
|
40
|
+
expect(transmissionVariants).toHaveLength(32);
|
|
41
|
+
expect(new Set(transmissionVariants.map((variant) => variant.definesKey)).size).toBe(32);
|
|
42
|
+
expect(new Set(transmissionVariants.map((variant) => variant.composedWgsl)).size).toBe(32);
|
|
43
|
+
for (const storageBufferAvailable of [false, true]) {
|
|
44
|
+
for (const transmissionAvailable of [false, true]) {
|
|
45
|
+
for (const projectorAvailable of [false, true]) {
|
|
46
|
+
const expectedKey = [
|
|
47
|
+
'CLUSTER_FORWARD_AVAILABLE=false',
|
|
48
|
+
`PROJECTOR_AVAILABLE=${projectorAvailable}`,
|
|
49
|
+
`STORAGE_BUFFER_AVAILABLE=${storageBufferAvailable}`,
|
|
50
|
+
`TRANSMISSION_AVAILABLE=${transmissionAvailable}`,
|
|
51
|
+
'VERTEX_COLOR_AVAILABLE=false',
|
|
52
|
+
].join('+');
|
|
53
|
+
expect(transmissionVariants).toContainEqual(
|
|
54
|
+
expect.objectContaining({
|
|
55
|
+
definesKey: expectedKey,
|
|
56
|
+
defines: expect.objectContaining({
|
|
57
|
+
CLUSTER_FORWARD_AVAILABLE: false,
|
|
58
|
+
PROJECTOR_AVAILABLE: projectorAvailable,
|
|
59
|
+
STORAGE_BUFFER_AVAILABLE: storageBufferAvailable,
|
|
60
|
+
TRANSMISSION_AVAILABLE: transmissionAvailable,
|
|
61
|
+
VERTEX_COLOR_AVAILABLE: false,
|
|
62
|
+
}),
|
|
63
|
+
}),
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('omits transmission-only texture bindings from the fallback variant', {
|
|
71
|
+
timeout: 20_000,
|
|
72
|
+
}, async () => {
|
|
73
|
+
const manifest = await engineManifest();
|
|
74
|
+
const standard = manifest.materialShaders.find(
|
|
75
|
+
(entry) => entry.identifier === 'forgeax::default-standard-pbr',
|
|
76
|
+
);
|
|
77
|
+
expect(standard).toBeDefined();
|
|
78
|
+
if (standard === undefined) return;
|
|
79
|
+
|
|
80
|
+
const fallback = standard.variants.find(
|
|
81
|
+
(variant) =>
|
|
82
|
+
variant.defines.CLUSTER_FORWARD_AVAILABLE === false &&
|
|
83
|
+
variant.defines.STORAGE_BUFFER_AVAILABLE === true &&
|
|
84
|
+
variant.defines.TRANSMISSION_AVAILABLE === false &&
|
|
85
|
+
variant.defines.VERTEX_COLOR_AVAILABLE === false,
|
|
86
|
+
);
|
|
87
|
+
const transmission = standard.variants.find(
|
|
88
|
+
(variant) =>
|
|
89
|
+
variant.defines.CLUSTER_FORWARD_AVAILABLE === false &&
|
|
90
|
+
variant.defines.STORAGE_BUFFER_AVAILABLE === true &&
|
|
91
|
+
variant.defines.TRANSMISSION_AVAILABLE === true &&
|
|
92
|
+
variant.defines.VERTEX_COLOR_AVAILABLE === false,
|
|
93
|
+
);
|
|
94
|
+
expect(fallback?.composedWgsl).not.toContain('@binding(13)');
|
|
95
|
+
expect(fallback?.composedWgsl).not.toContain('@binding(24)');
|
|
96
|
+
expect(transmission?.composedWgsl).toContain('@binding(13)');
|
|
97
|
+
expect(transmission?.composedWgsl).toContain('@binding(24)');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('registers one Standard row and resolves a declared variant by exact key', {
|
|
101
|
+
timeout: 20_000,
|
|
102
|
+
}, async () => {
|
|
103
|
+
const manifest = await engineManifest();
|
|
104
|
+
const standard = manifest.materialShaders.find(
|
|
105
|
+
(entry) => entry.identifier === 'forgeax::default-standard-pbr',
|
|
106
|
+
);
|
|
107
|
+
expect(standard).toBeDefined();
|
|
108
|
+
if (standard === undefined) return;
|
|
109
|
+
|
|
110
|
+
const registry = new ShaderRegistry({
|
|
111
|
+
device: device(),
|
|
112
|
+
manifestUrl: dataUrl({ entries: [], materialShaders: [standard] }),
|
|
113
|
+
});
|
|
114
|
+
const loaded = await registry.loadManifest();
|
|
115
|
+
expect(loaded.ok).toBe(true);
|
|
116
|
+
const rows = Array.from(registry.materialShaderManifestEntries()).filter(
|
|
117
|
+
(entry) => entry.identifier === 'forgeax::default-standard-pbr',
|
|
118
|
+
);
|
|
119
|
+
expect(rows).toHaveLength(1);
|
|
120
|
+
const registered = rows[0];
|
|
121
|
+
expect(registered).toBeDefined();
|
|
122
|
+
if (registered === undefined) return;
|
|
123
|
+
|
|
124
|
+
const transmission = registered.variants.find(
|
|
125
|
+
(variant) => variant.defines.TRANSMISSION_AVAILABLE === true,
|
|
126
|
+
);
|
|
127
|
+
expect(transmission).toBeDefined();
|
|
128
|
+
if (transmission === undefined) return;
|
|
129
|
+
expect(findVariantByKey(registered, transmission.definesKey)).toBe(transmission);
|
|
130
|
+
expect(
|
|
131
|
+
findVariantByKey(registered, `${transmission.definesKey}+UNDECLARED=true`),
|
|
132
|
+
).toBeUndefined();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('rejects a manifest with a missing transmission variant before prewarm', async () => {
|
|
136
|
+
const missingVariant = {
|
|
137
|
+
identifier: 'forgeax::default-standard-pbr',
|
|
138
|
+
sourcePath: 'default-standard-pbr.wgsl',
|
|
139
|
+
composedWgsl: 'standard-default',
|
|
140
|
+
paramSchema: '[]',
|
|
141
|
+
variants: [
|
|
142
|
+
{
|
|
143
|
+
definesKey: 'TRANSMISSION_AVAILABLE=false',
|
|
144
|
+
defines: { TRANSMISSION_AVAILABLE: false },
|
|
145
|
+
composedWgsl: 'standard-no-transmission',
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
};
|
|
149
|
+
const registry = new ShaderRegistry({
|
|
150
|
+
device: device(),
|
|
151
|
+
manifestUrl: dataUrl({ entries: [], materialShaders: [missingVariant] }),
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const loaded = await registry.loadManifest();
|
|
155
|
+
expect(loaded.ok).toBe(false);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('rejects duplicate variant keys instead of allowing ambiguous prewarm', async () => {
|
|
159
|
+
const duplicateVariant = {
|
|
160
|
+
identifier: 'forgeax::default-standard-pbr',
|
|
161
|
+
sourcePath: 'default-standard-pbr.wgsl',
|
|
162
|
+
composedWgsl: 'standard-default',
|
|
163
|
+
paramSchema: '[]',
|
|
164
|
+
variants: [
|
|
165
|
+
{
|
|
166
|
+
definesKey: 'TRANSMISSION_AVAILABLE=true',
|
|
167
|
+
defines: { TRANSMISSION_AVAILABLE: true },
|
|
168
|
+
composedWgsl: 'standard-transmission-a',
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
definesKey: 'TRANSMISSION_AVAILABLE=true',
|
|
172
|
+
defines: { TRANSMISSION_AVAILABLE: true },
|
|
173
|
+
composedWgsl: 'standard-transmission-b',
|
|
174
|
+
},
|
|
175
|
+
],
|
|
176
|
+
};
|
|
177
|
+
const registry = new ShaderRegistry({
|
|
178
|
+
device: device(),
|
|
179
|
+
manifestUrl: dataUrl({ entries: [], materialShaders: [duplicateVariant] }),
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const loaded = await registry.loadManifest();
|
|
183
|
+
expect(loaded.ok).toBe(false);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
@@ -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,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
|
+
});
|
|
@@ -59,7 +59,7 @@ describe('direct punctual lighting shader contract', () => {
|
|
|
59
59
|
const source = await readFile(pbrShaderPath, 'utf8');
|
|
60
60
|
|
|
61
61
|
expect(source).toContain(
|
|
62
|
-
'evaluate_cluster_lights(in.ndc, in.viewZ, in.worldPos, n, v,
|
|
62
|
+
'evaluate_cluster_lights(in.ndc, in.viewZ, in.worldPos, n, v, diffuseAlbedo, metallic, a, f0)',
|
|
63
63
|
);
|
|
64
64
|
expect(source).toContain('coatAlpha, vec3<f32>(0.04)');
|
|
65
65
|
});
|