@forgeax/engine-shader 0.1.27 → 0.1.29
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/NOTICE +35 -0
- package/README.md +39 -0
- package/dist/ShaderRegistry.d.ts +8 -0
- package/dist/ShaderRegistry.d.ts.map +1 -1
- package/dist/index.d.ts +6 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +312 -71
- package/dist/index.mjs.map +1 -1
- package/dist/material/artifact-types.d.ts +12 -2
- package/dist/material/artifact-types.d.ts.map +1 -1
- package/dist/material-schemas.d.ts +5 -0
- package/dist/material-schemas.d.ts.map +1 -1
- package/package.json +5 -4
- package/src/ShaderRegistry.ts +10 -0
- package/src/__tests__/auto-exposure-graph.unit.test.ts +59 -0
- package/src/__tests__/bloom-fxaa-tonemap.unit.test.ts +30 -15
- package/src/__tests__/builtin-texture-sampling-contract.test.ts +1 -1
- package/src/__tests__/default-standard-pbr-alpha.unit.test.ts +2 -1
- package/src/__tests__/default-standard-pbr-transmission.unit.test.ts +167 -6
- package/src/__tests__/direct-light-layout.unit.test.ts +1 -1
- package/src/__tests__/lighting-punctual.unit.test.ts +29 -1
- package/src/__tests__/ltc-provenance.unit.test.ts +13 -2
- package/src/__tests__/material-builtins.unit.test.ts +1 -0
- package/src/__tests__/material-contract.unit.test.ts +42 -9
- package/src/__tests__/material-derived-builtins.integration.test.ts +18 -2
- package/src/__tests__/probe-lighting-composition.unit.test.ts +1 -1
- package/src/__tests__/reflection-probe-sampling.unit.test.ts +23 -2
- package/src/__tests__/scene-temporal.unit.test.ts +10 -0
- package/src/__tests__/shader.unit.test.ts +1 -0
- package/src/__tests__/sprite-variants.unit.test.ts +1 -0
- package/src/__tests__/ssr-artifact.unit.test.ts +141 -0
- package/src/__tests__/ssr-bgl.integration.test.ts +185 -0
- package/src/__tests__/standard-output-domain.unit.test.ts +36 -0
- package/src/__tests__/standard-pbr-artifact-receipt.unit.test.ts +93 -0
- package/src/__tests__/standard-surface-pass-evaluation.integration.test.ts +16 -4
- package/src/__tests__/surface-v1.unit.test.ts +6 -0
- package/src/__tests__/taa-resolve.unit.test.ts +20 -9
- package/src/__tests__/transmission-thickness-scale.unit.test.ts +6 -1
- package/src/__tests__/transparent-pbr.unit.test.ts +1 -1
- package/src/__tests__/vertex-color-variant.unit.test.ts +4 -1
- package/src/atmosphere-background.wgsl +3 -3
- package/src/auto-exposure-meter.wgsl +179 -0
- package/src/color-lut.wgsl +17 -0
- package/src/common.wgsl +7 -5
- package/src/default-standard-pbr-skin.wgsl +142 -40
- package/src/default-standard-pbr.wgsl +198 -82
- package/src/default_standard_surface.wgsl +56 -19
- package/src/fxaa.wgsl +9 -5
- package/src/ibl-sampling.wgsl +30 -6
- package/src/index.ts +189 -0
- package/src/lighting-punctual.wgsl +5 -6
- package/src/material/artifact-types.ts +109 -63
- package/src/material-schemas.ts +84 -8
- package/src/output-encoding.wgsl +10 -0
- package/src/pbr-temporal.wgsl +9 -1
- package/src/scene-temporal.wgsl +2 -0
- package/src/shadow-pcf.wgsl +6 -8
- package/src/shadow-surface.wgsl +16 -0
- package/src/shadow_caster.wgsl +99 -61
- package/src/sprite-lit.wgsl +4 -4
- package/src/sprite.wgsl +3 -3
- package/src/ssr-compose.wgsl +129 -0
- package/src/ssr-hiz-reduce.wgsl +53 -0
- package/src/ssr-hiz.wgsl +66 -0
- package/src/ssr-temporal.wgsl +305 -0
- package/src/ssr-trace.wgsl +784 -0
- package/src/standard-cluster.wgsl +6 -4
- package/src/standard-surface.wgsl +696 -0
- package/src/surface_v1.wgsl +6 -0
- package/src/taa-resolve.wgsl +222 -28
- package/src/tbn.wgsl +1 -1
- package/src/tonemap.wgsl +37 -18
- package/src/unlit.wgsl +3 -3
|
@@ -2,9 +2,13 @@ import { readFileSync } from 'node:fs';
|
|
|
2
2
|
import { fileURLToPath } from 'node:url';
|
|
3
3
|
import { derive } from '@forgeax/engine-types';
|
|
4
4
|
import Ajv2020 from 'ajv/dist/2020.js';
|
|
5
|
-
import { describe, expect, it } from 'vitest';
|
|
5
|
+
import { beforeAll, describe, expect, it } from 'vitest';
|
|
6
6
|
import { DEFAULT_STANDARD_PBR_PARAM_SCHEMA } from '../material-schemas.js';
|
|
7
7
|
|
|
8
|
+
type EngineShaderManifest = Awaited<
|
|
9
|
+
ReturnType<typeof import('@forgeax/engine-vite-plugin-shader').buildEngineShaderManifest>
|
|
10
|
+
>;
|
|
11
|
+
|
|
8
12
|
const alphaSchema = JSON.parse(
|
|
9
13
|
readFileSync(
|
|
10
14
|
fileURLToPath(
|
|
@@ -59,6 +63,17 @@ describe('standard PBR material alpha contract', () => {
|
|
|
59
63
|
});
|
|
60
64
|
|
|
61
65
|
describe('standard PBR transmission material contract', () => {
|
|
66
|
+
// Manifest production is an integration boundary: compile the producer once
|
|
67
|
+
// and let each contract assertion consume the same receipt-backed result.
|
|
68
|
+
// Rebuilding it in every test made this unit file spend ~100s in CI and
|
|
69
|
+
// caused the 20s receipt assertion to time out without any semantic failure.
|
|
70
|
+
let engineManifest!: EngineShaderManifest;
|
|
71
|
+
|
|
72
|
+
beforeAll(async () => {
|
|
73
|
+
const { buildEngineShaderManifest } = await import('@forgeax/engine-vite-plugin-shader');
|
|
74
|
+
engineManifest = await buildEngineShaderManifest();
|
|
75
|
+
}, 60_000);
|
|
76
|
+
|
|
62
77
|
it('declares the seven transmission and volume parameters once, separate from CPU and GPU evidence', () => {
|
|
63
78
|
const entries = DEFAULT_STANDARD_PBR_PARAM_SCHEMA.filter((entry) =>
|
|
64
79
|
[
|
|
@@ -260,8 +275,7 @@ describe('standard PBR transmission material contract', () => {
|
|
|
260
275
|
},
|
|
261
276
|
]);
|
|
262
277
|
|
|
263
|
-
const
|
|
264
|
-
const manifest = await buildEngineShaderManifest();
|
|
278
|
+
const manifest = engineManifest;
|
|
265
279
|
const material = manifest.materialShaders.find(
|
|
266
280
|
(entry) => entry.identifier === 'forgeax::default-standard-pbr',
|
|
267
281
|
);
|
|
@@ -300,8 +314,7 @@ describe('standard PBR transmission material contract', () => {
|
|
|
300
314
|
// timeout-only failure.
|
|
301
315
|
timeout: 60_000,
|
|
302
316
|
}, async () => {
|
|
303
|
-
const
|
|
304
|
-
const manifest = await buildEngineShaderManifest();
|
|
317
|
+
const manifest = engineManifest;
|
|
305
318
|
const standard = manifest.materialShaders.find(
|
|
306
319
|
(entry) => entry.identifier === 'forgeax::default-standard-pbr',
|
|
307
320
|
);
|
|
@@ -324,8 +337,7 @@ describe('standard PBR transmission material contract', () => {
|
|
|
324
337
|
it('composes the scene-index entry into the same validated PBR artifact as the direct entry', {
|
|
325
338
|
timeout: 60_000,
|
|
326
339
|
}, async () => {
|
|
327
|
-
const
|
|
328
|
-
const manifest = await buildEngineShaderManifest();
|
|
340
|
+
const manifest = engineManifest;
|
|
329
341
|
const standard = manifest.materialShaders.find(
|
|
330
342
|
(entry) => entry.identifier === 'forgeax::default-standard-pbr',
|
|
331
343
|
);
|
|
@@ -338,11 +350,32 @@ describe('standard PBR transmission material contract', () => {
|
|
|
338
350
|
expect(standard.reflection?.layoutIdentity).toBe(standard.receiptIdentity);
|
|
339
351
|
});
|
|
340
352
|
|
|
353
|
+
it('keeps skin scene-index material rows and non-cluster punctual lighting in the GPU variant', {
|
|
354
|
+
timeout: 60_000,
|
|
355
|
+
}, async () => {
|
|
356
|
+
const manifest = engineManifest;
|
|
357
|
+
const skin = manifest.materialShaders.find((entry) => entry.identifier === 'forgeax::pbr-skin');
|
|
358
|
+
expect(skin).toBeDefined();
|
|
359
|
+
if (skin === undefined) return;
|
|
360
|
+
const sceneIndex = skin.variants.find(
|
|
361
|
+
(variant) =>
|
|
362
|
+
variant.defines.GPU_DRIVEN_SCENE_INDEX_AVAILABLE === true &&
|
|
363
|
+
variant.defines.STORAGE_BUFFER_AVAILABLE === true &&
|
|
364
|
+
variant.defines.CLUSTER_FORWARD_AVAILABLE === false &&
|
|
365
|
+
variant.defines.EXTENDED_LIGHTING_AVAILABLE === false &&
|
|
366
|
+
variant.defines.PROBE_BLEND_AVAILABLE === false &&
|
|
367
|
+
variant.defines.VERTEX_COLOR_AVAILABLE === false,
|
|
368
|
+
);
|
|
369
|
+
expect(sceneIndex).toBeDefined();
|
|
370
|
+
expect(sceneIndex?.composedWgsl).toContain('@binding(46)');
|
|
371
|
+
expect(sceneIndex?.composedWgsl).toMatch(/materialIndex/);
|
|
372
|
+
expect(sceneIndex?.composedWgsl).not.toMatch(/pointLightsBuffer|spotLightsBuffer/);
|
|
373
|
+
});
|
|
374
|
+
|
|
341
375
|
it('rejects a receipt that loses a required prepared ABI field', {
|
|
342
376
|
timeout: 60_000,
|
|
343
377
|
}, async () => {
|
|
344
|
-
const
|
|
345
|
-
const manifest = await buildEngineShaderManifest();
|
|
378
|
+
const manifest = engineManifest;
|
|
346
379
|
const standard = manifest.materialShaders.find(
|
|
347
380
|
(entry) => entry.identifier === 'forgeax::default-standard-pbr',
|
|
348
381
|
);
|
|
@@ -2,12 +2,16 @@ import { readFileSync } from 'node:fs';
|
|
|
2
2
|
import { resolve } from 'node:path';
|
|
3
3
|
import { derive } from '@forgeax/engine-types';
|
|
4
4
|
import { describe, expect, it } from 'vitest';
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_STANDARD_PBR_PARAM_SCHEMA,
|
|
7
|
+
STANDARD_PBR_ARTIFACT_RECEIPT,
|
|
8
|
+
STANDARD_PBR_SKIN_ARTIFACT_RECEIPT,
|
|
9
|
+
} from '../material-schemas.js';
|
|
6
10
|
|
|
7
11
|
describe('material derived built-in integration contract', () => {
|
|
8
12
|
it('routes every Standard lit consumer through the single Cluster accessor', () => {
|
|
9
13
|
const sources = [
|
|
10
|
-
readFileSync(resolve(import.meta.dirname, '../
|
|
14
|
+
readFileSync(resolve(import.meta.dirname, '../standard-surface.wgsl'), 'utf8'),
|
|
11
15
|
readFileSync(resolve(import.meta.dirname, '../default-standard-pbr-skin.wgsl'), 'utf8'),
|
|
12
16
|
];
|
|
13
17
|
|
|
@@ -37,6 +41,18 @@ describe('material derived built-in integration contract', () => {
|
|
|
37
41
|
expect(skinned.totalBytes).toBe(standard.totalBytes);
|
|
38
42
|
});
|
|
39
43
|
|
|
44
|
+
it('keeps direct and scene-index entries on one producer receipt', () => {
|
|
45
|
+
expect(STANDARD_PBR_ARTIFACT_RECEIPT.receiptIdentity).toBe(
|
|
46
|
+
STANDARD_PBR_SKIN_ARTIFACT_RECEIPT.receiptIdentity,
|
|
47
|
+
);
|
|
48
|
+
expect(STANDARD_PBR_ARTIFACT_RECEIPT.directEntry).toBe('vs_main');
|
|
49
|
+
expect(STANDARD_PBR_ARTIFACT_RECEIPT.sceneIndexEntry).toBe('vs_scene_index');
|
|
50
|
+
expect(STANDARD_PBR_SKIN_ARTIFACT_RECEIPT.skinPaletteAddress).toMatchObject({
|
|
51
|
+
group: 2,
|
|
52
|
+
binding: 1,
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
40
56
|
it('includes a coordinate member pair for every built-in texture binding', () => {
|
|
41
57
|
const derived = derive(DEFAULT_STANDARD_PBR_PARAM_SCHEMA);
|
|
42
58
|
const textureNames = DEFAULT_STANDARD_PBR_PARAM_SCHEMA.filter(
|
|
@@ -4,7 +4,7 @@ import { describe, expect, it } from 'vitest';
|
|
|
4
4
|
|
|
5
5
|
const shaderRoot = resolve(import.meta.dirname, '..');
|
|
6
6
|
const probe = readFileSync(resolve(shaderRoot, 'lighting-probe.wgsl'), 'utf8');
|
|
7
|
-
const standard = readFileSync(resolve(shaderRoot, '
|
|
7
|
+
const standard = readFileSync(resolve(shaderRoot, 'standard-surface.wgsl'), 'utf8');
|
|
8
8
|
const skin = readFileSync(resolve(shaderRoot, 'default-standard-pbr-skin.wgsl'), 'utf8');
|
|
9
9
|
|
|
10
10
|
describe('Probe diffuse shader composition', () => {
|
|
@@ -6,6 +6,10 @@ const standardSource = readFileSync(
|
|
|
6
6
|
new URL('../default-standard-pbr.wgsl', import.meta.url),
|
|
7
7
|
'utf8',
|
|
8
8
|
);
|
|
9
|
+
const skinSource = readFileSync(
|
|
10
|
+
new URL('../default-standard-pbr-skin.wgsl', import.meta.url),
|
|
11
|
+
'utf8',
|
|
12
|
+
);
|
|
9
13
|
|
|
10
14
|
describe('Standard reflection probe sampling contract', () => {
|
|
11
15
|
it('keeps global IBL helpers and adds a bounded local probe sampling helper', () => {
|
|
@@ -28,7 +32,7 @@ describe('Standard reflection probe sampling contract', () => {
|
|
|
28
32
|
);
|
|
29
33
|
expect(standardSource).toContain('kD * irradiance * diffuseAlbedo');
|
|
30
34
|
expect(standardSource).toContain(
|
|
31
|
-
'output.reflectionFallback = vec4<f32>(reflectionFallback,
|
|
35
|
+
'output.reflectionFallback = vec4<f32>(reflectionFallback, standardSsrCoverage());',
|
|
32
36
|
);
|
|
33
37
|
expect(standardSource).not.toContain('output.reflectionFallback = vec4<f32>(ambient, 1.0);');
|
|
34
38
|
});
|
|
@@ -40,6 +44,23 @@ describe('Standard reflection probe sampling contract', () => {
|
|
|
40
44
|
});
|
|
41
45
|
|
|
42
46
|
it('keeps zero-intensity probes distinguishable from an absent Skylight', () => {
|
|
43
|
-
expect(standardSource).toContain('
|
|
47
|
+
expect(standardSource).toContain('decodeSpecularEnvironmentScale(');
|
|
48
|
+
expect(samplingSource).toContain('vec3<f32>(max(-intensity - 1.0, 0.0))');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('keeps probe-blend and SSR on one sentinel-safe specular environment scale', () => {
|
|
52
|
+
// ReflectionProbe resources encode their intensity as a negative sentinel
|
|
53
|
+
// and use the Skylight color lanes for box metadata. Never multiply that
|
|
54
|
+
// metadata by the specular lobe. The same decoded scale must drive the
|
|
55
|
+
// PROBE_BLEND specular ambient term and detached SSR fallback.
|
|
56
|
+
expect(standardSource).toContain(
|
|
57
|
+
'let specularEnvironmentScale = decodeSpecularEnvironmentScale(',
|
|
58
|
+
);
|
|
59
|
+
expect(standardSource).toContain('specularIbl * specularEnvironmentScale');
|
|
60
|
+
expect(standardSource).toContain(
|
|
61
|
+
'reflectionFallback = reflectionFallback * specularEnvironmentScale * ao;',
|
|
62
|
+
);
|
|
63
|
+
expect(skinSource).toContain('let specularEnvironmentScale = decodeSpecularEnvironmentScale(');
|
|
64
|
+
expect(skinSource).toContain('specularIbl * specularEnvironmentScale');
|
|
44
65
|
});
|
|
45
66
|
});
|
|
@@ -10,6 +10,16 @@ const pbrTemporalConsumers = [
|
|
|
10
10
|
] as const;
|
|
11
11
|
|
|
12
12
|
describe('scene temporal varyings', () => {
|
|
13
|
+
it.each([
|
|
14
|
+
...localTemporalShaders,
|
|
15
|
+
...pbrTemporalConsumers,
|
|
16
|
+
])('%s shares main-depth raster coverage without jittering motion', (file) => {
|
|
17
|
+
const source = readFileSync(resolve(import.meta.dirname, '..', file), 'utf8');
|
|
18
|
+
expect(source).toContain('out.clip = view.worldViewProj * currentWorld;');
|
|
19
|
+
expect(source).toContain('out.currentClip = view.temporalCurrentViewProj * currentWorld;');
|
|
20
|
+
expect(source).not.toContain('out.clip = out.currentClip;');
|
|
21
|
+
expect(source.match(/@builtin\(position\) @invariant clip/g)).toHaveLength(2);
|
|
22
|
+
});
|
|
13
23
|
it('defines the temporal-v1 accessor ABI in one owner module', () => {
|
|
14
24
|
const source = readFileSync(resolve(import.meta.dirname, '..', 'scene-temporal.wgsl'), 'utf8');
|
|
15
25
|
expect(source).toContain('struct SceneTemporalV1');
|
|
@@ -864,6 +864,7 @@ import {
|
|
|
864
864
|
'#pragma variant_axis TRANSMISSION_AVAILABLE',
|
|
865
865
|
'#pragma variant_axis DIRECTIONAL_PCSS_AVAILABLE',
|
|
866
866
|
'#pragma variant_axis PROJECTOR_AVAILABLE',
|
|
867
|
+
'#pragma variant_axis GPU_DRIVEN_SCENE_INDEX_AVAILABLE',
|
|
867
868
|
'#pragma variant_axis REFLECTION_FALLBACK_AVAILABLE',
|
|
868
869
|
]);
|
|
869
870
|
});
|
|
@@ -122,6 +122,7 @@ describe('w6 (b) -- pbr / unlit / sprite-adjacent shaders DO NOT pick up PER_INS
|
|
|
122
122
|
'#pragma variant_axis TRANSMISSION_AVAILABLE',
|
|
123
123
|
'#pragma variant_axis DIRECTIONAL_PCSS_AVAILABLE',
|
|
124
124
|
'#pragma variant_axis PROJECTOR_AVAILABLE',
|
|
125
|
+
'#pragma variant_axis GPU_DRIVEN_SCENE_INDEX_AVAILABLE',
|
|
125
126
|
'#pragma variant_axis REFLECTION_FALLBACK_AVAILABLE',
|
|
126
127
|
]);
|
|
127
128
|
});
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { beforeAll, describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
interface ShaderBinding {
|
|
6
|
+
readonly binding: number;
|
|
7
|
+
readonly visibility: number;
|
|
8
|
+
readonly texture?: Readonly<Record<string, unknown>>;
|
|
9
|
+
readonly storageTexture?: Readonly<Record<string, unknown>>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface CompileValue {
|
|
13
|
+
readonly wgsl: string;
|
|
14
|
+
readonly glsl: string;
|
|
15
|
+
readonly bindings: readonly Readonly<Record<string, unknown>>[];
|
|
16
|
+
readonly manifestEntry: {
|
|
17
|
+
readonly hash: string;
|
|
18
|
+
readonly wgsl: string;
|
|
19
|
+
readonly glsl: string;
|
|
20
|
+
readonly bindings: string;
|
|
21
|
+
};
|
|
22
|
+
readonly deps: readonly string[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface CompileResult {
|
|
26
|
+
readonly ok: boolean;
|
|
27
|
+
readonly value?: CompileValue;
|
|
28
|
+
readonly error?: { readonly code: string; readonly message: string };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface CompilerModule {
|
|
32
|
+
compileShader(
|
|
33
|
+
source: string,
|
|
34
|
+
options: { readonly id: string; readonly imports?: Readonly<Record<string, string>> },
|
|
35
|
+
): Promise<CompileResult>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const SHADER_PATHS = {
|
|
39
|
+
hiz: resolve(import.meta.dirname, '../ssr-hiz.wgsl'),
|
|
40
|
+
hizReduce: resolve(import.meta.dirname, '../ssr-hiz-reduce.wgsl'),
|
|
41
|
+
trace: resolve(import.meta.dirname, '../ssr-trace.wgsl'),
|
|
42
|
+
temporal: resolve(import.meta.dirname, '../ssr-temporal.wgsl'),
|
|
43
|
+
compose: resolve(import.meta.dirname, '../ssr-compose.wgsl'),
|
|
44
|
+
} as const;
|
|
45
|
+
|
|
46
|
+
const SOURCES = {
|
|
47
|
+
hiz: readFileSync(SHADER_PATHS.hiz, 'utf8'),
|
|
48
|
+
hizReduce: readFileSync(SHADER_PATHS.hizReduce, 'utf8'),
|
|
49
|
+
trace: readFileSync(SHADER_PATHS.trace, 'utf8'),
|
|
50
|
+
temporal: readFileSync(SHADER_PATHS.temporal, 'utf8'),
|
|
51
|
+
compose: readFileSync(SHADER_PATHS.compose, 'utf8'),
|
|
52
|
+
} as const;
|
|
53
|
+
const COMMON = readFileSync(resolve(import.meta.dirname, '../common.wgsl'), 'utf8');
|
|
54
|
+
|
|
55
|
+
let compiler: CompilerModule;
|
|
56
|
+
|
|
57
|
+
beforeAll(async () => {
|
|
58
|
+
compiler = (await import(
|
|
59
|
+
/* @vite-ignore */ new URL('../../../shader-compiler/dist/index.mjs', import.meta.url).href
|
|
60
|
+
)) as unknown as CompilerModule;
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
async function compile(name: keyof typeof SOURCES): Promise<CompileValue> {
|
|
64
|
+
const result = await compiler.compileShader(SOURCES[name], {
|
|
65
|
+
id: `forgeax_ssr::${name}`,
|
|
66
|
+
...(name === 'trace' || name === 'hiz' || name === 'temporal' || name === 'compose'
|
|
67
|
+
? { imports: { 'forgeax_view::common': COMMON } }
|
|
68
|
+
: {}),
|
|
69
|
+
});
|
|
70
|
+
expect(result.ok, result.ok ? undefined : `${name}: ${result.error?.message}`).toBe(true);
|
|
71
|
+
if (!result.ok || result.value === undefined) throw new Error(`failed to compile ${name}`);
|
|
72
|
+
return result.value;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
describe('SSR built-in shader artifacts', () => {
|
|
76
|
+
it('owns one build-time source for each spatial producer stage', () => {
|
|
77
|
+
expect(SOURCES.hiz).toContain('#define_import_path forgeax_ssr::hiz');
|
|
78
|
+
expect(SOURCES.hizReduce).toContain('#define_import_path forgeax_ssr::hiz_reduce');
|
|
79
|
+
expect(SOURCES.trace).toContain('#define_import_path forgeax_ssr::trace');
|
|
80
|
+
expect(SOURCES.hiz).toContain('SSR_HIZ_FORMAT');
|
|
81
|
+
expect(SOURCES.hiz).toContain('linearizeSsrDepth');
|
|
82
|
+
expect(SOURCES.hiz).toContain('view.temporalProjection');
|
|
83
|
+
expect(SOURCES.hizReduce).toContain('reduceSsrHiZFootprint');
|
|
84
|
+
expect(SOURCES.trace).toContain('SSR_TRACE_MAX_COARSE_STEPS');
|
|
85
|
+
expect(SOURCES.trace).toContain('SSR_TRACE_MAX_REFINE_STEPS');
|
|
86
|
+
expect(SOURCES.trace).toContain('traceScreenRay');
|
|
87
|
+
expect(SOURCES.trace).toContain('textureLoad(hizPyramid');
|
|
88
|
+
expect(SOURCES.trace).toContain('view.ssrParams.w > 0.5');
|
|
89
|
+
expect(SOURCES.temporal).toContain('SsrTemporalParams');
|
|
90
|
+
expect(SOURCES.temporal).toContain('resolvedOutput');
|
|
91
|
+
expect(SOURCES.temporal).toContain('sourceSize.x == size.x * 2u');
|
|
92
|
+
expect(SOURCES.temporal).toContain('topLeft = textureLoad(currentTrace');
|
|
93
|
+
expect(SOURCES.temporal).toContain('bottomRight = textureLoad(currentTrace');
|
|
94
|
+
expect(SOURCES.temporal).toContain('count += 1.0');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('compiles and reflects every artifact through the build-time compiler', async () => {
|
|
98
|
+
for (const name of Object.keys(SOURCES) as Array<keyof typeof SOURCES>) {
|
|
99
|
+
const value = await compile(name);
|
|
100
|
+
expect(value.wgsl.length).toBeGreaterThan(0);
|
|
101
|
+
if (name !== 'compose') expect(value.glsl).toBe('');
|
|
102
|
+
expect(value.manifestEntry.wgsl).toBe(value.wgsl);
|
|
103
|
+
if (name !== 'compose') expect(value.manifestEntry.glsl ?? '').toBe('');
|
|
104
|
+
expect(value.manifestEntry.hash).toMatch(/^[0-9a-f]{8,64}$/);
|
|
105
|
+
expect(value.deps).toEqual(name === 'hizReduce' ? [] : ['forgeax_view::common']);
|
|
106
|
+
expect(JSON.parse(value.manifestEntry.bindings)).toEqual(value.bindings);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('keeps artifact identity content-addressed and deterministic', async () => {
|
|
111
|
+
for (const name of Object.keys(SOURCES) as Array<keyof typeof SOURCES>) {
|
|
112
|
+
const first = await compile(name);
|
|
113
|
+
const second = await compile(name);
|
|
114
|
+
expect(second.manifestEntry.hash).toBe(first.manifestEntry.hash);
|
|
115
|
+
expect(second.manifestEntry.wgsl).toBe(first.manifestEntry.wgsl);
|
|
116
|
+
expect(second.manifestEntry.bindings).toBe(first.manifestEntry.bindings);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('keeps the runtime registry physically isolated from the compiler', () => {
|
|
121
|
+
const registrySource = readFileSync(
|
|
122
|
+
resolve(import.meta.dirname, '../ShaderRegistry.ts'),
|
|
123
|
+
'utf8',
|
|
124
|
+
);
|
|
125
|
+
const packageJson = JSON.parse(
|
|
126
|
+
readFileSync(resolve(import.meta.dirname, '../../package.json'), 'utf8'),
|
|
127
|
+
) as { dependencies?: Record<string, string> };
|
|
128
|
+
expect(registrySource).not.toMatch(
|
|
129
|
+
/^\s*(?:import|export).*@forgeax\/engine-(?:shader-compiler|naga|wgpu-wasm)/mu,
|
|
130
|
+
);
|
|
131
|
+
expect(Object.keys(packageJson.dependencies ?? {})).not.toEqual(
|
|
132
|
+
expect.arrayContaining([
|
|
133
|
+
'@forgeax/engine-shader-compiler',
|
|
134
|
+
'@forgeax/engine-naga',
|
|
135
|
+
'@forgeax/engine-wgpu-wasm',
|
|
136
|
+
]),
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
export type { ShaderBinding };
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { beforeAll, describe, expect, it } from 'vitest';
|
|
4
|
+
import { SSR_SHADER_MODULES } from '../index.js';
|
|
5
|
+
|
|
6
|
+
interface CompileValue {
|
|
7
|
+
readonly bindings: readonly Readonly<Record<string, unknown>>[];
|
|
8
|
+
readonly manifestEntry: { readonly hash: string; readonly bindings: string };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface CompileResult {
|
|
12
|
+
readonly ok: boolean;
|
|
13
|
+
readonly value?: CompileValue;
|
|
14
|
+
readonly error?: { readonly code: string; readonly message: string };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface CompilerModule {
|
|
18
|
+
compileShader(
|
|
19
|
+
source: string,
|
|
20
|
+
options: { readonly id: string; readonly imports?: Readonly<Record<string, string>> },
|
|
21
|
+
): Promise<CompileResult>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const SOURCES = {
|
|
25
|
+
hiz: readFileSync(resolve(import.meta.dirname, '../ssr-hiz.wgsl'), 'utf8'),
|
|
26
|
+
hizReduce: readFileSync(resolve(import.meta.dirname, '../ssr-hiz-reduce.wgsl'), 'utf8'),
|
|
27
|
+
trace: readFileSync(resolve(import.meta.dirname, '../ssr-trace.wgsl'), 'utf8'),
|
|
28
|
+
temporal: readFileSync(resolve(import.meta.dirname, '../ssr-temporal.wgsl'), 'utf8'),
|
|
29
|
+
} as const;
|
|
30
|
+
const COMMON = readFileSync(resolve(import.meta.dirname, '../common.wgsl'), 'utf8');
|
|
31
|
+
|
|
32
|
+
let compiler: CompilerModule;
|
|
33
|
+
|
|
34
|
+
beforeAll(async () => {
|
|
35
|
+
compiler = (await import(
|
|
36
|
+
/* @vite-ignore */ new URL('../../../shader-compiler/dist/index.mjs', import.meta.url).href
|
|
37
|
+
)) as unknown as CompilerModule;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
async function compile(name: keyof typeof SOURCES): Promise<CompileValue> {
|
|
41
|
+
const result = await compiler.compileShader(SOURCES[name], {
|
|
42
|
+
id: `forgeax_ssr::${name}`,
|
|
43
|
+
imports: { 'forgeax_view::common': COMMON },
|
|
44
|
+
});
|
|
45
|
+
expect(result.ok, result.ok ? undefined : result.error?.message).toBe(true);
|
|
46
|
+
if (!result.ok || result.value === undefined) throw new Error(`failed to compile ${name}`);
|
|
47
|
+
return result.value;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function entries(value: CompileValue): readonly Readonly<Record<string, unknown>>[] {
|
|
51
|
+
const group = value.bindings[0];
|
|
52
|
+
expect(group).toBeDefined();
|
|
53
|
+
if (group === undefined || !Array.isArray(group.entries)) throw new Error('missing group zero');
|
|
54
|
+
return group.entries as readonly Readonly<Record<string, unknown>>[];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function storageTexture(
|
|
58
|
+
entry: Readonly<Record<string, unknown>> | undefined,
|
|
59
|
+
): Readonly<Record<string, unknown>> {
|
|
60
|
+
expect(entry?.storageTexture).toBeDefined();
|
|
61
|
+
if (
|
|
62
|
+
entry === undefined ||
|
|
63
|
+
typeof entry.storageTexture !== 'object' ||
|
|
64
|
+
entry.storageTexture === null
|
|
65
|
+
) {
|
|
66
|
+
throw new Error('missing storage texture binding');
|
|
67
|
+
}
|
|
68
|
+
return entry.storageTexture as Readonly<Record<string, unknown>>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe('SSR built-in shader binding contract', () => {
|
|
72
|
+
it('reflects Hi-Z as a depth input plus r32float storage output', async () => {
|
|
73
|
+
const value = await compile('hiz');
|
|
74
|
+
const reflected = entries(value);
|
|
75
|
+
expect(reflected).toHaveLength(3);
|
|
76
|
+
expect(reflected[0]).toMatchObject({
|
|
77
|
+
binding: 0,
|
|
78
|
+
texture: { sampleType: 'depth', viewDimension: '2d' },
|
|
79
|
+
});
|
|
80
|
+
expect(storageTexture(reflected[1])).toMatchObject({
|
|
81
|
+
access: 'write-only',
|
|
82
|
+
format: 'r32float',
|
|
83
|
+
viewDimension: '2d',
|
|
84
|
+
});
|
|
85
|
+
expect(reflected[2]).toMatchObject({ binding: 2, buffer: { type: 'uniform' } });
|
|
86
|
+
expect(JSON.parse(value.manifestEntry.bindings)).toEqual(value.bindings);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('reflects trace inputs including lighting-owned coverage and the shared View', async () => {
|
|
90
|
+
const value = await compile('trace');
|
|
91
|
+
const reflected = entries(value);
|
|
92
|
+
expect(reflected).toHaveLength(9);
|
|
93
|
+
for (let binding = 0; binding < 4; binding += 1) {
|
|
94
|
+
expect(reflected[binding]).toMatchObject({
|
|
95
|
+
binding,
|
|
96
|
+
texture: {
|
|
97
|
+
sampleType: binding === 0 ? 'depth' : 'unfilterable-float',
|
|
98
|
+
viewDimension: '2d',
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
expect(storageTexture(reflected[4])).toMatchObject({
|
|
103
|
+
access: 'write-only',
|
|
104
|
+
format: 'rgba16float',
|
|
105
|
+
viewDimension: '2d',
|
|
106
|
+
});
|
|
107
|
+
expect(reflected[5]).toMatchObject({ binding: 5, buffer: { type: 'uniform' } });
|
|
108
|
+
expect(reflected[7]).toMatchObject({
|
|
109
|
+
binding: 7,
|
|
110
|
+
texture: { sampleType: 'unfilterable-float', viewDimension: '2d' },
|
|
111
|
+
});
|
|
112
|
+
expect(reflected[8]).toMatchObject({
|
|
113
|
+
binding: 8,
|
|
114
|
+
storageTexture: { access: 'write-only', format: 'r32float', viewDimension: '2d' },
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('reflects Hi-Z reduction as an r32float sampled input plus storage output', async () => {
|
|
119
|
+
const value = await compile('hizReduce');
|
|
120
|
+
const reflected = entries(value);
|
|
121
|
+
expect(reflected).toHaveLength(2);
|
|
122
|
+
expect(reflected[0]).toMatchObject({
|
|
123
|
+
binding: 0,
|
|
124
|
+
texture: { sampleType: 'unfilterable-float', viewDimension: '2d' },
|
|
125
|
+
});
|
|
126
|
+
expect(storageTexture(reflected[1])).toMatchObject({
|
|
127
|
+
access: 'write-only',
|
|
128
|
+
format: 'r32float',
|
|
129
|
+
viewDimension: '2d',
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('reflects temporal resolve with packed actual normal and confidence history', async () => {
|
|
134
|
+
const value = await compile('temporal');
|
|
135
|
+
const reflected = entries(value);
|
|
136
|
+
expect(reflected).toHaveLength(12);
|
|
137
|
+
expect(reflected[11]).toMatchObject({
|
|
138
|
+
binding: 11,
|
|
139
|
+
texture: { sampleType: 'unfilterable-float', viewDimension: '2d' },
|
|
140
|
+
});
|
|
141
|
+
expect(reflected[9]).toMatchObject({
|
|
142
|
+
binding: 9,
|
|
143
|
+
texture: { sampleType: 'unfilterable-float', viewDimension: '2d' },
|
|
144
|
+
});
|
|
145
|
+
expect(reflected[10]).toMatchObject({
|
|
146
|
+
binding: 10,
|
|
147
|
+
storageTexture: { access: 'write-only', format: 'rgba8unorm', viewDimension: '2d' },
|
|
148
|
+
});
|
|
149
|
+
expect(reflected[8]).toMatchObject({ binding: 8, buffer: { type: 'uniform' } });
|
|
150
|
+
for (let binding = 0; binding < 5; binding += 1) {
|
|
151
|
+
expect(reflected[binding]).toMatchObject({
|
|
152
|
+
binding,
|
|
153
|
+
texture: {
|
|
154
|
+
sampleType: binding === 1 ? 'depth' : 'unfilterable-float',
|
|
155
|
+
viewDimension: '2d',
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
expect(storageTexture(reflected[5])).toMatchObject({
|
|
160
|
+
access: 'write-only',
|
|
161
|
+
format: 'rgba16float',
|
|
162
|
+
viewDimension: '2d',
|
|
163
|
+
});
|
|
164
|
+
expect(reflected[6]).toMatchObject({ binding: 6, buffer: { type: 'uniform' } });
|
|
165
|
+
expect(storageTexture(reflected[7])).toMatchObject({
|
|
166
|
+
access: 'write-only',
|
|
167
|
+
format: 'rgba16float',
|
|
168
|
+
viewDimension: '2d',
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('requires a registry-discoverable stable module id for each artifact', () => {
|
|
173
|
+
const registrySource = readFileSync(
|
|
174
|
+
resolve(import.meta.dirname, '../ShaderRegistry.ts'),
|
|
175
|
+
'utf8',
|
|
176
|
+
);
|
|
177
|
+
expect(registrySource).toContain('SSR_SHADER_MODULES');
|
|
178
|
+
expect(SSR_SHADER_MODULES).toEqual({
|
|
179
|
+
hiz: 'forgeax_ssr::hiz',
|
|
180
|
+
hizReduce: 'forgeax_ssr::hiz_reduce',
|
|
181
|
+
trace: 'forgeax_ssr::trace',
|
|
182
|
+
temporal: 'forgeax_ssr::temporal',
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
|
|
5
|
+
const shaderRoot = resolve(import.meta.dirname, '..');
|
|
6
|
+
const outputEncoding = readFileSync(resolve(shaderRoot, 'output-encoding.wgsl'), 'utf8');
|
|
7
|
+
const tonemap = readFileSync(resolve(shaderRoot, 'tonemap.wgsl'), 'utf8');
|
|
8
|
+
|
|
9
|
+
describe('Standard output shader domain contract', () => {
|
|
10
|
+
it('owns the only explicit linear-LDR to display-sRGB conversion', () => {
|
|
11
|
+
expect(outputEncoding).toContain('linearToSrgbOetf');
|
|
12
|
+
expect(outputEncoding.match(/linearToSrgbOetf\s*\(/g)).toHaveLength(1);
|
|
13
|
+
expect(outputEncoding).toContain('fn encodeOutput');
|
|
14
|
+
expect(outputEncoding).toContain('return vec4<f32>(encoded, alpha);');
|
|
15
|
+
expect(tonemap).toContain('#import forgeax_view::output_encoding');
|
|
16
|
+
expect(tonemap).not.toContain('linearToSrgbOetf(mapped)');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('keeps tone mapping in linear HDR to linear LDR and preserves alpha', () => {
|
|
20
|
+
expect(tonemap).toContain('fn toneMapLinearLdr');
|
|
21
|
+
expect(tonemap).toContain('fn mapTonemap');
|
|
22
|
+
expect(tonemap).toContain('fn fs_tone_only');
|
|
23
|
+
expect(tonemap).toContain('return vec4<f32>(mapTonemap(source.rgb), source.a);');
|
|
24
|
+
expect(tonemap).toContain('fn fs_encode_only');
|
|
25
|
+
expect(tonemap).toContain('return encodeFinal(source.rgb, source.a, in.position);');
|
|
26
|
+
expect(tonemap).not.toContain('encodedDestinationBlend');
|
|
27
|
+
expect(tonemap).toContain('ditherUnorm8(encoded.rgb, position.xy)');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('fails closed when a route has no declared output encoding owner', () => {
|
|
31
|
+
const source = `${outputEncoding}\n${tonemap}`;
|
|
32
|
+
expect(source.match(/fn encodeOutput/g)).toHaveLength(1);
|
|
33
|
+
expect(source.match(/linearToSrgbOetf\s*\(/g)).toHaveLength(1);
|
|
34
|
+
expect(source).not.toMatch(/srgb.*attachment.*and.*explicit|explicit.*and.*srgb.*attachment/i);
|
|
35
|
+
});
|
|
36
|
+
});
|