@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.
Files changed (73) hide show
  1. package/NOTICE +35 -0
  2. package/README.md +39 -0
  3. package/dist/ShaderRegistry.d.ts +8 -0
  4. package/dist/ShaderRegistry.d.ts.map +1 -1
  5. package/dist/index.d.ts +6 -2
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.mjs +312 -71
  8. package/dist/index.mjs.map +1 -1
  9. package/dist/material/artifact-types.d.ts +12 -2
  10. package/dist/material/artifact-types.d.ts.map +1 -1
  11. package/dist/material-schemas.d.ts +5 -0
  12. package/dist/material-schemas.d.ts.map +1 -1
  13. package/package.json +5 -4
  14. package/src/ShaderRegistry.ts +10 -0
  15. package/src/__tests__/auto-exposure-graph.unit.test.ts +59 -0
  16. package/src/__tests__/bloom-fxaa-tonemap.unit.test.ts +30 -15
  17. package/src/__tests__/builtin-texture-sampling-contract.test.ts +1 -1
  18. package/src/__tests__/default-standard-pbr-alpha.unit.test.ts +2 -1
  19. package/src/__tests__/default-standard-pbr-transmission.unit.test.ts +167 -6
  20. package/src/__tests__/direct-light-layout.unit.test.ts +1 -1
  21. package/src/__tests__/lighting-punctual.unit.test.ts +29 -1
  22. package/src/__tests__/ltc-provenance.unit.test.ts +13 -2
  23. package/src/__tests__/material-builtins.unit.test.ts +1 -0
  24. package/src/__tests__/material-contract.unit.test.ts +42 -9
  25. package/src/__tests__/material-derived-builtins.integration.test.ts +18 -2
  26. package/src/__tests__/probe-lighting-composition.unit.test.ts +1 -1
  27. package/src/__tests__/reflection-probe-sampling.unit.test.ts +23 -2
  28. package/src/__tests__/scene-temporal.unit.test.ts +10 -0
  29. package/src/__tests__/shader.unit.test.ts +1 -0
  30. package/src/__tests__/sprite-variants.unit.test.ts +1 -0
  31. package/src/__tests__/ssr-artifact.unit.test.ts +141 -0
  32. package/src/__tests__/ssr-bgl.integration.test.ts +185 -0
  33. package/src/__tests__/standard-output-domain.unit.test.ts +36 -0
  34. package/src/__tests__/standard-pbr-artifact-receipt.unit.test.ts +93 -0
  35. package/src/__tests__/standard-surface-pass-evaluation.integration.test.ts +16 -4
  36. package/src/__tests__/surface-v1.unit.test.ts +6 -0
  37. package/src/__tests__/taa-resolve.unit.test.ts +20 -9
  38. package/src/__tests__/transmission-thickness-scale.unit.test.ts +6 -1
  39. package/src/__tests__/transparent-pbr.unit.test.ts +1 -1
  40. package/src/__tests__/vertex-color-variant.unit.test.ts +4 -1
  41. package/src/atmosphere-background.wgsl +3 -3
  42. package/src/auto-exposure-meter.wgsl +179 -0
  43. package/src/color-lut.wgsl +17 -0
  44. package/src/common.wgsl +7 -5
  45. package/src/default-standard-pbr-skin.wgsl +142 -40
  46. package/src/default-standard-pbr.wgsl +198 -82
  47. package/src/default_standard_surface.wgsl +56 -19
  48. package/src/fxaa.wgsl +9 -5
  49. package/src/ibl-sampling.wgsl +30 -6
  50. package/src/index.ts +189 -0
  51. package/src/lighting-punctual.wgsl +5 -6
  52. package/src/material/artifact-types.ts +109 -63
  53. package/src/material-schemas.ts +84 -8
  54. package/src/output-encoding.wgsl +10 -0
  55. package/src/pbr-temporal.wgsl +9 -1
  56. package/src/scene-temporal.wgsl +2 -0
  57. package/src/shadow-pcf.wgsl +6 -8
  58. package/src/shadow-surface.wgsl +16 -0
  59. package/src/shadow_caster.wgsl +99 -61
  60. package/src/sprite-lit.wgsl +4 -4
  61. package/src/sprite.wgsl +3 -3
  62. package/src/ssr-compose.wgsl +129 -0
  63. package/src/ssr-hiz-reduce.wgsl +53 -0
  64. package/src/ssr-hiz.wgsl +66 -0
  65. package/src/ssr-temporal.wgsl +305 -0
  66. package/src/ssr-trace.wgsl +784 -0
  67. package/src/standard-cluster.wgsl +6 -4
  68. package/src/standard-surface.wgsl +696 -0
  69. package/src/surface_v1.wgsl +6 -0
  70. package/src/taa-resolve.wgsl +222 -28
  71. package/src/tbn.wgsl +1 -1
  72. package/src/tonemap.wgsl +37 -18
  73. 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 { buildEngineShaderManifest } = await import('@forgeax/engine-vite-plugin-shader');
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 { buildEngineShaderManifest } = await import('@forgeax/engine-vite-plugin-shader');
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 { buildEngineShaderManifest } = await import('@forgeax/engine-vite-plugin-shader');
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 { buildEngineShaderManifest } = await import('@forgeax/engine-vite-plugin-shader');
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 { DEFAULT_STANDARD_PBR_PARAM_SCHEMA } from '../material-schemas.js';
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, '../default-standard-pbr.wgsl'), 'utf8'),
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, 'default-standard-pbr.wgsl'), 'utf8');
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, 1.0);',
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('max(-skylight.intensity - 1.0, 0.0) * ao');
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
+ });