@forgeax/engine-render 0.1.24 → 0.1.25
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 +100 -1
- package/dist/assembly/factory.d.ts +1 -1
- package/dist/assembly/factory.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 -1
- 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-KZFTK6DO.mjs → chunk-HKXTW355.mjs} +7 -4
- package/dist/chunk-HKXTW355.mjs.map +1 -0
- package/dist/{chunk-YKB5DQV7.mjs → chunk-JSEDTZYS.mjs} +87 -19
- package/dist/chunk-JSEDTZYS.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-Z3YYH2MB.mjs → chunk-N3RT2EUR.mjs} +94 -41
- package/dist/chunk-N3RT2EUR.mjs.map +1 -0
- package/dist/{chunk-YRUYGA4A.mjs → chunk-TCLDX7SZ.mjs} +559 -138
- package/dist/chunk-TCLDX7SZ.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 +865 -142
- package/dist/construct-renderer.mjs.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.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 +22 -3
- package/dist/record/main-pass-geometry.d.ts.map +1 -1
- package/dist/record/render-context.d.ts +2 -1
- package/dist/record/render-context.d.ts.map +1 -1
- package/dist/record/shadow-pass.d.ts +11 -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 +4 -0
- package/dist/render-system-extract-tail.d.ts.map +1 -1
- package/dist/render-system-extract.d.ts +25 -1
- 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__/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 +5 -13
- package/src/__tests__/material-pass-policy.unit.test.ts +69 -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 +42 -0
- 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 -0
- 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/surface-projection.ts +90 -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 +90 -158
- 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/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 +107 -12
- package/src/pipeline-builder.ts +14 -10
- package/src/pipeline-spec.ts +6 -14
- package/src/record/frame-snapshot.ts +19 -2
- package/src/record/frame.ts +31 -2
- package/src/record/main-pass-geometry.ts +498 -59
- package/src/record/main-pass-sprite-draws.ts +1 -1
- package/src/record/render-context.ts +4 -0
- package/src/record/shadow-pass.ts +46 -99
- package/src/recovery/render-system-candidate.ts +41 -4
- package/src/render-contract.ts +4 -0
- package/src/render-system-extract-tail.ts +107 -51
- package/src/render-system-extract.ts +53 -26
- package/src/render-system.ts +40 -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/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
|
@@ -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
|
+
});
|
|
@@ -68,19 +68,11 @@ describe('vertex color does not change the material contract', () => {
|
|
|
68
68
|
expect(material.passes?.[0]?.renderState).toMatchObject({ depthWriteEnabled: false });
|
|
69
69
|
});
|
|
70
70
|
|
|
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) => {
|
|
71
|
+
it('projects the standard material raster orientation into its ShadowCaster pass', () => {
|
|
72
|
+
const material = Materials.standard({
|
|
73
|
+
baseColor: [1, 1, 1, 1],
|
|
74
|
+
renderState: { cullMode: 'none', frontFace: 'cw' },
|
|
75
|
+
});
|
|
84
76
|
const shadowCaster = material.passes?.find((entry) => entry.name === 'shadow-caster');
|
|
85
77
|
expect(shadowCaster?.renderState).toMatchObject({ cullMode: 'none', frontFace: 'cw' });
|
|
86
78
|
});
|
|
@@ -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
|
+
});
|
|
@@ -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', () => {
|
|
@@ -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);
|
|
@@ -45,6 +45,39 @@ describe('custom skinned mesh motion consumer contract', () => {
|
|
|
45
45
|
expect(writes[1]?.data[12]).toBe(0.5);
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
+
it('keeps stable uniform palette slices isolated across frame-pool resets', () => {
|
|
49
|
+
const device = {
|
|
50
|
+
limits: { maxUniformBufferBindingSize: 65536 },
|
|
51
|
+
createBuffer: () => ok({} as Buffer),
|
|
52
|
+
destroyBuffer: () => undefined,
|
|
53
|
+
queue: { writeBuffer: () => ok(undefined) },
|
|
54
|
+
} as unknown as RhiDevice;
|
|
55
|
+
const allocator = createSkinPaletteAllocator(device, 65536, false);
|
|
56
|
+
const first = allocator.allocateSliceFor('world:1', 1);
|
|
57
|
+
allocator.resetForFrame();
|
|
58
|
+
const second = allocator.allocateSliceFor('world:2', 1);
|
|
59
|
+
|
|
60
|
+
expect(second.buffer).not.toBe(first.buffer);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('does not reuse a split storage range beyond its binding window', () => {
|
|
64
|
+
let bufferId = 0;
|
|
65
|
+
const device = {
|
|
66
|
+
limits: { maxStorageBufferBindingSize: 65536 },
|
|
67
|
+
createBuffer: () => ok({ id: bufferId++ } as unknown as Buffer),
|
|
68
|
+
queue: { writeBuffer: () => ok(undefined) },
|
|
69
|
+
} as unknown as RhiDevice;
|
|
70
|
+
const allocator = createSkinPaletteAllocator(device, 65536, true);
|
|
71
|
+
|
|
72
|
+
const full = allocator.allocateSliceFor('world:full', 255);
|
|
73
|
+
allocator.releaseSliceFor('world:full');
|
|
74
|
+
const firstSmall = allocator.allocateSliceFor('world:first', 1);
|
|
75
|
+
const secondSmall = allocator.allocateSliceFor('world:second', 1);
|
|
76
|
+
|
|
77
|
+
expect(firstSmall.buffer).toBe(full.buffer);
|
|
78
|
+
expect(secondSmall.buffer).not.toBe(full.buffer);
|
|
79
|
+
});
|
|
80
|
+
|
|
48
81
|
it('prepares both URP and HDRP pbr-skin consumers with the skin-cluster contract', () => {
|
|
49
82
|
const variants = selectSkinPrewarmVariants(
|
|
50
83
|
{
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
SHADOW_CASTER_SHADER_ID,
|
|
10
10
|
shadowCasterVariantSet,
|
|
11
11
|
} from '../pbr-pipeline.js';
|
|
12
|
+
import { shadowShaderMap } from '../record/shadow-pass.js';
|
|
12
13
|
|
|
13
14
|
describe('skinned shadow caster', () => {
|
|
14
15
|
it('derives closed capability variants without changing the shader identity', () => {
|
|
@@ -52,8 +53,7 @@ describe('skinned shadow caster', () => {
|
|
|
52
53
|
expect(shader).toContain('let skinMatrix = palette[');
|
|
53
54
|
expect(record).toContain('entry.source.skin.byteOffset');
|
|
54
55
|
expect(record).toContain("'shadow-pbr-skin-mesh'");
|
|
55
|
-
expect(builder).toContain(
|
|
56
|
-
expect(builder).toContain('? [...ctx.vertexBuffers]');
|
|
56
|
+
expect(builder).toContain('buffers: [...ctx.vertexBuffers]');
|
|
57
57
|
});
|
|
58
58
|
|
|
59
59
|
it('keeps mixed static and skinned shadow draws on matching variants and bindings', () => {
|
|
@@ -129,4 +129,32 @@ describe('skinned shadow caster', () => {
|
|
|
129
129
|
);
|
|
130
130
|
expect(recordSource).toContain('binding: 12');
|
|
131
131
|
});
|
|
132
|
+
|
|
133
|
+
it('keeps the dispatch material handle, snapshot, and frame slot together', () => {
|
|
134
|
+
const paramSnapshot = { alphaClipThreshold: 0.4 };
|
|
135
|
+
const context = {
|
|
136
|
+
dispatch: [
|
|
137
|
+
{
|
|
138
|
+
renderableIndex: 4,
|
|
139
|
+
materialHandle: 42,
|
|
140
|
+
materialShaderId: 'forgeax::default-standard-pbr',
|
|
141
|
+
paramSnapshot,
|
|
142
|
+
tags: { LightMode: 'ShadowCaster' },
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
validatedOrdered: [{ renderableIndex: 4 }],
|
|
146
|
+
materialSlotIndices: [[3, 7]],
|
|
147
|
+
materialSlots: Array.from({ length: 8 }, (_, slot) => ({
|
|
148
|
+
materialHandle: slot === 7 ? 42 : 11,
|
|
149
|
+
})),
|
|
150
|
+
} as never;
|
|
151
|
+
|
|
152
|
+
const bindings = shadowShaderMap(context);
|
|
153
|
+
expect(bindings.get(4)?.get(42)).toMatchObject({
|
|
154
|
+
materialHandle: 42,
|
|
155
|
+
materialShaderId: SHADOW_CASTER_SHADER_ID,
|
|
156
|
+
materialSlot: 7,
|
|
157
|
+
paramSnapshot,
|
|
158
|
+
});
|
|
159
|
+
});
|
|
132
160
|
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import {
|
|
5
|
+
projectStandardSurfacePasses,
|
|
6
|
+
type StandardSurfaceProjectionOptions,
|
|
7
|
+
} from '../assembly/material/surface-projection';
|
|
8
|
+
import { GPU_DRIVEN_RIGID_UNLIT_WGSL } from '../gpu-driven/production-raster';
|
|
9
|
+
|
|
10
|
+
function shaderSource(name: string): string {
|
|
11
|
+
return readFileSync(
|
|
12
|
+
fileURLToPath(new URL(`../../../shader/src/${name}`, import.meta.url)),
|
|
13
|
+
'utf8',
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const standard: StandardSurfaceProjectionOptions = {
|
|
18
|
+
surfaceModule: 'game_3d::rusted_iron_surface',
|
|
19
|
+
values: {},
|
|
20
|
+
geometryVariant: 'rigid',
|
|
21
|
+
lightingLane: 'direct',
|
|
22
|
+
alphaClip: true,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
describe('Surface adjacent regressions', () => {
|
|
26
|
+
it('retains the default Standard pass family and provenance', () => {
|
|
27
|
+
const passes = projectStandardSurfacePasses(standard);
|
|
28
|
+
expect(passes.map((pass) => pass.name)).toEqual(['forward', 'deferred', 'shadow-caster']);
|
|
29
|
+
expect(
|
|
30
|
+
passes.every((pass) => pass.program.moduleSlots?.surface === standard.surfaceModule),
|
|
31
|
+
).toBe(true);
|
|
32
|
+
expect(
|
|
33
|
+
passes.every(
|
|
34
|
+
(pass) =>
|
|
35
|
+
(pass.renderState?.tags as Readonly<Record<string, string>> | undefined)?.SurfaceKind ===
|
|
36
|
+
'standard',
|
|
37
|
+
),
|
|
38
|
+
).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('keeps explicit full-custom pass ownership closed', () => {
|
|
42
|
+
const passes = projectStandardSurfacePasses({
|
|
43
|
+
...standard,
|
|
44
|
+
surfaceKind: 'full-custom',
|
|
45
|
+
declaredPasses: ['forward'],
|
|
46
|
+
});
|
|
47
|
+
expect(passes.map((pass) => pass.name)).toEqual(['forward']);
|
|
48
|
+
expect(
|
|
49
|
+
(passes[0]?.renderState?.tags as Readonly<Record<string, string>> | undefined)?.SurfaceKind,
|
|
50
|
+
).toBe('full-custom');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('keeps skinning and GPU-driven siblings on their own shader owners', () => {
|
|
54
|
+
const skinned = shaderSource('default-standard-pbr-skin.wgsl');
|
|
55
|
+
expect(skinned).toContain('skinIndex');
|
|
56
|
+
expect(skinned).toContain('skinWeight');
|
|
57
|
+
expect(GPU_DRIVEN_RIGID_UNLIT_WGSL).toContain('@builtin(instance_index)');
|
|
58
|
+
expect(GPU_DRIVEN_RIGID_UNLIT_WGSL).not.toContain('surface_v1');
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { projectStandardSurfacePasses } from '../assembly/material/surface-projection';
|
|
3
|
+
import { layerPlanFor, passFor, SURFACE_CASES } from './surface-standard-pipeline.fixture';
|
|
4
|
+
|
|
5
|
+
describe('Standard Surface render projection contract', () => {
|
|
6
|
+
it('projects every published cell to its declared pass and Surface module', () => {
|
|
7
|
+
for (const surfaceCase of SURFACE_CASES) {
|
|
8
|
+
const passes = projectStandardSurfacePasses({
|
|
9
|
+
surfaceModule: surfaceCase.surfaceModule,
|
|
10
|
+
values: surfaceCase.material.values ?? {},
|
|
11
|
+
geometryVariant: 'rigid',
|
|
12
|
+
lightingLane: 'direct',
|
|
13
|
+
alphaClip: true,
|
|
14
|
+
layerPlan: layerPlanFor(surfaceCase),
|
|
15
|
+
});
|
|
16
|
+
expect(passes.some((entry) => entry.name === passFor(surfaceCase))).toBe(true);
|
|
17
|
+
expect(passes[0]?.program.moduleSlots?.surface).toBe(surfaceCase.surfaceModule);
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { projectStandardSurfacePasses } from '../assembly/material/surface-projection';
|
|
3
|
+
import { layerPlanFor, passFor, SURFACE_CASES } from './surface-standard-pipeline.fixture';
|
|
4
|
+
|
|
5
|
+
describe('Standard Surface render projection contract', () => {
|
|
6
|
+
it('keeps physical roots Forward-only while base roots remain Deferred', () => {
|
|
7
|
+
for (const surfaceCase of SURFACE_CASES) {
|
|
8
|
+
const passes = projectStandardSurfacePasses({
|
|
9
|
+
surfaceModule: surfaceCase.surfaceModule,
|
|
10
|
+
values: surfaceCase.material.values ?? {},
|
|
11
|
+
geometryVariant: 'skinned',
|
|
12
|
+
lightingLane: 'clustered',
|
|
13
|
+
alphaClip: true,
|
|
14
|
+
layerPlan: layerPlanFor(surfaceCase),
|
|
15
|
+
});
|
|
16
|
+
expect(passes.some((entry) => entry.name === passFor(surfaceCase))).toBe(true);
|
|
17
|
+
expect(passes[0]?.program.moduleSlots?.surface).toBe(surfaceCase.surfaceModule);
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
});
|