@forgeax/engine-render 0.1.24 → 0.1.26
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 +118 -1
- package/dist/assembly/factory.d.ts +1 -1
- package/dist/assembly/factory.d.ts.map +1 -1
- package/dist/assembly/material/assembly.d.ts +4 -17
- package/dist/assembly/material/assembly.d.ts.map +1 -1
- package/dist/assembly/material/standard-layer-projection.d.ts.map +1 -1
- package/dist/assembly/material/surface-projection.d.ts +24 -0
- package/dist/assembly/material/surface-projection.d.ts.map +1 -0
- package/dist/assembly/material-shader-policy.d.ts +20 -0
- package/dist/assembly/material-shader-policy.d.ts.map +1 -1
- package/dist/assembly/webgpu-ready.d.ts.map +1 -1
- package/dist/assembly/webgpu-renderer.d.ts +1 -2
- package/dist/assembly/webgpu-renderer.d.ts.map +1 -1
- package/dist/assembly/webgpu-vertex-layouts.d.ts +89 -0
- package/dist/assembly/webgpu-vertex-layouts.d.ts.map +1 -0
- package/dist/assets/asset-decoders.d.ts.map +1 -1
- package/dist/authoring.mjs +1 -1
- package/dist/{chunk-Q752SHI5.mjs → chunk-ADHHVYLW.mjs} +3 -3
- package/dist/chunk-ADHHVYLW.mjs.map +1 -0
- package/dist/{chunk-TFH5I7JJ.mjs → chunk-E34VL5VI.mjs} +3 -3
- package/dist/{chunk-TFH5I7JJ.mjs.map → chunk-E34VL5VI.mjs.map} +1 -1
- package/dist/{chunk-YKB5DQV7.mjs → chunk-GF523LHF.mjs} +89 -21
- package/dist/chunk-GF523LHF.mjs.map +1 -0
- package/dist/{chunk-KZFTK6DO.mjs → chunk-HKXTW355.mjs} +7 -4
- package/dist/chunk-HKXTW355.mjs.map +1 -0
- package/dist/{chunk-UQYS7FRM.mjs → chunk-KM2NOX2I.mjs} +4 -4
- package/dist/{chunk-UQYS7FRM.mjs.map → chunk-KM2NOX2I.mjs.map} +1 -1
- package/dist/{chunk-YRUYGA4A.mjs → chunk-TMIASV2N.mjs} +944 -577
- package/dist/chunk-TMIASV2N.mjs.map +1 -0
- package/dist/{chunk-Z3YYH2MB.mjs → chunk-YUQXSEHG.mjs} +160 -73
- package/dist/chunk-YUQXSEHG.mjs.map +1 -0
- package/dist/{chunk-RR5L4SRS.mjs → chunk-ZZ4YQ474.mjs} +2 -2
- package/dist/{chunk-RR5L4SRS.mjs.map → chunk-ZZ4YQ474.mjs.map} +1 -1
- package/dist/components/instances.d.ts +13 -31
- package/dist/components/instances.d.ts.map +1 -1
- package/dist/construct-renderer.mjs +1012 -149
- package/dist/construct-renderer.mjs.map +1 -1
- package/dist/extract/contracts.d.ts +6 -6
- package/dist/extract/contracts.d.ts.map +1 -1
- package/dist/gpu-driven/production-raster.d.ts +6 -1
- package/dist/gpu-driven/production-raster.d.ts.map +1 -1
- package/dist/gpu-scene.d.ts +4 -0
- package/dist/gpu-scene.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +27 -9
- package/dist/index.mjs.map +1 -1
- package/dist/instance-buffer-cache.d.ts +7 -1
- package/dist/instance-buffer-cache.d.ts.map +1 -1
- package/dist/instances.d.ts +101 -0
- package/dist/instances.d.ts.map +1 -0
- package/dist/internal.mjs +5 -5
- package/dist/materials.d.ts +63 -2
- package/dist/materials.d.ts.map +1 -1
- package/dist/pipeline-builder.d.ts.map +1 -1
- package/dist/pipeline-spec-types.d.ts +2 -0
- package/dist/pipeline-spec-types.d.ts.map +1 -1
- package/dist/pipeline-spec.d.ts.map +1 -1
- package/dist/record/frame-snapshot.d.ts +13 -2
- package/dist/record/frame-snapshot.d.ts.map +1 -1
- package/dist/record/frame.d.ts.map +1 -1
- package/dist/record/main-pass-geometry.d.ts +23 -4
- package/dist/record/main-pass-geometry.d.ts.map +1 -1
- package/dist/record/main-pass-material.d.ts +20 -2
- package/dist/record/main-pass-material.d.ts.map +1 -1
- package/dist/record/main-pass-sprite-draws.d.ts +3 -3
- package/dist/record/main-pass-sprite-draws.d.ts.map +1 -1
- package/dist/record/main-pass.d.ts.map +1 -1
- package/dist/record/material-uniforms.d.ts +4 -0
- package/dist/record/material-uniforms.d.ts.map +1 -0
- package/dist/record/render-context.d.ts +4 -3
- package/dist/record/render-context.d.ts.map +1 -1
- package/dist/record/shadow-pass.d.ts +10 -1
- package/dist/record/shadow-pass.d.ts.map +1 -1
- package/dist/recovery/render-system-candidate.d.ts +1 -1
- package/dist/recovery/render-system-candidate.d.ts.map +1 -1
- package/dist/render-contract.d.ts +4 -0
- package/dist/render-contract.d.ts.map +1 -1
- package/dist/render-system-extract-tail.d.ts +6 -0
- package/dist/render-system-extract-tail.d.ts.map +1 -1
- package/dist/render-system-extract.d.ts +44 -9
- package/dist/render-system-extract.d.ts.map +1 -1
- package/dist/render-system.d.ts +4 -1
- package/dist/render-system.d.ts.map +1 -1
- package/dist/scene/render-scene-types.d.ts +2 -0
- package/dist/scene/render-scene-types.d.ts.map +1 -1
- package/dist/scene/render-scene.d.ts +12 -1
- package/dist/scene/render-scene.d.ts.map +1 -1
- package/dist/systems/skin-palette-allocator.d.ts +6 -0
- package/dist/systems/skin-palette-allocator.d.ts.map +1 -1
- package/dist/temporal/index.mjs +5 -5
- package/package.json +20 -20
- package/src/__tests__/fixtures/ai-weapon-spirit/README.md +20 -0
- package/src/__tests__/fixtures/ai-weapon-spirit/low-poly-toon.wgsl +178 -0
- package/src/__tests__/fixtures/ai-weapon-spirit/material.json +97 -0
- package/src/__tests__/fixtures/ai-weapon-spirit/painterly-surface.wgsl +55 -0
- package/src/__tests__/gpu-driven-view-gpu-evidence.ts +3 -3
- package/src/__tests__/gpu-driven-view.browser.test.ts +11 -3
- package/src/__tests__/gpu-scene.unit.test.ts +38 -2
- package/src/__tests__/instances-store.unit.test.ts +46 -0
- package/src/__tests__/instances-world-ownership.integration.test.ts +62 -0
- package/src/__tests__/material-contract-inventory.unit.test.ts +48 -13
- package/src/__tests__/{material-static-dynamic-route.unit.test.ts → material-layer-projection.unit.test.ts} +30 -47
- package/src/__tests__/material-pass-policy.unit.test.ts +69 -0
- package/src/__tests__/material-program-dispatch.unit.test.ts +48 -0
- package/src/__tests__/material-snapshot-mutation.integration.test.ts +69 -0
- package/src/__tests__/material-surface-authoring.test-d.ts +62 -0
- package/src/__tests__/materials-transmission-validation.unit.test.ts +24 -0
- package/src/__tests__/mesh-buffer-usage-surface.unit.test.ts +0 -4
- package/src/__tests__/renderer-factory-material-contract.unit.test.ts +44 -2
- package/src/__tests__/skin-motion-regression.unit.test.ts +33 -0
- package/src/__tests__/skinned-shadow-caster.test.ts +30 -2
- package/src/__tests__/surface-adjacent-regressions.integration.test.ts +60 -0
- package/src/__tests__/surface-standard-pipeline.browser.test.ts +20 -0
- package/src/__tests__/surface-standard-pipeline.dawn.test.ts +20 -0
- package/src/__tests__/surface-standard-pipeline.fixture.ts +132 -0
- package/src/__tests__/surface-variant-provenance.integration.test.ts +33 -0
- package/src/__tests__/volumetric-fog-budget-recovery.integration.test.ts +1 -1
- package/src/assembly/factory.ts +2 -3
- package/src/assembly/material/__tests__/standard-layer-plan-projection.integration.test.ts +18 -0
- package/src/assembly/material/__tests__/surface-pass-projection.integration.test.ts +62 -0
- package/src/assembly/material/assembly.ts +8 -40
- package/src/assembly/material/standard-layer-projection.ts +3 -1
- package/src/assembly/material/surface-projection.ts +91 -0
- package/src/assembly/material-shader-policy.ts +88 -10
- package/src/assembly/recovery/renderer-recover.ts +1 -1
- package/src/assembly/webgpu-ready.ts +3 -16
- package/src/assembly/webgpu-renderer.ts +106 -166
- package/src/assembly/webgpu-vertex-layouts.ts +132 -0
- package/src/assets/asset-decoders.ts +27 -4
- package/src/components/instances.ts +14 -197
- package/src/extract/contracts.ts +6 -6
- package/src/gpu-driven/production-raster.ts +34 -1
- package/src/gpu-scene.ts +84 -29
- package/src/index.ts +1 -0
- package/src/instance-buffer-cache.ts +14 -1
- package/src/instances.ts +288 -0
- package/src/materials.ts +109 -14
- package/src/pipeline-builder.ts +14 -10
- package/src/pipeline-spec-types.ts +2 -0
- package/src/pipeline-spec.ts +13 -17
- package/src/record/frame-snapshot.ts +19 -2
- package/src/record/frame.ts +38 -3
- package/src/record/main-pass-geometry.ts +837 -337
- package/src/record/main-pass-material.ts +87 -2
- package/src/record/main-pass-sprite-draws.ts +66 -169
- package/src/record/main-pass.ts +24 -246
- package/src/record/material-uniforms.ts +186 -0
- package/src/record/render-context.ts +8 -0
- package/src/record/shadow-pass.ts +120 -115
- package/src/recovery/render-system-candidate.ts +46 -5
- package/src/render-contract.ts +4 -0
- package/src/render-system-extract-tail.ts +153 -57
- package/src/render-system-extract.ts +110 -73
- package/src/render-system.ts +59 -5
- package/src/scene/render-scene-types.ts +2 -0
- package/src/scene/render-scene.ts +579 -36
- package/src/systems/skin-palette-allocator.ts +129 -7
- package/src/transmission/__tests__/surface-compatibility.integration.test.ts +32 -0
- package/dist/assembly/material/pipeline-projection.d.ts +0 -30
- package/dist/assembly/material/pipeline-projection.d.ts.map +0 -1
- package/dist/chunk-KZFTK6DO.mjs.map +0 -1
- package/dist/chunk-Q752SHI5.mjs.map +0 -1
- package/dist/chunk-YKB5DQV7.mjs.map +0 -1
- package/dist/chunk-YRUYGA4A.mjs.map +0 -1
- package/dist/chunk-Z3YYH2MB.mjs.map +0 -1
- package/src/assembly/material/pipeline-projection.ts +0 -63
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { World } from '@forgeax/engine-ecs';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { InstanceProjectionStore } from '../instances';
|
|
4
|
+
|
|
5
|
+
function matrices(x: number): Float32Array {
|
|
6
|
+
return new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, x, 0, 0, 1]);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe('renderer instance projection', () => {
|
|
10
|
+
it('derives independent complete snapshots for consumers that skip different updates', () => {
|
|
11
|
+
const world = new World();
|
|
12
|
+
const first = new InstanceProjectionStore();
|
|
13
|
+
const late = new InstanceProjectionStore();
|
|
14
|
+
const before = first.project(world, 1, matrices(0));
|
|
15
|
+
late.project(world, 1, matrices(0));
|
|
16
|
+
first.project(world, 1, matrices(1));
|
|
17
|
+
first.project(world, 1, matrices(2));
|
|
18
|
+
const latest = late.project(world, 1, matrices(2));
|
|
19
|
+
expect(before.transforms[12]).toBe(0);
|
|
20
|
+
expect(latest.transforms[12]).toBe(2);
|
|
21
|
+
const stable = late.project(world, 1, matrices(2));
|
|
22
|
+
expect(stable.transforms).toBe(latest.transforms);
|
|
23
|
+
expect(stable.revision).toBe(latest.revision);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('does not alias identical entity numbers across World identities', () => {
|
|
27
|
+
const projection = new InstanceProjectionStore();
|
|
28
|
+
const a = projection.project(new World(), 1, matrices(10));
|
|
29
|
+
const b = projection.project(new World(), 1, matrices(99));
|
|
30
|
+
expect(a.collectionId).not.toBe(b.collectionId);
|
|
31
|
+
expect(a.transforms[12]).toBe(10);
|
|
32
|
+
expect(b.transforms[12]).toBe(99);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('rebuilds from unchanged source after dropping renderer resources', () => {
|
|
36
|
+
const world = new World();
|
|
37
|
+
const transforms = matrices(4);
|
|
38
|
+
const projection = new InstanceProjectionStore();
|
|
39
|
+
projection.project(world, 1, transforms);
|
|
40
|
+
projection.dispose();
|
|
41
|
+
expect(projection.project(world, 1, transforms).transforms).toEqual(transforms);
|
|
42
|
+
expect(new InstanceProjectionStore().project(world, 1, transforms).transforms).toEqual(
|
|
43
|
+
transforms,
|
|
44
|
+
);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { HANDLE_CUBE } from '@forgeax/engine-assets-runtime';
|
|
2
|
+
import { World } from '@forgeax/engine-ecs';
|
|
3
|
+
import { registerPropagateTransforms, Transform } from '@forgeax/engine-scene';
|
|
4
|
+
import { describe, expect, it } from 'vitest';
|
|
5
|
+
import { Camera } from '../components/camera';
|
|
6
|
+
import { Instances } from '../components/instances';
|
|
7
|
+
import { MeshFilter } from '../components/mesh-filter';
|
|
8
|
+
import { MeshRenderer } from '../components/mesh-renderer';
|
|
9
|
+
import { extractFrames } from '../render-system-extract';
|
|
10
|
+
|
|
11
|
+
function matrices(count: number, x: number): Float32Array {
|
|
12
|
+
const out = new Float32Array(count * 16);
|
|
13
|
+
for (let i = 0; i < count; i++) {
|
|
14
|
+
out[i * 16] = out[i * 16 + 5] = out[i * 16 + 10] = out[i * 16 + 15] = 1;
|
|
15
|
+
out[i * 16 + 12] = x;
|
|
16
|
+
}
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function scene(count: number, x: number) {
|
|
21
|
+
const world = new World();
|
|
22
|
+
registerPropagateTransforms(world);
|
|
23
|
+
world
|
|
24
|
+
.spawn(
|
|
25
|
+
{ component: Transform, data: { pos: [0, 0, 5] } },
|
|
26
|
+
{ component: Camera, data: { fov: Math.PI / 4, aspect: 1, near: 0.1, far: 100 } },
|
|
27
|
+
)
|
|
28
|
+
.unwrap();
|
|
29
|
+
const entity = world
|
|
30
|
+
.spawn(
|
|
31
|
+
{ component: Transform, data: {} },
|
|
32
|
+
{ component: MeshFilter, data: { assetHandle: HANDLE_CUBE } },
|
|
33
|
+
{ component: MeshRenderer, data: {} },
|
|
34
|
+
{ component: Instances, data: { transforms: matrices(count, x) } },
|
|
35
|
+
)
|
|
36
|
+
.unwrap();
|
|
37
|
+
world.update(0).unwrap();
|
|
38
|
+
return { world, entity };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('World-owned instance authoring', () => {
|
|
42
|
+
it.each([1500, 10000, 20000])('extracts %s matrices without creating a Renderer', (count) => {
|
|
43
|
+
const { world } = scene(count, 0);
|
|
44
|
+
const snapshot = extractFrames([world], 0).renderables[0]?.instances;
|
|
45
|
+
expect(snapshot?.instanceCount).toBe(count);
|
|
46
|
+
expect(snapshot?.transforms).toEqual(matrices(count, 0));
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('keeps worlds isolated and lets independent consumers observe skipped updates', () => {
|
|
50
|
+
const a = scene(2, 0);
|
|
51
|
+
const b = scene(2, 1);
|
|
52
|
+
const before = extractFrames([a.world], 0).renderables[0]?.instances;
|
|
53
|
+
expect(extractFrames([b.world], 0).renderables[0]?.instances?.transforms[12]).toBe(1);
|
|
54
|
+
a.world.set(a.entity, Instances, { transforms: matrices(2, 0.5) }).unwrap();
|
|
55
|
+
extractFrames([a.world], 0);
|
|
56
|
+
a.world.set(a.entity, Instances, { transforms: matrices(2, -0.5) }).unwrap();
|
|
57
|
+
const late = extractFrames([a.world], 0).renderables[0]?.instances;
|
|
58
|
+
expect(late?.transforms[12]).toBe(-0.5);
|
|
59
|
+
expect(before?.transforms[12]).toBe(0);
|
|
60
|
+
expect(extractFrames([b.world], 0).renderables[0]?.instances?.transforms[12]).toBe(1);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -2,10 +2,12 @@ import {
|
|
|
2
2
|
createBuiltinMaterialAsset,
|
|
3
3
|
DEFAULT_STANDARD_PBR_PARAM_SCHEMA,
|
|
4
4
|
DEFAULT_UNLIT_PARAM_SCHEMA,
|
|
5
|
+
STANDARD_PIPELINE_PARAM_SCHEMA,
|
|
5
6
|
} from '@forgeax/engine-shader';
|
|
6
7
|
import { resolveMaterialAsset } from '@forgeax/engine-types';
|
|
7
8
|
import { describe, expect, it } from 'vitest';
|
|
8
9
|
import { Materials } from '../materials.js';
|
|
10
|
+
import { materialParametersToParamSchema } from '../render-system-extract.js';
|
|
9
11
|
|
|
10
12
|
describe('vertex color does not change the material contract', () => {
|
|
11
13
|
it('keeps colored and plain materials on the same schema and resource identity', () => {
|
|
@@ -62,25 +64,58 @@ describe('vertex color does not change the material contract', () => {
|
|
|
62
64
|
expect(material.values).toHaveProperty('ior', 1.5);
|
|
63
65
|
});
|
|
64
66
|
|
|
67
|
+
it('routes canonical Standard roots through the complete runtime UBO schema', () => {
|
|
68
|
+
const material = Materials.standard({ baseColor: [1, 1, 1, 1] });
|
|
69
|
+
const authoredSchema = materialParametersToParamSchema(material.parameters ?? []);
|
|
70
|
+
const canonicalSchema = materialParametersToParamSchema(
|
|
71
|
+
material.parameters ?? [],
|
|
72
|
+
'forgeax::default-standard-pbr',
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
expect(authoredSchema.map((entry) => entry.name)).toEqual(
|
|
76
|
+
material.parameters?.map((parameter) => parameter.name),
|
|
77
|
+
);
|
|
78
|
+
expect(canonicalSchema).toBe(STANDARD_PIPELINE_PARAM_SCHEMA);
|
|
79
|
+
expect(authoredSchema).not.toBe(STANDARD_PIPELINE_PARAM_SCHEMA);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('publishes the complete Standard contract when authoring a custom Surface', () => {
|
|
83
|
+
const custom = Materials.standard({
|
|
84
|
+
surfaceModule: 'project::paint',
|
|
85
|
+
parameters: [{ name: 'paintStrength', type: 'f32', default: 0.75 }],
|
|
86
|
+
values: { paintStrength: 0.5 },
|
|
87
|
+
});
|
|
88
|
+
const names = custom.parameters?.map((parameter) => parameter.name) ?? [];
|
|
89
|
+
expect(names).toEqual(
|
|
90
|
+
expect.arrayContaining([
|
|
91
|
+
'baseColor',
|
|
92
|
+
'metallic',
|
|
93
|
+
'roughness',
|
|
94
|
+
'baseColorTexture',
|
|
95
|
+
'paintStrength',
|
|
96
|
+
]),
|
|
97
|
+
);
|
|
98
|
+
expect(new Set(names).size).toBe(names.length);
|
|
99
|
+
expect(custom.parameters?.find((parameter) => parameter.name === 'paintStrength')).toEqual({
|
|
100
|
+
name: 'paintStrength',
|
|
101
|
+
type: 'f32',
|
|
102
|
+
default: 0.75,
|
|
103
|
+
});
|
|
104
|
+
expect(custom.values).toEqual({ paintStrength: 0.5 });
|
|
105
|
+
expect(names).not.toContain('clearcoat');
|
|
106
|
+
});
|
|
107
|
+
|
|
65
108
|
it('routes active transmission to Forward and ShadowCaster only', () => {
|
|
66
109
|
const material = Materials.standard({ baseColor: [1, 1, 1, 1], transmission: 0.5 });
|
|
67
110
|
expect(material.passes?.map((pass) => pass.name)).toEqual(['forward', 'shadow-caster']);
|
|
68
111
|
expect(material.passes?.[0]?.renderState).toMatchObject({ depthWriteEnabled: false });
|
|
69
112
|
});
|
|
70
113
|
|
|
71
|
-
it
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
[
|
|
77
|
-
'standard',
|
|
78
|
-
Materials.standard({
|
|
79
|
-
baseColor: [1, 1, 1, 1],
|
|
80
|
-
renderState: { cullMode: 'none', frontFace: 'cw' },
|
|
81
|
-
}),
|
|
82
|
-
],
|
|
83
|
-
] as const)('projects the %s material raster orientation into its ShadowCaster pass', (_name, material) => {
|
|
114
|
+
it('projects the standard material raster orientation into its ShadowCaster pass', () => {
|
|
115
|
+
const material = Materials.standard({
|
|
116
|
+
baseColor: [1, 1, 1, 1],
|
|
117
|
+
renderState: { cullMode: 'none', frontFace: 'cw' },
|
|
118
|
+
});
|
|
84
119
|
const shadowCaster = material.passes?.find((entry) => entry.name === 'shadow-caster');
|
|
85
120
|
expect(shadowCaster?.renderState).toMatchObject({ cullMode: 'none', frontFace: 'cw' });
|
|
86
121
|
});
|
|
@@ -1,14 +1,10 @@
|
|
|
1
1
|
import type { CookedMaterialRecord } from '@forgeax/engine-pack';
|
|
2
2
|
import { deriveStandardLayerPlan } from '@forgeax/engine-types';
|
|
3
|
-
import { describe, expect, it
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
4
|
import { assembleMaterialProjection } from '../assembly/material/assembly.js';
|
|
5
|
-
import {
|
|
6
|
-
routeMaterialPipeline,
|
|
7
|
-
updateMaterialRuntimeValues,
|
|
8
|
-
} from '../assembly/material/pipeline-projection.js';
|
|
9
5
|
|
|
10
6
|
const record: CookedMaterialRecord = {
|
|
11
|
-
schemaVersion: 'material-cook/
|
|
7
|
+
schemaVersion: 'material-cook/4',
|
|
12
8
|
guid: 'material-route',
|
|
13
9
|
specializationKey: 'route-key',
|
|
14
10
|
authored: {
|
|
@@ -24,14 +20,34 @@ const record: CookedMaterialRecord = {
|
|
|
24
20
|
values: { baseColor: [1, 1, 1, 1], useNormalMap: false },
|
|
25
21
|
},
|
|
26
22
|
refs: { parent: [], textures: [], samplers: [], modules: ['project::standard'] },
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
23
|
+
programs: [
|
|
24
|
+
{
|
|
25
|
+
specializationKey: 'route-program',
|
|
26
|
+
artifact: {
|
|
27
|
+
mediaType: 'text/wgsl',
|
|
28
|
+
path: 'material-route.wgsl',
|
|
29
|
+
digest: 'sha256:route',
|
|
30
|
+
bytes: new Uint8Array([4]),
|
|
31
|
+
},
|
|
32
|
+
selections: [
|
|
33
|
+
{
|
|
34
|
+
pass: 'forward',
|
|
35
|
+
context: {
|
|
36
|
+
backend: 'webgpu',
|
|
37
|
+
capability: 'storage-buffer',
|
|
38
|
+
pipeline: 'forward',
|
|
39
|
+
geometry: 'mesh',
|
|
40
|
+
pass: 'forward',
|
|
41
|
+
profile: 'forgeax-material-wgsl-v1',
|
|
42
|
+
toolchain: 'naga-oil',
|
|
43
|
+
instrumentation: 'none',
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
],
|
|
33
49
|
receipt: {
|
|
34
|
-
schemaVersion: 'material-cook/
|
|
50
|
+
schemaVersion: 'material-cook/4',
|
|
35
51
|
sourceClosure: ['project::standard'],
|
|
36
52
|
profile: 'forgeax-material-wgsl-v1',
|
|
37
53
|
compilerVersion: 'compiler/1',
|
|
@@ -59,7 +75,7 @@ const record: CookedMaterialRecord = {
|
|
|
59
75
|
},
|
|
60
76
|
};
|
|
61
77
|
|
|
62
|
-
describe('cooked material
|
|
78
|
+
describe('cooked material layer projection', () => {
|
|
63
79
|
it('rejects a stale Standard layer plan before render projection', () => {
|
|
64
80
|
const standardPass = { name: 'forward', program: { module: 'forgeax::default-standard-pbr' } };
|
|
65
81
|
const standardResolved = { ...record.resolved, passes: [standardPass] };
|
|
@@ -87,37 +103,4 @@ describe('cooked material static and dynamic routing', () => {
|
|
|
87
103
|
}),
|
|
88
104
|
).toThrow('Standard material layer-plan identity mismatch');
|
|
89
105
|
});
|
|
90
|
-
|
|
91
|
-
it('updates runtime values without requesting a compile or changing the artifact key', () => {
|
|
92
|
-
const projection = assembleMaterialProjection(record);
|
|
93
|
-
const updated = updateMaterialRuntimeValues(projection, {
|
|
94
|
-
baseColor: [0.25, 0.5, 0.75, 1],
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
expect(updated.runtimeValues.baseColor).toEqual([0.25, 0.5, 0.75, 1]);
|
|
98
|
-
expect(updated.specializationKey).toBe(projection.specializationKey);
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
it('selects a cooked static specialization and returns a structured miss', () => {
|
|
102
|
-
const projection = assembleMaterialProjection(record);
|
|
103
|
-
const lookup = vi.fn((key: string) =>
|
|
104
|
-
key === 'route-key' ? { key, bytes: new Uint8Array([4]), digest: 'sha256:route' } : undefined,
|
|
105
|
-
);
|
|
106
|
-
|
|
107
|
-
const ready = routeMaterialPipeline({ projection, lookupArtifact: lookup });
|
|
108
|
-
expect(ready).toMatchObject({ kind: 'ready', specializationKey: 'route-key' });
|
|
109
|
-
expect(lookup).toHaveBeenCalledWith('route-key');
|
|
110
|
-
|
|
111
|
-
const missing = routeMaterialPipeline({
|
|
112
|
-
projection: { ...projection, specializationKey: 'uncooked-static-selection' },
|
|
113
|
-
lookupArtifact: lookup,
|
|
114
|
-
});
|
|
115
|
-
expect(missing).toMatchObject({
|
|
116
|
-
kind: 'error',
|
|
117
|
-
error: {
|
|
118
|
-
code: 'material-specialization-not-cooked',
|
|
119
|
-
detail: { specializationKey: 'uncooked-static-selection' },
|
|
120
|
-
},
|
|
121
|
-
});
|
|
122
|
-
});
|
|
123
106
|
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { Materials } from '../materials';
|
|
3
|
+
|
|
4
|
+
const surface = {
|
|
5
|
+
surfaceModule: 'game_3d::rusted_iron_surface',
|
|
6
|
+
parameters: [
|
|
7
|
+
{ name: 'ironColor', type: 'color' as const },
|
|
8
|
+
{ name: 'alphaClipThreshold', type: 'f32' as const, optional: true },
|
|
9
|
+
],
|
|
10
|
+
values: { ironColor: [0.4, 0.45, 0.47, 1], alphaClipThreshold: 0.5 },
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
describe('custom Standard Surface pass policy', () => {
|
|
14
|
+
it('keeps opaque and alpha-clip custom surfaces on Forward, Deferred, and ShadowCaster', () => {
|
|
15
|
+
const material = Materials.standard(surface as never);
|
|
16
|
+
expect(material.passes?.map((pass) => pass.name)).toEqual([
|
|
17
|
+
'forward',
|
|
18
|
+
'deferred',
|
|
19
|
+
'shadow-caster',
|
|
20
|
+
]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('does not create Deferred for an explicitly blended custom surface', () => {
|
|
24
|
+
const material = Materials.standard({
|
|
25
|
+
...surface,
|
|
26
|
+
renderState: { blend: { color: {}, alpha: {} } },
|
|
27
|
+
} as never);
|
|
28
|
+
expect(material.passes?.map((pass) => pass.name)).not.toContain('deferred');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('does not create Deferred when the effective root declares a physical layer', () => {
|
|
32
|
+
const material = Materials.standard({
|
|
33
|
+
...surface,
|
|
34
|
+
parameters: [
|
|
35
|
+
{ name: 'clearcoat', type: 'f32' },
|
|
36
|
+
{ name: 'clearcoatRoughness', type: 'f32' },
|
|
37
|
+
],
|
|
38
|
+
values: { clearcoat: 0, clearcoatRoughness: 0.5 },
|
|
39
|
+
} as never);
|
|
40
|
+
expect(material.passes?.map((pass) => pass.name)).toEqual(['forward', 'shadow-caster']);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('keeps optional physical fields inactive until the root makes the fragment effective', () => {
|
|
44
|
+
const material = Materials.standard({
|
|
45
|
+
...surface,
|
|
46
|
+
parameters: [
|
|
47
|
+
{ name: 'clearcoat', type: 'f32', optional: true },
|
|
48
|
+
{ name: 'clearcoatRoughness', type: 'f32', optional: true },
|
|
49
|
+
],
|
|
50
|
+
values: { clearcoat: 0, clearcoatRoughness: 0.5 },
|
|
51
|
+
} as never);
|
|
52
|
+
expect(material.passes?.map((pass) => pass.name)).toEqual([
|
|
53
|
+
'forward',
|
|
54
|
+
'deferred',
|
|
55
|
+
'shadow-caster',
|
|
56
|
+
]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('projects an explicitly declared default clearcoat root as Forward-only', () => {
|
|
60
|
+
const material = Materials.standard({
|
|
61
|
+
baseColor: [0.28, 0.035, 0.075, 1],
|
|
62
|
+
metallic: 0.12,
|
|
63
|
+
roughness: 0.18,
|
|
64
|
+
clearcoat: 0.82,
|
|
65
|
+
clearcoatRoughness: 0.08,
|
|
66
|
+
});
|
|
67
|
+
expect(material.passes?.map((pass) => pass.name)).toEqual(['forward', 'shadow-caster']);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { EntityHandle } from '@forgeax/engine-ecs';
|
|
2
|
+
import type { MaterialPass } from '@forgeax/engine-types';
|
|
3
|
+
import { expect, it } from 'vitest';
|
|
4
|
+
import { appendMaterialDispatchEntries, type DispatchEntry } from '../render-system-extract.js';
|
|
5
|
+
|
|
6
|
+
it('dispatches each Pass with its published program, including an Engine-authored shadow', () => {
|
|
7
|
+
const passes: MaterialPass[] = [
|
|
8
|
+
{
|
|
9
|
+
name: 'Forward',
|
|
10
|
+
program: { module: 'game::toon', vertexEntry: 'vs_main', fragmentEntry: 'fs_toon' },
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
name: 'ShadowCaster',
|
|
14
|
+
program: { module: 'forgeax::default-shadow-caster', vertexEntry: 'vs_shadow' },
|
|
15
|
+
renderState: { tags: { LightMode: 'ShadowCaster' } },
|
|
16
|
+
},
|
|
17
|
+
];
|
|
18
|
+
const dispatch: DispatchEntry[] = [];
|
|
19
|
+
appendMaterialDispatchEntries(dispatch, passes, 1 as EntityHandle, 7, 0, 0, { amount: 0 }, 0, {
|
|
20
|
+
Forward: 'cooked-toon',
|
|
21
|
+
ShadowCaster: 'cooked-shadow',
|
|
22
|
+
});
|
|
23
|
+
expect(
|
|
24
|
+
dispatch.map((entry) => [entry.materialShaderId, entry.vertexEntry, entry.fragmentEntry]),
|
|
25
|
+
).toEqual([
|
|
26
|
+
['cooked-toon', 'vs_main', 'fs_toon'],
|
|
27
|
+
['cooked-shadow', 'vs_shadow', undefined],
|
|
28
|
+
]);
|
|
29
|
+
expect(
|
|
30
|
+
dispatch.every((entry) => entry.materialHandle === 7 && entry.paramSnapshot?.amount === 0),
|
|
31
|
+
).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('refuses an incomplete published Pass map instead of using an authored shader', () => {
|
|
35
|
+
expect(() =>
|
|
36
|
+
appendMaterialDispatchEntries(
|
|
37
|
+
[],
|
|
38
|
+
[{ name: 'ShadowCaster', program: { module: 'forgeax::default-shadow-caster' } }],
|
|
39
|
+
1 as EntityHandle,
|
|
40
|
+
7,
|
|
41
|
+
0,
|
|
42
|
+
0,
|
|
43
|
+
{},
|
|
44
|
+
0,
|
|
45
|
+
{ Forward: 'cooked-toon' },
|
|
46
|
+
),
|
|
47
|
+
).toThrow('Missing published material program');
|
|
48
|
+
});
|
|
@@ -3,7 +3,9 @@ import { World } from '@forgeax/engine-ecs';
|
|
|
3
3
|
import { Transform } from '@forgeax/engine-scene';
|
|
4
4
|
import type { ShaderRegistry } from '@forgeax/engine-shader';
|
|
5
5
|
import type { MaterialAsset } from '@forgeax/engine-types';
|
|
6
|
+
import { deriveStandardLayerPlan } from '@forgeax/engine-types';
|
|
6
7
|
import { describe, expect, it } from 'vitest';
|
|
8
|
+
import { projectStandardSurfacePasses } from '../assembly/material/surface-projection';
|
|
7
9
|
import { MeshFilter } from '../components/mesh-filter';
|
|
8
10
|
import { MeshRenderer } from '../components/mesh-renderer';
|
|
9
11
|
import { extractFrames, type MaterialSnapshotCachesByWorld } from '../render-system-extract';
|
|
@@ -50,4 +52,71 @@ describe('material snapshot mutation', () => {
|
|
|
50
52
|
expect(second?.baseColor).toEqual(new Float32Array([0, 1, 0]));
|
|
51
53
|
expect(second).not.toBe(first);
|
|
52
54
|
});
|
|
55
|
+
|
|
56
|
+
it('keeps the pipeline identity stable for a value-only child update', () => {
|
|
57
|
+
const world = new World();
|
|
58
|
+
const assets = new AssetRegistry({
|
|
59
|
+
findMaterialArtifact: () => ({ ok: false, error: new Error('not registered') }),
|
|
60
|
+
} as unknown as ShaderRegistry);
|
|
61
|
+
const values: Record<string, unknown> = { baseColor: [1, 0, 0, 1] };
|
|
62
|
+
const material: MaterialAsset = {
|
|
63
|
+
kind: 'material',
|
|
64
|
+
passes: [
|
|
65
|
+
{
|
|
66
|
+
name: 'Forward',
|
|
67
|
+
program: { module: 'forgeax_material::standard' },
|
|
68
|
+
renderState: { tags: { LightMode: 'Forward' }, queue: 2000 },
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
values: values as NonNullable<MaterialAsset['values']>,
|
|
72
|
+
};
|
|
73
|
+
const materialHandle = world.allocSharedRef('MaterialAsset', material);
|
|
74
|
+
world.spawn(
|
|
75
|
+
{ component: Transform, data: {} },
|
|
76
|
+
{ component: MeshFilter, data: { assetHandle: HANDLE_CUBE } },
|
|
77
|
+
{ component: MeshRenderer, data: { materials: [materialHandle] } },
|
|
78
|
+
);
|
|
79
|
+
const materialCaches: MaterialSnapshotCachesByWorld = new WeakMap();
|
|
80
|
+
const first = extractFrames([world], 0, assets, undefined, materialCaches).renderables[0];
|
|
81
|
+
values.baseColor = [0, 1, 0, 1];
|
|
82
|
+
const second = extractFrames([world], 0, assets, undefined, materialCaches).renderables[0];
|
|
83
|
+
|
|
84
|
+
expect(first?.gpuDrivenDraws?.[0]?.pipelineClass).toBe(
|
|
85
|
+
second?.gpuDrivenDraws?.[0]?.pipelineClass,
|
|
86
|
+
);
|
|
87
|
+
expect(first?.material?.materialShaderId).toBe(second?.material?.materialShaderId);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('falsifies legacy surface, physical deferred, and forward fallback projections', () => {
|
|
91
|
+
const basePlan = deriveStandardLayerPlan([]);
|
|
92
|
+
const physicalPlan = deriveStandardLayerPlan([
|
|
93
|
+
{ name: 'clearcoat', type: 'f32' },
|
|
94
|
+
{ name: 'clearcoatRoughness', type: 'f32' },
|
|
95
|
+
]);
|
|
96
|
+
|
|
97
|
+
const base = projectStandardSurfacePasses({
|
|
98
|
+
surfaceModule: 'game_3d::rusted_iron_surface',
|
|
99
|
+
values: {},
|
|
100
|
+
layerPlan: basePlan,
|
|
101
|
+
geometryVariant: 'rigid',
|
|
102
|
+
lightingLane: 'direct',
|
|
103
|
+
});
|
|
104
|
+
const physical = projectStandardSurfacePasses({
|
|
105
|
+
surfaceModule: 'game_3d::rusted_iron_surface',
|
|
106
|
+
values: {},
|
|
107
|
+
layerPlan: physicalPlan,
|
|
108
|
+
geometryVariant: 'rigid',
|
|
109
|
+
lightingLane: 'direct',
|
|
110
|
+
});
|
|
111
|
+
const legacy = base.map((entry) => ({
|
|
112
|
+
...entry,
|
|
113
|
+
program: { ...entry.program, moduleSlots: { surface: 'legacy::surface_data' } },
|
|
114
|
+
}));
|
|
115
|
+
const physicalDeferred = physical.some((entry) => entry.name === 'deferred');
|
|
116
|
+
const forwardFallback = base.some((entry) => entry.name === 'forward');
|
|
117
|
+
|
|
118
|
+
expect(legacy[0]?.program.moduleSlots?.surface).not.toBe('game_3d::rusted_iron_surface');
|
|
119
|
+
expect(physicalDeferred).toBe(false);
|
|
120
|
+
expect(forwardFallback).toBe(true);
|
|
121
|
+
});
|
|
53
122
|
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { MaterialAsset } from '@forgeax/engine-types';
|
|
2
|
+
import { describe, expectTypeOf, it } from 'vitest';
|
|
3
|
+
import {
|
|
4
|
+
type CustomStandardSurfaceOptions,
|
|
5
|
+
type DefaultStandardOptions,
|
|
6
|
+
Materials,
|
|
7
|
+
type StandardOptions,
|
|
8
|
+
} from '../materials';
|
|
9
|
+
|
|
10
|
+
const parameters = [{ name: 'ironColor', type: 'color' as const }];
|
|
11
|
+
const values = { ironColor: [0.4, 0.45, 0.47, 1] as const };
|
|
12
|
+
|
|
13
|
+
describe('Standard Surface authoring union', () => {
|
|
14
|
+
it('exposes one callable Parameters union for default and custom options', () => {
|
|
15
|
+
const defaultOptions = { baseColor: [1, 1, 1, 1] as const } satisfies DefaultStandardOptions;
|
|
16
|
+
const customOptions = {
|
|
17
|
+
surfaceModule: 'game_3d::rusted_iron_surface',
|
|
18
|
+
parameters,
|
|
19
|
+
values,
|
|
20
|
+
} satisfies CustomStandardSurfaceOptions;
|
|
21
|
+
expectTypeOf<Parameters<typeof Materials.standard>[0]>().toEqualTypeOf<StandardOptions>();
|
|
22
|
+
|
|
23
|
+
function acceptStandard<T extends StandardOptions>(options: T): T {
|
|
24
|
+
return options;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
acceptStandard(defaultOptions);
|
|
28
|
+
acceptStandard(customOptions);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('accepts a custom Surface without convenience fields', () => {
|
|
32
|
+
const options = {
|
|
33
|
+
surfaceModule: 'game_3d::rusted_iron_surface',
|
|
34
|
+
parameters,
|
|
35
|
+
values,
|
|
36
|
+
castShadow: true,
|
|
37
|
+
} satisfies CustomStandardSurfaceOptions;
|
|
38
|
+
const material = Materials.standard(options);
|
|
39
|
+
expectTypeOf(material.passes?.[0]?.program.moduleSlots?.surface).toBeString;
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('keeps default and custom options mutually exclusive', () => {
|
|
43
|
+
expectTypeOf<DefaultStandardOptions>().toHaveProperty('baseColor');
|
|
44
|
+
Materials.standard({
|
|
45
|
+
surfaceModule: 'game_3d::rusted_iron_surface',
|
|
46
|
+
parameters,
|
|
47
|
+
values,
|
|
48
|
+
// @ts-expect-error Custom Surface options cannot add Standard convenience fields.
|
|
49
|
+
baseColor: [1, 1, 1, 1],
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('keeps custom Surface parameters sparse and root-owned', () => {
|
|
54
|
+
const custom = Materials.standard({
|
|
55
|
+
surfaceModule: 'game_3d::rusted_iron_surface',
|
|
56
|
+
parameters,
|
|
57
|
+
values,
|
|
58
|
+
});
|
|
59
|
+
expectTypeOf(custom.parameters).toEqualTypeOf<MaterialAsset['parameters']>();
|
|
60
|
+
expectTypeOf(custom.values).toEqualTypeOf<MaterialAsset['values']>();
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -33,4 +33,28 @@ describe('Materials.standard transmission validation', () => {
|
|
|
33
33
|
}),
|
|
34
34
|
).toThrow(MaterialTransmissionContractError);
|
|
35
35
|
});
|
|
36
|
+
|
|
37
|
+
it('preserves the typed physical contract error for incomplete custom layers', () => {
|
|
38
|
+
const options = {
|
|
39
|
+
surfaceModule: 'game_3d::custom_surface',
|
|
40
|
+
parameters: [{ name: 'clearcoat', type: 'f32' as const }],
|
|
41
|
+
values: { clearcoat: 0.5 },
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
expect(() => Materials.standard(options as never)).toThrow(Error);
|
|
45
|
+
try {
|
|
46
|
+
Materials.standard(options as never);
|
|
47
|
+
} catch (error) {
|
|
48
|
+
expect(error).toMatchObject({
|
|
49
|
+
code: 'material-physical-contract-invalid',
|
|
50
|
+
detail: {
|
|
51
|
+
code: 'material-physical-contract-invalid',
|
|
52
|
+
material: 'Standard',
|
|
53
|
+
layer: 'clearcoat',
|
|
54
|
+
missing: ['clearcoatRoughness'],
|
|
55
|
+
reason: 'incomplete-layer',
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
});
|
|
36
60
|
});
|
|
@@ -47,10 +47,6 @@ const featureBufferSources = [
|
|
|
47
47
|
source: readFileSync(new URL('../record/main-pass-sprite-draws.ts', import.meta.url), 'utf8'),
|
|
48
48
|
names: ['GPU_BUFFER_USAGE_STORAGE', 'GPU_BUFFER_USAGE_UNIFORM', 'GPU_BUFFER_USAGE_COPY_DST'],
|
|
49
49
|
},
|
|
50
|
-
{
|
|
51
|
-
source: readFileSync(new URL('../record/shadow-pass.ts', import.meta.url), 'utf8'),
|
|
52
|
-
names: ['GPU_BUFFER_USAGE_STORAGE', 'GPU_BUFFER_USAGE_UNIFORM', 'GPU_BUFFER_USAGE_COPY_DST'],
|
|
53
|
-
},
|
|
54
50
|
{
|
|
55
51
|
source: readFileSync(new URL('../systems/skin-palette-allocator.ts', import.meta.url), 'utf8'),
|
|
56
52
|
names: ['GPU_BUFFER_USAGE_STORAGE', 'GPU_BUFFER_USAGE_UNIFORM', 'GPU_BUFFER_USAGE_COPY_DST'],
|
|
@@ -8,7 +8,9 @@ import {
|
|
|
8
8
|
allowsUnlitPreparedFallback,
|
|
9
9
|
isSharedMaterialUserRegionCompatible,
|
|
10
10
|
normalizeMaterialShaderVariantSet,
|
|
11
|
+
resolveMaterialShaderBackendArtifactKey,
|
|
11
12
|
resolveMaterialShaderBindingContract,
|
|
13
|
+
resolveMaterialShaderUvSetCount,
|
|
12
14
|
resolveMaterialShaderVariantSet,
|
|
13
15
|
resolveMaterialShaderVertexInputContract,
|
|
14
16
|
selectNoColorPbrVariant,
|
|
@@ -390,6 +392,31 @@ describe('material shader variant identity', () => {
|
|
|
390
392
|
).toBe('STORAGE_BUFFER_AVAILABLE=true');
|
|
391
393
|
expect(normalizeMaterialShaderVariantSet('CUSTOM=true', undefined)).toBe('CUSTOM=true');
|
|
392
394
|
});
|
|
395
|
+
|
|
396
|
+
it('projects a published Surface artifact to its WebGL2 capability alias', () => {
|
|
397
|
+
const base = 'surface-specialization';
|
|
398
|
+
const webgl2 = 'forgeax::surface-variant::surface-specialization::webgl2::uniform-fallback';
|
|
399
|
+
const artifact = {
|
|
400
|
+
metadata: {
|
|
401
|
+
variants: [
|
|
402
|
+
{
|
|
403
|
+
backend: 'webgpu',
|
|
404
|
+
capability: 'storage-buffer',
|
|
405
|
+
specializationKey: base,
|
|
406
|
+
},
|
|
407
|
+
{
|
|
408
|
+
backend: 'webgl2',
|
|
409
|
+
capability: 'uniform-fallback',
|
|
410
|
+
specializationKey: webgl2,
|
|
411
|
+
},
|
|
412
|
+
],
|
|
413
|
+
},
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
expect(resolveMaterialShaderBackendArtifactKey(base, 'wgpu-webgl2', artifact)).toBe(webgl2);
|
|
417
|
+
expect(resolveMaterialShaderBackendArtifactKey(base, 'webgpu', artifact)).toBe(base);
|
|
418
|
+
expect(resolveMaterialShaderBackendArtifactKey(base, 'wgpu-webgl2', undefined)).toBe(base);
|
|
419
|
+
});
|
|
393
420
|
});
|
|
394
421
|
|
|
395
422
|
describe('temporal material PSO creation contract', () => {
|
|
@@ -601,10 +628,10 @@ describe('material shader binding contract', () => {
|
|
|
601
628
|
'utf8',
|
|
602
629
|
);
|
|
603
630
|
const projectionSource = readFileSync(
|
|
604
|
-
resolve(import.meta.dirname, '../assembly/material/
|
|
631
|
+
resolve(import.meta.dirname, '../assembly/material/assembly.ts'),
|
|
605
632
|
'utf8',
|
|
606
633
|
);
|
|
607
|
-
expect(projectionSource).toContain('
|
|
634
|
+
expect(projectionSource).toContain('projectMaterialRecord');
|
|
608
635
|
expect(recordSource).not.toMatch(/internals\.assets\.get<MaterialAsset>/);
|
|
609
636
|
expect(recordSource).not.toMatch(/firstMaterial as \{/);
|
|
610
637
|
expect(recordSource).not.toContain('baseColorHandle');
|
|
@@ -754,6 +781,21 @@ describe('material shader vertex input contract', () => {
|
|
|
754
781
|
expect(resolveMaterialShaderVertexInputContract(source)).toBe('none');
|
|
755
782
|
});
|
|
756
783
|
|
|
784
|
+
it('derives authored Standard UV aliases from the vertex input struct', () => {
|
|
785
|
+
const source = `
|
|
786
|
+
struct VsIn {
|
|
787
|
+
@location(0) position: vec3<f32>,
|
|
788
|
+
@location(2) uv: vec2<f32>,
|
|
789
|
+
@location(6) uv1_: vec2<f32>,
|
|
790
|
+
@location(12) uv7_: vec2<f32>,
|
|
791
|
+
}
|
|
792
|
+
struct VsOut { @builtin(position) position: vec4<f32> }
|
|
793
|
+
@vertex fn vs_main(input: VsIn) -> VsOut { var out: VsOut; return out }
|
|
794
|
+
`;
|
|
795
|
+
|
|
796
|
+
expect(resolveMaterialShaderUvSetCount(source)).toBe(8);
|
|
797
|
+
});
|
|
798
|
+
|
|
757
799
|
it('keeps an explicit prepared vertex layout on a missing shader', () => {
|
|
758
800
|
expect(allowsUnlitPreparedFallback(null, undefined)).toBe(true);
|
|
759
801
|
expect(allowsUnlitPreparedFallback(null, 'position-size-color-instance', 'forward')).toBe(true);
|