@forgeax/engine-runtime 0.1.27 → 0.1.29
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +98 -0
- package/dist/collect-scene-asset.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.mjs +480 -15
- package/dist/index.mjs.map +1 -1
- package/dist/scene-utils/mount-override-fold.d.ts +3 -1
- package/dist/scene-utils/mount-override-fold.d.ts.map +1 -1
- package/package.json +37 -30
- package/src/__tests__/animation-graph-collect-roundtrip.test.ts +4 -2
- package/src/__tests__/asset-registry-aabb.cases.unit.test.ts +5 -4
- package/src/__tests__/asset-registry-guid-reverse.test.ts +22 -27
- package/src/__tests__/asset-registry-mounts.test.ts +174 -734
- package/src/__tests__/asset-registry-scene.cases.unit.test.ts +9 -18
- package/src/__tests__/asset-registry.recursive.spec.ts +23 -29
- package/src/__tests__/camera-ortho.cases.unit.test.ts +12 -3
- package/src/__tests__/collect-scene-asset.test.ts +96 -113
- package/src/__tests__/collect-scene-mount-collapse.test.ts +64 -66
- package/src/__tests__/collect-transient-children-absent.test.ts +11 -17
- package/src/__tests__/collect-transient-roundtrip.test.ts +12 -13
- package/src/__tests__/collect-transient-skip.test.ts +20 -27
- package/src/__tests__/components.test-d.ts +17 -1
- package/src/__tests__/components.unit.test.ts +75 -27
- package/src/__tests__/composite-skybox-cross-world.browser.test.ts +4 -2
- package/src/__tests__/dawn/instances-per-instance-pbr.dawn.test.ts +8 -4
- package/src/__tests__/dawn/instances-shadow.dawn.test.ts +4 -1
- package/src/__tests__/dawn/instances-uniform-fallback.dawn.test.ts +66 -92
- package/src/__tests__/dawn/material-cooked-fixture.dawn.test.ts +200 -4
- package/src/__tests__/errors.unit.test.ts +13 -0
- package/src/__tests__/feature-depth-material.dawn.test.ts +23 -0
- package/src/__tests__/feature-depth-material.fixture.ts +473 -0
- package/src/__tests__/fullscreen-post-process-pass.dawn.test.ts +14 -14
- package/src/__tests__/geometry.unit.test.ts +42 -43
- package/src/__tests__/gpu-resource-release.cases.unit.test.ts +16 -9
- package/src/__tests__/handle-migration.test.ts +0 -1
- package/src/__tests__/instances-renderer-lifecycle.integration.test.ts +20 -14
- package/src/__tests__/instances.test-d.ts +13 -52
- package/src/__tests__/instantiate-mount-deferred-wiring.test.ts +42 -80
- package/src/__tests__/lazy-catalog.cases.unit.test.ts +17 -3
- package/src/__tests__/loader-registry.cases.unit.test.ts +4 -4
- package/src/__tests__/material-publication.browser.test.ts +29 -0
- package/src/__tests__/material-publication.commands.ts +23 -0
- package/src/__tests__/material-publication.dawn.test.ts +24 -0
- package/src/__tests__/material-publication.fixture.ts +349 -0
- package/src/__tests__/material-publication.server.ts +109 -0
- package/src/__tests__/parse-scene-payload-refs.cases.unit.test.ts +73 -96
- package/src/__tests__/pbr-pipeline.unit.test.ts +25 -11
- package/src/__tests__/point-light-shadow.browser.test.ts +8 -2
- package/src/__tests__/point-light-shadow.unit.test.ts +37 -8
- package/src/__tests__/record-all-topology.cases.unit.test.ts +52 -0
- package/src/__tests__/record-draw-branch.cases.unit.test.ts +1 -0
- package/src/__tests__/record-worldid-isolation.test.ts +47 -28
- package/src/__tests__/render-error-exhaustive.test-d.ts +24 -0
- package/src/__tests__/render-system-extract-dispatch-ordering.test.ts +15 -4
- package/src/__tests__/render-system-extract.test.ts +61 -0
- package/src/__tests__/render-system-extract.unit.test.ts +21 -7
- package/src/__tests__/render-system-fold-bucket.unit.test.ts +33 -1
- package/src/__tests__/render-system-fold-material.unit.test.ts +32 -6
- package/src/__tests__/render-system-fold-modegate.unit.test.ts +26 -4
- package/src/__tests__/render-system-fold.dawn.test.ts +4 -1
- package/src/__tests__/render-system-mega.test.ts +50 -62
- package/src/__tests__/renderer-device-loss-provider-feasibility.dawn.test.ts +0 -1
- package/src/__tests__/renderer-device-loss-recovery.dawn.test.ts +0 -3
- package/src/__tests__/renderer-draw-world.cases.unit.test.ts +16 -513
- package/src/__tests__/resolve-scene-guids.cases.unit.test.ts +48 -43
- package/src/__tests__/rhi-null-command-flow.unit.test.ts +9 -7
- package/src/__tests__/roots-to-scene-asset.test.ts +27 -28
- package/src/__tests__/roundtrip-primitive.test.ts +24 -23
- package/src/__tests__/scene-equirect-roundtrip.test.ts +5 -16
- package/src/__tests__/scene-instantiate-cycle-runtime.test.ts +13 -29
- package/src/__tests__/scene-mount-fixed-point.test.ts +104 -227
- package/src/__tests__/scene-mount-roundtrip.test.ts +95 -961
- package/src/__tests__/scene-mount-wrapper-roundtrip.test.ts +36 -182
- package/src/__tests__/scene-sugar-instantiate.test.ts +20 -15
- package/src/__tests__/serialize-mounts-roundtrip.test.ts +108 -492
- package/src/__tests__/serialize-scene-asset.test.ts +36 -50
- package/src/__tests__/shadow-caster-empty-schema.test.ts +3 -1
- package/src/__tests__/shadow-fields-observable.dawn.test.ts +34 -19
- package/src/__tests__/shadow-mesh-ssbo-order.test.ts +5 -12
- package/src/__tests__/skin-extract-getarrayview-count.unit.test.ts +6 -4
- package/src/__tests__/skin-m2-extract-happy.cases.unit.test.ts +50 -2
- package/src/__tests__/skin-m2-extract-joint-error.cases.unit.test.ts +50 -2
- package/src/__tests__/skin-m2-extract-null-assets.cases.unit.test.ts +50 -2
- package/src/__tests__/skin-m2-extract-skeleton-error.cases.unit.test.ts +50 -2
- package/src/__tests__/skin-palette-extract.cases.unit.test.ts +2 -0
- package/src/__tests__/skinned-shadow-mixed.dawn.test.ts +93 -48
- package/src/__tests__/skylight-pipeline-layout.cases.unit.test.ts +6 -2
- package/src/__tests__/sparse-tag-scene-roundtrip.test.ts +1 -1
- package/src/__tests__/sprite-lit-bgl-byte-identical.test.ts +11 -3
- package/src/__tests__/template-game-default-api-contract.test.ts +21 -21
- package/src/__tests__/tonemap-hdr-target.cases.unit.test.ts +8 -5
- package/src/__tests__/tonemap-pipeline-split.cases.unit.test.ts +3 -0
- package/src/__tests__/transient-fields-collect.test.ts +4 -4
- package/src/__tests__/transient-runtime-behavior.test.ts +9 -9
- package/src/__tests__/vfx-depth-material.dawn.test.ts +42 -0
- package/src/__tests__/vfx-mesh-lighting.browser.test.ts +16 -0
- package/src/__tests__/vfx-mesh-lighting.commands.ts +14 -0
- package/src/__tests__/vfx-mesh-lighting.dawn.test.ts +16 -0
- package/src/__tests__/vfx-mesh-lighting.fixture.ts +883 -0
- package/src/__tests__/wave1-dynamic-geometry.browser.test.ts +326 -0
- package/src/__tests__/wave1-rendering-materials.browser.test.ts +617 -0
- package/src/__tests__/wave1-rendering-p0.browser.test.ts +751 -0
- package/src/__tests__/wave1-rendering-recovery.browser.test.ts +666 -0
- package/src/__tests__/wave1-shadow-diagnostic.browser.test.ts +582 -0
- package/src/__tests__/world-scene-despawn.test.ts +8 -8
- package/src/__tests__/world-scene-instantiate.test.ts +23 -29
- package/src/__tests__/world-scene-nested.test.ts +90 -163
- package/src/__tests__/world-scene-override.test.ts +17 -16
- package/src/__tests__/zero-compression-zero-load.integration.test.ts +1 -0
- package/src/collect-scene-asset.ts +637 -19
- package/src/components/__tests__/sprite-animation.test-d.ts +2 -2
- package/src/index.ts +1 -0
- package/src/scene-utils/mount-override-fold.ts +5 -14
- package/src/__tests__/node-shims.d.ts +0 -31
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import { World } from '@forgeax/engine-ecs';
|
|
2
|
+
import { createBoxGeometry } from '@forgeax/engine-geometry';
|
|
3
|
+
import { AssetGuid } from '@forgeax/engine-pack';
|
|
4
|
+
import { Camera, MeshFilter, MeshRenderer } from '@forgeax/engine-render';
|
|
5
|
+
import { attachRecorder, buildFrameModel, decodeTape } from '@forgeax/engine-rhi-debug';
|
|
6
|
+
import { Transform } from '@forgeax/engine-scene';
|
|
7
|
+
import type { MaterialAsset, PackIndexEntry, RuntimeAssetBinding } from '@forgeax/engine-types';
|
|
8
|
+
import { ok } from '@forgeax/engine-types';
|
|
9
|
+
import { expect } from 'vitest';
|
|
10
|
+
import { loadBackendPack } from '../backend-selection';
|
|
11
|
+
import { createDevImportTransport } from '../dev-import-transport';
|
|
12
|
+
import { constructRuntimeRendererHost } from '../renderer-host';
|
|
13
|
+
|
|
14
|
+
const inspect = (value: unknown) =>
|
|
15
|
+
JSON.stringify(value, (_key, item) =>
|
|
16
|
+
item instanceof Error ? { ...item, message: item.message } : item,
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
export async function verifyMaterialPublication(options: {
|
|
20
|
+
readonly binding: RuntimeAssetBinding;
|
|
21
|
+
readonly guids: readonly string[];
|
|
22
|
+
readonly shaderManifestUrl: string;
|
|
23
|
+
readonly update: (value: 'red' | 'blue' | 'broken') => Promise<void>;
|
|
24
|
+
readonly saveTape?: (bytes: Uint8Array) => void;
|
|
25
|
+
}) {
|
|
26
|
+
const errors: unknown[] = [];
|
|
27
|
+
let device: GPUDevice | undefined;
|
|
28
|
+
let target: GPUTexture | undefined;
|
|
29
|
+
let surfaceFormat: GPUTextureFormat = 'rgba8unorm';
|
|
30
|
+
const canvas = {
|
|
31
|
+
width: 64,
|
|
32
|
+
height: 64,
|
|
33
|
+
getContext: () => ({
|
|
34
|
+
configure(config: GPUCanvasConfiguration) {
|
|
35
|
+
device = config.device;
|
|
36
|
+
surfaceFormat = config.format;
|
|
37
|
+
device.addEventListener('uncapturederror', (event) => errors.push(event.error.message));
|
|
38
|
+
target = device.createTexture({
|
|
39
|
+
size: [64, 64],
|
|
40
|
+
format: config.format,
|
|
41
|
+
viewFormats: [config.format === 'bgra8unorm' ? 'bgra8unorm-srgb' : 'rgba8unorm-srgb'],
|
|
42
|
+
usage: 0x10 | 0x01,
|
|
43
|
+
});
|
|
44
|
+
},
|
|
45
|
+
unconfigure() {},
|
|
46
|
+
getCurrentTexture: () => target,
|
|
47
|
+
}),
|
|
48
|
+
addEventListener() {},
|
|
49
|
+
removeEventListener() {},
|
|
50
|
+
};
|
|
51
|
+
const transport = createDevImportTransport(options.binding);
|
|
52
|
+
const backend = await loadBackendPack({});
|
|
53
|
+
if (!backend.ok) throw backend.error;
|
|
54
|
+
if (backend.value.createShaderModule === undefined)
|
|
55
|
+
throw new Error('missing backend shader factory');
|
|
56
|
+
const recording = attachRecorder({
|
|
57
|
+
...backend.value,
|
|
58
|
+
createShaderModule: backend.value.createShaderModule,
|
|
59
|
+
});
|
|
60
|
+
if (!recording.ok) throw recording.error;
|
|
61
|
+
const recorder = recording.value;
|
|
62
|
+
const constructed = await constructRuntimeRendererHost(
|
|
63
|
+
canvas,
|
|
64
|
+
{
|
|
65
|
+
rhi: recorder.backend.rhi,
|
|
66
|
+
rhiInstrumentation: {
|
|
67
|
+
resolveSurfaceDevice(device) {
|
|
68
|
+
const unwrapped = recorder.backend.unwrapDeviceForSurface(device);
|
|
69
|
+
if (!unwrapped.ok) throw unwrapped.error;
|
|
70
|
+
return ok(unwrapped.value);
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
shaderManifestUrl: options.shaderManifestUrl,
|
|
76
|
+
importTransport: transport,
|
|
77
|
+
},
|
|
78
|
+
);
|
|
79
|
+
if (!constructed.ok) throw constructed.error;
|
|
80
|
+
const { renderer, assets } = constructed.value;
|
|
81
|
+
renderer.subscribe((event) => {
|
|
82
|
+
if (event.kind === 'error') errors.push(event.error);
|
|
83
|
+
});
|
|
84
|
+
assets.configureRuntimeBinding(options.binding);
|
|
85
|
+
const world = new World();
|
|
86
|
+
try {
|
|
87
|
+
const catalog = async () => {
|
|
88
|
+
const response = await fetch(options.binding.catalogUrl);
|
|
89
|
+
const body = (await response.json()) as {
|
|
90
|
+
authority: string;
|
|
91
|
+
entries: PackIndexEntry[];
|
|
92
|
+
diagnostics?: unknown[];
|
|
93
|
+
};
|
|
94
|
+
return body;
|
|
95
|
+
};
|
|
96
|
+
const baseline = await catalog();
|
|
97
|
+
expect(baseline.authority, inspect(baseline)).toBe('authoritative');
|
|
98
|
+
expect(baseline.entries).toHaveLength(3);
|
|
99
|
+
const firstGuid = options.guids[0];
|
|
100
|
+
const childGuid = options.guids[1];
|
|
101
|
+
const otherGuid = options.guids[2];
|
|
102
|
+
if (firstGuid === undefined || childGuid === undefined || otherGuid === undefined)
|
|
103
|
+
throw new Error('missing fixture identity');
|
|
104
|
+
const firstRow = baseline.entries.find((row) => row.guid === firstGuid);
|
|
105
|
+
const otherRow = baseline.entries.find((row) => row.guid === otherGuid);
|
|
106
|
+
if (firstRow === undefined || otherRow === undefined)
|
|
107
|
+
throw new Error('missing material Catalog row');
|
|
108
|
+
const transportResult = await transport.fetchPack(firstGuid, options.binding);
|
|
109
|
+
expect(transportResult.ok, inspect(transportResult)).toBe(true);
|
|
110
|
+
const packUrl = new URL(firstRow.packageUrl, options.binding.catalogUrl).href;
|
|
111
|
+
const pack = await (await fetch(packUrl)).json();
|
|
112
|
+
const row = pack.assets.find((asset: { guid: string }) => asset.guid === firstGuid);
|
|
113
|
+
const descriptors = Object.values(row.artifacts) as { path: string; byteLength: number }[];
|
|
114
|
+
const cooked = (
|
|
115
|
+
row.payload as {
|
|
116
|
+
cooked: {
|
|
117
|
+
programs: readonly {
|
|
118
|
+
specializationKey: string;
|
|
119
|
+
artifact: { path: string };
|
|
120
|
+
}[];
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
).cooked;
|
|
124
|
+
// Material publication is a complete multi-program set: each compiled pass
|
|
125
|
+
// owns one independently addressable raw WGSL artifact.
|
|
126
|
+
expect(cooked.programs).toHaveLength(3);
|
|
127
|
+
expect(descriptors).toHaveLength(cooked.programs.length);
|
|
128
|
+
const publishedSources = new Map<string, string>();
|
|
129
|
+
for (const descriptor of descriptors) {
|
|
130
|
+
const program = cooked.programs.find(
|
|
131
|
+
(candidate) =>
|
|
132
|
+
descriptor.path === candidate.artifact.path ||
|
|
133
|
+
descriptor.path.endsWith(`/${candidate.artifact.path}`),
|
|
134
|
+
);
|
|
135
|
+
if (program === undefined) throw new Error(`missing program for ${descriptor.path}`);
|
|
136
|
+
const response = await fetch(new URL(descriptor.path, packUrl));
|
|
137
|
+
expect(response.status).toBe(200);
|
|
138
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
139
|
+
expect(bytes.byteLength).toBe(descriptor.byteLength);
|
|
140
|
+
publishedSources.set(program.specializationKey, new TextDecoder().decode(bytes));
|
|
141
|
+
}
|
|
142
|
+
const load = async (guid: string) => {
|
|
143
|
+
const parsed = AssetGuid.parse(guid);
|
|
144
|
+
if (!parsed.ok) throw parsed.error;
|
|
145
|
+
const loaded = await assets.loadByGuid<MaterialAsset>(parsed.value);
|
|
146
|
+
if (!loaded.ok) throw loaded.error;
|
|
147
|
+
return loaded.value;
|
|
148
|
+
};
|
|
149
|
+
const first = await load(firstGuid);
|
|
150
|
+
const inherited = await load(childGuid);
|
|
151
|
+
const other = await load(otherGuid);
|
|
152
|
+
const firstProjection = assets.getMaterialProjectionForPayload(first);
|
|
153
|
+
if (firstProjection === undefined) throw new Error('missing first material projection');
|
|
154
|
+
const shadow = assets.shaderRegistry.findMaterialArtifact('forgeax::default-shadow-caster');
|
|
155
|
+
if (!shadow.ok) throw shadow.error;
|
|
156
|
+
for (const program of cooked.programs)
|
|
157
|
+
expect(assets.shaderRegistry.findMaterialArtifact(program.specializationKey)).toMatchObject({
|
|
158
|
+
ok: true,
|
|
159
|
+
value: { source: publishedSources.get(program.specializationKey) },
|
|
160
|
+
});
|
|
161
|
+
world
|
|
162
|
+
.spawn(
|
|
163
|
+
{ component: Transform, data: { pos: [0, 0, 3], quat: [0, 0, 0, 1], scale: [1, 1, 1] } },
|
|
164
|
+
{ component: Camera, data: { fov: Math.PI / 4, aspect: 1, near: 0.1, far: 100 } },
|
|
165
|
+
)
|
|
166
|
+
.unwrap();
|
|
167
|
+
const entity = world
|
|
168
|
+
.spawn(
|
|
169
|
+
{ component: Transform, data: { pos: [0, 0, 0], quat: [0, 0, 0, 1], scale: [1, 1, 1] } },
|
|
170
|
+
{
|
|
171
|
+
component: MeshFilter,
|
|
172
|
+
data: {
|
|
173
|
+
assetHandle: world.allocSharedRef('MeshAsset', createBoxGeometry(1, 1, 1).unwrap()),
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
component: MeshRenderer,
|
|
178
|
+
data: { materials: [world.allocSharedRef('MaterialAsset', first)] },
|
|
179
|
+
},
|
|
180
|
+
)
|
|
181
|
+
.unwrap();
|
|
182
|
+
const attached = renderer.attach(world);
|
|
183
|
+
if (!attached.ok) throw attached.error;
|
|
184
|
+
const lease = attached.value;
|
|
185
|
+
const sample = async (material: MaterialAsset) => {
|
|
186
|
+
world
|
|
187
|
+
.set(entity, MeshRenderer, {
|
|
188
|
+
materials: [world.internSharedRef('MaterialAsset', material)],
|
|
189
|
+
})
|
|
190
|
+
.unwrap();
|
|
191
|
+
for (let frame = 0; frame < 15; frame++) {
|
|
192
|
+
const capture = frame === 14 ? recorder.captureFrame() : undefined;
|
|
193
|
+
if (capture !== undefined) {
|
|
194
|
+
const before = await recorder.frameBoundary();
|
|
195
|
+
if (!before.ok) throw before.error;
|
|
196
|
+
}
|
|
197
|
+
world.update().unwrap();
|
|
198
|
+
const result = renderer.draw({
|
|
199
|
+
leases: [lease],
|
|
200
|
+
camera: { lease },
|
|
201
|
+
environment: { lease },
|
|
202
|
+
});
|
|
203
|
+
if (!result.ok) throw result.error;
|
|
204
|
+
if (capture !== undefined) {
|
|
205
|
+
const after = await recorder.frameBoundary();
|
|
206
|
+
if (!after.ok) throw after.error;
|
|
207
|
+
const captured = await capture;
|
|
208
|
+
if (!captured.ok) throw captured.error;
|
|
209
|
+
options.saveTape?.(captured.value.bytes);
|
|
210
|
+
const decoded = decodeTape(captured.value.bytes);
|
|
211
|
+
if (!decoded.ok) throw decoded.error;
|
|
212
|
+
expect(
|
|
213
|
+
buildFrameModel(decoded.value).works.some(
|
|
214
|
+
(work) => work.kind === 'draw' || work.kind === 'drawIndexed',
|
|
215
|
+
),
|
|
216
|
+
).toBe(true);
|
|
217
|
+
}
|
|
218
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
|
219
|
+
}
|
|
220
|
+
if (device === undefined || target === undefined) throw new Error('missing real GPU target');
|
|
221
|
+
const buffer = device.createBuffer({ size: 256, usage: 0x08 | 0x01 });
|
|
222
|
+
try {
|
|
223
|
+
const encoder = device.createCommandEncoder();
|
|
224
|
+
encoder.copyTextureToBuffer(
|
|
225
|
+
{ texture: target, origin: [32, 32] },
|
|
226
|
+
{ buffer, bytesPerRow: 256 },
|
|
227
|
+
[1, 1],
|
|
228
|
+
);
|
|
229
|
+
device.queue.submit([encoder.finish()]);
|
|
230
|
+
await buffer.mapAsync(0x01);
|
|
231
|
+
const pixel = [...new Uint8Array(buffer.getMappedRange()).slice(0, 4)];
|
|
232
|
+
buffer.unmap();
|
|
233
|
+
if (surfaceFormat === 'bgra8unorm') [pixel[0], pixel[2]] = [pixel[2] ?? 0, pixel[0] ?? 0];
|
|
234
|
+
expect(errors, inspect(errors)).toEqual([]);
|
|
235
|
+
return pixel;
|
|
236
|
+
} finally {
|
|
237
|
+
buffer.destroy();
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
const red = await sample(first);
|
|
241
|
+
expect(red[0]).toBeGreaterThan(150);
|
|
242
|
+
expect(red[2]).toBeLessThan(5);
|
|
243
|
+
expect(await sample(inherited)).toEqual(red);
|
|
244
|
+
const overlay = first.passes?.find((pass) => pass.name === 'Overlay');
|
|
245
|
+
if (overlay === undefined) throw new Error('missing independent Overlay pass');
|
|
246
|
+
const projection = assets.getMaterialProjectionForPayload(first);
|
|
247
|
+
const overlayProgram = projection?.passes.find((pass) => pass.name === overlay.name)
|
|
248
|
+
?.programs[0];
|
|
249
|
+
if (overlayProgram === undefined) throw new Error('missing published Overlay program');
|
|
250
|
+
const overlayMaterial: MaterialAsset = {
|
|
251
|
+
...first,
|
|
252
|
+
passes: [
|
|
253
|
+
{
|
|
254
|
+
...overlay,
|
|
255
|
+
program: { ...overlay.program, module: overlayProgram.specializationKey },
|
|
256
|
+
renderState: { ...overlay.renderState, tags: { LightMode: 'Forward' } },
|
|
257
|
+
},
|
|
258
|
+
],
|
|
259
|
+
};
|
|
260
|
+
const green = await sample(overlayMaterial);
|
|
261
|
+
expect(green[0]).toBeLessThan(5);
|
|
262
|
+
expect(green[1]).toBeGreaterThan(150);
|
|
263
|
+
expect(green[2]).toBeLessThan(5);
|
|
264
|
+
const waitFor = async (accept: (value: Awaited<ReturnType<typeof catalog>>) => boolean) => {
|
|
265
|
+
const deadline = Date.now() + 20_000;
|
|
266
|
+
while (Date.now() < deadline) {
|
|
267
|
+
const next = await catalog();
|
|
268
|
+
if (accept(next)) return next;
|
|
269
|
+
await new Promise<void>((resolve) => setTimeout(resolve, 50));
|
|
270
|
+
}
|
|
271
|
+
const last = await catalog();
|
|
272
|
+
throw new Error(
|
|
273
|
+
`material publication did not settle: ${inspect({
|
|
274
|
+
authority: last.authority,
|
|
275
|
+
entries: last.entries.map((row) => ({
|
|
276
|
+
guid: row.guid,
|
|
277
|
+
revision: row.revision,
|
|
278
|
+
lifecycle: row.lifecycle,
|
|
279
|
+
})),
|
|
280
|
+
diagnostics: last.diagnostics,
|
|
281
|
+
})}`,
|
|
282
|
+
);
|
|
283
|
+
};
|
|
284
|
+
await options.update('blue');
|
|
285
|
+
const changed = await waitFor(
|
|
286
|
+
(next) =>
|
|
287
|
+
next.authority === 'authoritative' &&
|
|
288
|
+
next.entries.some(
|
|
289
|
+
(row) => row.guid === firstGuid && row.packageUrl !== firstRow.packageUrl,
|
|
290
|
+
),
|
|
291
|
+
);
|
|
292
|
+
expect(changed.entries.find((row) => row.guid === otherGuid)?.packageUrl).toBe(
|
|
293
|
+
otherRow.packageUrl,
|
|
294
|
+
);
|
|
295
|
+
assets.invalidate(firstGuid);
|
|
296
|
+
const revised = await load(firstGuid);
|
|
297
|
+
expect(await load(otherGuid)).toBe(other);
|
|
298
|
+
const revisedProjection = assets.getMaterialProjectionForPayload(revised);
|
|
299
|
+
if (revisedProjection === undefined) throw new Error('missing revised material projection');
|
|
300
|
+
// Authored pass modules remain stable semantic identities. Dependency
|
|
301
|
+
// invalidation is observed through the immutable cooked publication key,
|
|
302
|
+
// which is what the renderer selects for the new payload generation.
|
|
303
|
+
expect(revisedProjection.specializationKey).not.toBe(firstProjection.specializationKey);
|
|
304
|
+
expect(revised.passes?.find((pass) => pass.name === 'Overlay')?.program.module).toBe(
|
|
305
|
+
overlay.program.module,
|
|
306
|
+
);
|
|
307
|
+
const blue = await sample(revised);
|
|
308
|
+
expect(blue[2]).toBeGreaterThan(150);
|
|
309
|
+
expect(blue[0]).toBeLessThan(5);
|
|
310
|
+
expect(await sample(first)).toEqual(red); // Previous live program remains owned.
|
|
311
|
+
expect(await sample(overlayMaterial)).toEqual(green);
|
|
312
|
+
expect(assets.shaderRegistry.findMaterialArtifact('forgeax::default-shadow-caster')).toEqual(
|
|
313
|
+
shadow,
|
|
314
|
+
);
|
|
315
|
+
await options.update('broken');
|
|
316
|
+
await waitFor((next) => next.authority !== 'authoritative');
|
|
317
|
+
const lastGood = await fetch(
|
|
318
|
+
new URL(
|
|
319
|
+
changed.entries.find((row) => row.guid === firstGuid)?.packageUrl ?? '',
|
|
320
|
+
options.binding.catalogUrl,
|
|
321
|
+
),
|
|
322
|
+
);
|
|
323
|
+
// A failed producer preserves accepted bytes and live resources; a new
|
|
324
|
+
// lazy import remains fenced until the author repairs the source.
|
|
325
|
+
expect(lastGood.status).toBe(200);
|
|
326
|
+
expect(
|
|
327
|
+
(await fetch(`${options.binding.importUrlBase}/${firstGuid}`, { method: 'POST' })).status,
|
|
328
|
+
).toBe(409);
|
|
329
|
+
expect(await sample(revised)).toEqual(blue);
|
|
330
|
+
await options.update('red');
|
|
331
|
+
await waitFor(
|
|
332
|
+
(next) =>
|
|
333
|
+
next.authority === 'authoritative' &&
|
|
334
|
+
next.entries.some(
|
|
335
|
+
(row) => row.guid === firstGuid && row.packageUrl === firstRow.packageUrl,
|
|
336
|
+
),
|
|
337
|
+
);
|
|
338
|
+
assets.invalidate(firstGuid);
|
|
339
|
+
expect(await sample(await load(firstGuid))).toEqual(red);
|
|
340
|
+
expect(errors, inspect(errors)).toEqual([]);
|
|
341
|
+
} finally {
|
|
342
|
+
const disposed = await renderer.dispose();
|
|
343
|
+
target?.destroy();
|
|
344
|
+
device?.destroy();
|
|
345
|
+
if (!disposed.ok) errors.push(disposed.error);
|
|
346
|
+
await recorder.dispose();
|
|
347
|
+
}
|
|
348
|
+
expect(errors, inspect(errors)).toEqual([]);
|
|
349
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { AssetGuid, PackageId } from '@forgeax/engine-pack/guid';
|
|
5
|
+
import { createMaterialPackCooker } from '@forgeax/engine-shader-compiler';
|
|
6
|
+
import { createStandaloneRuntimeAssetBinding } from '@forgeax/engine-types';
|
|
7
|
+
import { pluginPack } from '@forgeax/engine-vite-plugin-pack';
|
|
8
|
+
import { createServer } from 'vite';
|
|
9
|
+
|
|
10
|
+
/** Real disk -> registered cooker -> Vite HTTP producer for both GPU backends. */
|
|
11
|
+
export async function startMaterialPublicationServer() {
|
|
12
|
+
const root = await mkdtemp(join(tmpdir(), 'forgeax-material-publication-'));
|
|
13
|
+
const assets = join(root, 'assets');
|
|
14
|
+
await mkdir(assets);
|
|
15
|
+
const source = (
|
|
16
|
+
module: string,
|
|
17
|
+
color: string,
|
|
18
|
+
dependency = false,
|
|
19
|
+
) => `#define_import_path ${module}
|
|
20
|
+
#import forgeax_view::common::{view, meshes}
|
|
21
|
+
${dependency ? '#import game::color::{shade}' : ''}
|
|
22
|
+
@vertex fn vs_main(@location(0) pos: vec3<f32>, @builtin(instance_index) idx: u32) -> @builtin(position) vec4<f32> {
|
|
23
|
+
return view.worldViewProj * meshes[idx].worldFromLocal * vec4<f32>(pos, 1.0);
|
|
24
|
+
}
|
|
25
|
+
@fragment fn fs_main() -> @location(0) vec4<f32> { return ${dependency ? 'shade()' : `vec4<f32>(${color})`}; }
|
|
26
|
+
`;
|
|
27
|
+
const update = (value: 'red' | 'blue' | 'broken') =>
|
|
28
|
+
writeFile(
|
|
29
|
+
join(assets, 'color.wgsl'),
|
|
30
|
+
`#define_import_path game::color\nfn shade() -> vec4<f32> { ${value === 'broken' ? 'broken' : `return vec4<f32>(${value === 'red' ? '1.0, 0.0, 0.0, 1.0' : '0.0, 0.0, 1.0, 1.0'});`} }\n`,
|
|
31
|
+
);
|
|
32
|
+
await update('red');
|
|
33
|
+
await writeFile(join(assets, 'first.wgsl'), source('game::first', '', true));
|
|
34
|
+
await writeFile(join(assets, 'overlay.wgsl'), source('game::overlay', '0.0, 1.0, 0.0, 1.0'));
|
|
35
|
+
await writeFile(join(assets, 'other.wgsl'), source('game::other', '1.0, 1.0, 0.0, 1.0'));
|
|
36
|
+
const guids: string[] = [];
|
|
37
|
+
for (const [index, name] of ['first', 'other'].entries()) {
|
|
38
|
+
const packageId = `019a0000-0000-7000-8000-00000000010${index}`;
|
|
39
|
+
const parsed = PackageId.parse(packageId);
|
|
40
|
+
if (!parsed.ok) throw parsed.error;
|
|
41
|
+
guids.push(AssetGuid.format(AssetGuid.derive(parsed.value, 'material/main')));
|
|
42
|
+
if (index === 0) guids.push(AssetGuid.format(AssetGuid.derive(parsed.value, 'material/child')));
|
|
43
|
+
const passes = [
|
|
44
|
+
{
|
|
45
|
+
name: 'Forward',
|
|
46
|
+
program: { module: `game::${name}`, vertexEntry: 'vs_main', fragmentEntry: 'fs_main' },
|
|
47
|
+
renderState: { tags: { LightMode: 'Forward' } },
|
|
48
|
+
},
|
|
49
|
+
...(index === 0
|
|
50
|
+
? [
|
|
51
|
+
{
|
|
52
|
+
name: 'Overlay',
|
|
53
|
+
program: {
|
|
54
|
+
module: 'game::overlay',
|
|
55
|
+
vertexEntry: 'vs_main',
|
|
56
|
+
fragmentEntry: 'fs_main',
|
|
57
|
+
},
|
|
58
|
+
renderState: { tags: { LightMode: 'Overlay' } },
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
name: 'ShadowCaster',
|
|
62
|
+
program: { module: 'forgeax::default-shadow-caster', vertexEntry: 'vs_main' },
|
|
63
|
+
renderState: { tags: { LightMode: 'ShadowCaster' } },
|
|
64
|
+
},
|
|
65
|
+
]
|
|
66
|
+
: []),
|
|
67
|
+
];
|
|
68
|
+
await writeFile(
|
|
69
|
+
join(assets, `${name}.pack.ts`),
|
|
70
|
+
`import { definePack, definePackageId } from '@forgeax/engine-pack/source';
|
|
71
|
+
import { ok } from '@forgeax/engine-types';
|
|
72
|
+
export default definePack({ schemaVersion: '2.0.0', packageId: definePackageId('${packageId}'),
|
|
73
|
+
build() { return ok({ 'material/main': ${JSON.stringify({ kind: 'material', passes, parameters: [] })}
|
|
74
|
+
${index === 0 ? `, 'material/child': { kind: 'material', parent: new Uint8Array(${JSON.stringify([...AssetGuid.derive(parsed.value, 'material/main')])}), values: {} }` : ''}
|
|
75
|
+
}); } });\n`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
const binding = createStandaloneRuntimeAssetBinding('material-publication');
|
|
79
|
+
const plugin = pluginPack({
|
|
80
|
+
roots: [assets],
|
|
81
|
+
runtimeBinding: binding,
|
|
82
|
+
producerReadiness: 'on-demand',
|
|
83
|
+
cookers: [createMaterialPackCooker([assets])],
|
|
84
|
+
ddc: { projectDdcRoot: join(root, '.ddc') },
|
|
85
|
+
});
|
|
86
|
+
const server = await createServer({
|
|
87
|
+
root,
|
|
88
|
+
configFile: false,
|
|
89
|
+
logLevel: 'silent',
|
|
90
|
+
plugins: [plugin],
|
|
91
|
+
server: { host: '127.0.0.1', port: 0, cors: true },
|
|
92
|
+
});
|
|
93
|
+
await server.listen();
|
|
94
|
+
const baseUrl = server.resolvedUrls?.local[0];
|
|
95
|
+
if (baseUrl === undefined) throw new Error('Vite has no HTTP listener');
|
|
96
|
+
return {
|
|
97
|
+
binding: {
|
|
98
|
+
...binding,
|
|
99
|
+
catalogUrl: new URL(binding.catalogUrl, baseUrl).href,
|
|
100
|
+
importUrlBase: new URL(binding.importUrlBase, baseUrl).href,
|
|
101
|
+
},
|
|
102
|
+
guids,
|
|
103
|
+
update,
|
|
104
|
+
async close() {
|
|
105
|
+
await server.close();
|
|
106
|
+
await rm(root, { recursive: true, force: true });
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|