@forgeax/engine-render 0.1.35 → 0.1.36
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 +17 -0
- package/dist/assembly/webgpu-vertex-layouts.d.ts.map +1 -1
- package/dist/{chunk-MXTWKZO4.mjs → chunk-2OZAS3WU.mjs} +225 -4
- package/dist/chunk-2OZAS3WU.mjs.map +1 -0
- package/dist/{chunk-G6HDNGY7.mjs → chunk-BZQ4CDRF.mjs} +24 -230
- package/dist/chunk-BZQ4CDRF.mjs.map +1 -0
- package/dist/{chunk-LNIUIKQ7.mjs → chunk-CJOV2XLO.mjs} +77 -19
- package/dist/chunk-CJOV2XLO.mjs.map +1 -0
- package/dist/{chunk-3XUBWRY2.mjs → chunk-D6WSBXJ2.mjs} +4 -4
- package/dist/{chunk-3XUBWRY2.mjs.map → chunk-D6WSBXJ2.mjs.map} +1 -1
- package/dist/{chunk-LM5IE7GF.mjs → chunk-DRF6OULX.mjs} +3 -3
- package/dist/{chunk-LM5IE7GF.mjs.map → chunk-DRF6OULX.mjs.map} +1 -1
- package/dist/{chunk-SGPUJW3F.mjs → chunk-OB5WAFYH.mjs} +3 -3
- package/dist/{chunk-SGPUJW3F.mjs.map → chunk-OB5WAFYH.mjs.map} +1 -1
- package/dist/{chunk-MZ4C7EHF.mjs → chunk-OEYKKMXG.mjs} +5 -5
- package/dist/{chunk-MZ4C7EHF.mjs.map → chunk-OEYKKMXG.mjs.map} +1 -1
- package/dist/construct-renderer.mjs +130 -71
- package/dist/construct-renderer.mjs.map +1 -1
- package/dist/device/device-scope.d.ts.map +1 -1
- package/dist/features/particle-mesh-layout.d.ts +19 -0
- package/dist/features/particle-mesh-layout.d.ts.map +1 -0
- package/dist/features/types.d.ts +5 -1
- package/dist/features/types.d.ts.map +1 -1
- package/dist/gpu-driven/production-raster.d.ts +1 -1
- package/dist/gpu-driven/production-raster.d.ts.map +1 -1
- package/dist/gpu-driven/shadow-views.d.ts +1 -1
- package/dist/gpu-driven/shadow-views.d.ts.map +1 -1
- package/dist/gpu-driven/view-gpu.d.ts +1 -1
- package/dist/gpu-driven/view-gpu.d.ts.map +1 -1
- package/dist/index.mjs +48 -36
- package/dist/index.mjs.map +1 -1
- package/dist/internal.mjs +5 -5
- package/dist/pbr-pipeline.d.ts.map +1 -1
- package/dist/publication/publisher.d.ts +1 -0
- package/dist/publication/publisher.d.ts.map +1 -1
- package/dist/record/frame.d.ts.map +1 -1
- package/dist/record/prepared-material-bindings.d.ts +1 -2
- package/dist/record/prepared-material-bindings.d.ts.map +1 -1
- package/dist/record/render-context.d.ts +2 -0
- package/dist/record/render-context.d.ts.map +1 -1
- package/dist/record/shadow-pass.d.ts +2 -2
- package/dist/record/shadow-pass.d.ts.map +1 -1
- package/dist/record/typed-frame-graph.d.ts.map +1 -1
- package/dist/recovery/render-system-candidate.d.ts +1 -8
- package/dist/recovery/render-system-candidate.d.ts.map +1 -1
- package/dist/render-system-extract-tail.d.ts.map +1 -1
- package/dist/render-system-extract.d.ts.map +1 -1
- package/dist/render-system.d.ts.map +1 -1
- package/dist/temporal/index.mjs +4 -4
- package/dist/typed-shadow-passes.d.ts.map +1 -1
- package/package.json +21 -21
- package/src/__tests__/device-scope-lifecycle.unit.test.ts +26 -0
- package/src/__tests__/gpu-driven-production.integration.test.ts +18 -1
- package/src/__tests__/gpu-driven-shadow-views.unit.test.ts +23 -0
- package/src/__tests__/material-vertex-texture.browser.test.ts +7 -0
- package/src/__tests__/material-vertex-texture.dawn.test.ts +7 -0
- package/src/__tests__/material-vertex-texture.fixture.ts +31 -0
- package/src/__tests__/recovery-candidate-prepare.contract.test.ts +5 -0
- package/src/__tests__/renderer-factory-material-contract.unit.test.ts +106 -4
- package/src/__tests__/runtime-material-program.integration.test.ts +79 -0
- package/src/__tests__/vertex-layouts-ssot.unit.test.ts +23 -3
- package/src/assembly/webgpu-renderer.ts +12 -9
- package/src/assembly/webgpu-vertex-layouts.ts +2 -9
- package/src/device/device-scope.ts +3 -0
- package/src/features/particle-mesh-layout.ts +25 -0
- package/src/features/types.ts +5 -1
- package/src/gpu-driven/production-raster.ts +9 -3
- package/src/gpu-driven/shadow-views.ts +27 -2
- package/src/gpu-driven/view-gpu.ts +5 -0
- package/src/pbr-pipeline.ts +4 -1
- package/src/publication/__tests__/features.integration.test.ts +51 -0
- package/src/publication/__tests__/publication.integration.test.ts +45 -0
- package/src/publication/publisher.ts +56 -29
- package/src/record/__tests__/prepared-runtime-material.integration.test.ts +134 -0
- package/src/record/frame.ts +45 -6
- package/src/record/prepared-material-bindings.ts +11 -37
- package/src/record/render-context.ts +2 -0
- package/src/record/shadow-pass.ts +65 -4
- package/src/record/typed-frame-graph.ts +5 -1
- package/src/recovery/render-system-candidate.ts +1 -9
- package/src/render-system-extract-tail.ts +15 -6
- package/src/render-system-extract.ts +9 -2
- package/src/render-system.ts +0 -1
- package/src/typed-shadow-passes.ts +36 -5
- package/dist/chunk-G6HDNGY7.mjs.map +0 -1
- package/dist/chunk-LNIUIKQ7.mjs.map +0 -1
- package/dist/chunk-MXTWKZO4.mjs.map +0 -1
|
@@ -188,3 +188,29 @@ describe('DeviceScope lifecycle transaction', () => {
|
|
|
188
188
|
expect(cleanupCount).toBe(1);
|
|
189
189
|
});
|
|
190
190
|
});
|
|
191
|
+
|
|
192
|
+
it('detaches terminal child scopes while retaining pending ownership', () => {
|
|
193
|
+
const parent = DeviceScope.create(1, 'renderer');
|
|
194
|
+
const pending = parent.createChild('pending');
|
|
195
|
+
let cleaned = 0;
|
|
196
|
+
pending._adopt('buffer', {}, () => {
|
|
197
|
+
cleaned += 1;
|
|
198
|
+
});
|
|
199
|
+
pending.beginRetire();
|
|
200
|
+
for (let index = 0; index < 300; index += 1) {
|
|
201
|
+
for (const action of ['retire', 'abandon', 'dispose'] as const) {
|
|
202
|
+
const child = parent.createChild(action);
|
|
203
|
+
child._adopt('buffer', {}, () => {
|
|
204
|
+
cleaned += 1;
|
|
205
|
+
});
|
|
206
|
+
child[action]();
|
|
207
|
+
child[action]();
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
expect(parent.childrenSnapshot).toEqual([pending]);
|
|
211
|
+
expect(cleaned).toBe(900);
|
|
212
|
+
pending.retire();
|
|
213
|
+
expect(parent.childrenSnapshot).toEqual([]);
|
|
214
|
+
parent.dispose();
|
|
215
|
+
expect(cleaned).toBe(901);
|
|
216
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { HANDLE_CUBE } from '@forgeax/engine-assets-runtime';
|
|
2
2
|
import { World } from '@forgeax/engine-ecs';
|
|
3
3
|
import { deriveVertexLayoutProjection } from '@forgeax/engine-geometry';
|
|
4
|
-
import { vec3 } from '@forgeax/engine-math';
|
|
4
|
+
import { frustum, mat4, vec3 } from '@forgeax/engine-math';
|
|
5
5
|
import { RenderGraphBuilder } from '@forgeax/engine-render-graph';
|
|
6
6
|
import {
|
|
7
7
|
type BindGroup,
|
|
@@ -2426,6 +2426,23 @@ describe('GPU-driven production projection', () => {
|
|
|
2426
2426
|
expect(authored?.[2]).toEqual({ cullMode: 'none', frontFace: 'cw' });
|
|
2427
2427
|
expect(authored?.[15]).toBe(skinned ? 'pbr-skin' : 'pbr');
|
|
2428
2428
|
if (skinned) expect(authored?.[5]).toContain('SKINNING_DISABLED=false');
|
|
2429
|
+
|
|
2430
|
+
sourceForRecord.transform.world[12] = 100;
|
|
2431
|
+
indexedDraws.length = 0;
|
|
2432
|
+
const culled = recordShadowCasterDraws(
|
|
2433
|
+
context,
|
|
2434
|
+
pass,
|
|
2435
|
+
{} as never,
|
|
2436
|
+
{} as never,
|
|
2437
|
+
new Set([0]),
|
|
2438
|
+
new Map([[0, new Set([20, 21, 22])]]),
|
|
2439
|
+
shadowDispatches,
|
|
2440
|
+
new Set([claimedKey]),
|
|
2441
|
+
false,
|
|
2442
|
+
frustum.fromViewProjection(frustum.create(), mat4.identity(mat4.create())),
|
|
2443
|
+
);
|
|
2444
|
+
expect(indexedDraws).toHaveLength(skinned ? 3 : 0);
|
|
2445
|
+
expect(culled).toEqual(skinned ? residualKeys : new Set());
|
|
2429
2446
|
});
|
|
2430
2447
|
|
|
2431
2448
|
it('fails closed at every shadow front door when expected casters lack bind groups', () => {
|
|
@@ -13,6 +13,7 @@ import { RenderScene } from '../scene/render-scene';
|
|
|
13
13
|
|
|
14
14
|
const shadowViewsPath = new URL('../gpu-driven/shadow-views.ts', import.meta.url);
|
|
15
15
|
const shadowViewsSource = existsSync(shadowViewsPath) ? readFileSync(shadowViewsPath, 'utf8') : '';
|
|
16
|
+
const recordFrameSource = readFileSync(new URL('../record/frame.ts', import.meta.url), 'utf8');
|
|
16
17
|
const viewSource = readFileSync(new URL('../gpu-driven/view-gpu.ts', import.meta.url), 'utf8');
|
|
17
18
|
|
|
18
19
|
const shadowKinds = [
|
|
@@ -83,6 +84,23 @@ describe('GPU-driven shadow view owner contract', () => {
|
|
|
83
84
|
expect(viewSource).toMatch(/labelPrefix|viewPrefix|viewId/);
|
|
84
85
|
});
|
|
85
86
|
|
|
87
|
+
it('fences shadow view cache identity at accepted graph generations', () => {
|
|
88
|
+
const cacheTokens = [
|
|
89
|
+
...recordFrameSource.matchAll(/cacheToken:\s*shadowViewToken\(([^)]*)\)/g),
|
|
90
|
+
].map(([, argumentsSource]) => argumentsSource ?? '');
|
|
91
|
+
|
|
92
|
+
expect(cacheTokens).toHaveLength(3);
|
|
93
|
+
expect(
|
|
94
|
+
cacheTokens.every((argumentsSource) => argumentsSource.includes('graphShadowMapSize')),
|
|
95
|
+
).toBe(true);
|
|
96
|
+
expect(
|
|
97
|
+
cacheTokens.every((argumentsSource) =>
|
|
98
|
+
argumentsSource.includes('frameState.graphGeneration'),
|
|
99
|
+
),
|
|
100
|
+
).toBe(true);
|
|
101
|
+
expect(recordFrameSource).toMatch(/shadowViewToken[\s\S]*graphGeneration/);
|
|
102
|
+
});
|
|
103
|
+
|
|
86
104
|
it('reuses each view on a cache hit and invalidates only that view', async () => {
|
|
87
105
|
const adapter = (await rhi.requestAdapter()).unwrap();
|
|
88
106
|
const device = (await adapter.requestDevice()).unwrap();
|
|
@@ -140,6 +158,11 @@ describe('GPU-driven shadow view owner contract', () => {
|
|
|
140
158
|
.inspect().passes,
|
|
141
159
|
).toHaveLength(0);
|
|
142
160
|
|
|
161
|
+
const replacementGraph = new RenderGraphBuilder<{ readonly encoder: RhiCommandEncoder }>();
|
|
162
|
+
const forced = pool.project(replacementGraph, identity, true).unwrap();
|
|
163
|
+
expect(forced.cache).toBe('hit');
|
|
164
|
+
expect(forced.passNames.length).toBeGreaterThan(0);
|
|
165
|
+
|
|
143
166
|
const changedMaterial = {
|
|
144
167
|
...material,
|
|
145
168
|
baseColor: new Float32Array([0.25, 1, 1]),
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { expect } from 'vitest';
|
|
2
|
+
import { appendInjection, buildPbrMaterialUserRegionEntries } from '../pbr-pipeline';
|
|
3
|
+
|
|
4
|
+
export async function verifyMaterialVertexTexture(): Promise<void> {
|
|
5
|
+
const adapter = await navigator.gpu.requestAdapter();
|
|
6
|
+
if (!adapter) throw new Error('WebGPU adapter unavailable');
|
|
7
|
+
const device = await adapter.requestDevice();
|
|
8
|
+
try {
|
|
9
|
+
const entries = buildPbrMaterialUserRegionEntries([
|
|
10
|
+
{ name: 'displacement', type: 'texture2d' },
|
|
11
|
+
]);
|
|
12
|
+
for (const entry of appendInjection(entries, 'ibl')) expect(entry.visibility).toBe(2);
|
|
13
|
+
device.pushErrorScope('validation');
|
|
14
|
+
const module = device.createShaderModule({
|
|
15
|
+
code: `@group(0) @binding(1) var s: sampler;
|
|
16
|
+
@group(0) @binding(2) var t: texture_2d<f32>;
|
|
17
|
+
@vertex fn vs() -> @builtin(position) vec4<f32> { return vec4<f32>(textureSampleLevel(t,s,vec2<f32>(0.5),0.0).xyz,1.0); }
|
|
18
|
+
@fragment fn fs() -> @location(0) vec4<f32> { return vec4<f32>(1.0); }`,
|
|
19
|
+
});
|
|
20
|
+
device.createRenderPipeline({
|
|
21
|
+
layout: device.createPipelineLayout({
|
|
22
|
+
bindGroupLayouts: [device.createBindGroupLayout({ entries })],
|
|
23
|
+
}),
|
|
24
|
+
vertex: { module, entryPoint: 'vs' },
|
|
25
|
+
fragment: { module, entryPoint: 'fs', targets: [{ format: 'rgba8unorm' }] },
|
|
26
|
+
});
|
|
27
|
+
expect(await device.popErrorScope()).toBeNull();
|
|
28
|
+
} finally {
|
|
29
|
+
device.destroy();
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -139,6 +139,11 @@ describe('recovery candidate preparation contract', () => {
|
|
|
139
139
|
expect(rendererSource).not.toMatch(/\n\s*materialShaderPipelineCache\.(get|set|has)\(/);
|
|
140
140
|
});
|
|
141
141
|
|
|
142
|
+
it('forks the validated shader catalog onto a recovery device', () => {
|
|
143
|
+
expect(rendererSource).toContain('activeCatalog.forkForDevice(getShaderModuleAdapter())');
|
|
144
|
+
expect(rendererSource).toContain('state === candidateShaderState && activeCatalog !== null');
|
|
145
|
+
});
|
|
146
|
+
|
|
142
147
|
it('resolves recovery variants from the candidate device capabilities', () => {
|
|
143
148
|
const start = rendererSource.indexOf('const resolveCachedMaterialShaderVariantSet');
|
|
144
149
|
const end = rendererSource.indexOf('const findMaterialShaderManifestEntry', start);
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
import { resolve } from 'node:path';
|
|
3
|
+
import { AssetRegistry } from '@forgeax/engine-assets-runtime';
|
|
3
4
|
import { type RhiDevice, RhiError } from '@forgeax/engine-rhi';
|
|
4
|
-
import
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
import {
|
|
6
|
+
createMaterialProgramArtifactReceipt,
|
|
7
|
+
type MaterialShaderManifestEntry,
|
|
8
|
+
type MaterialShaderManifestVariant,
|
|
9
|
+
type ShaderCatalog,
|
|
10
|
+
ShaderRegistry,
|
|
11
|
+
type ShaderRegistryDevice,
|
|
8
12
|
} from '@forgeax/engine-shader';
|
|
9
13
|
import { describe, expect, it, vi } from 'vitest';
|
|
10
14
|
import { deviceOptionsForAdapter } from '../assembly/device-feature-admission';
|
|
@@ -128,6 +132,104 @@ fn cloud_direct_solar_factorX() -> f32 {
|
|
|
128
132
|
});
|
|
129
133
|
});
|
|
130
134
|
|
|
135
|
+
it('selects the replacement device capability variant after a metadata-only fork', async () => {
|
|
136
|
+
const uniformReceipt = createMaterialProgramArtifactReceipt({
|
|
137
|
+
schema: [],
|
|
138
|
+
directEntry: 'vs_uniform',
|
|
139
|
+
sceneIndexEntry: 'vs_scene_uniform',
|
|
140
|
+
vertexInputs: [],
|
|
141
|
+
layoutIdentity: 'uniform',
|
|
142
|
+
});
|
|
143
|
+
const storageReceipt = createMaterialProgramArtifactReceipt({
|
|
144
|
+
schema: [],
|
|
145
|
+
directEntry: 'vs_storage',
|
|
146
|
+
sceneIndexEntry: 'vs_scene_storage',
|
|
147
|
+
vertexInputs: [],
|
|
148
|
+
layoutIdentity: 'storage',
|
|
149
|
+
});
|
|
150
|
+
const entry: MaterialShaderManifestEntry = {
|
|
151
|
+
identifier: 'game::variant',
|
|
152
|
+
sourcePath: 'variant.wgsl',
|
|
153
|
+
composedWgsl: 'fallback',
|
|
154
|
+
paramSchema: '[]',
|
|
155
|
+
uvSetCount: 3,
|
|
156
|
+
variants: [
|
|
157
|
+
{
|
|
158
|
+
definesKey: 'STORAGE_BUFFER_AVAILABLE=false',
|
|
159
|
+
defines: { STORAGE_BUFFER_AVAILABLE: false },
|
|
160
|
+
composedWgsl: 'uniform-variant',
|
|
161
|
+
receipt: uniformReceipt,
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
definesKey: '',
|
|
165
|
+
defines: { STORAGE_BUFFER_AVAILABLE: true },
|
|
166
|
+
composedWgsl: 'storage-variant',
|
|
167
|
+
receipt: storageReceipt,
|
|
168
|
+
},
|
|
169
|
+
],
|
|
170
|
+
};
|
|
171
|
+
const manifestUrl = URL.createObjectURL(
|
|
172
|
+
new Blob([JSON.stringify({ entries: [], materialShaders: [entry] })]),
|
|
173
|
+
);
|
|
174
|
+
const registryDevice = (): ShaderRegistryDevice => ({
|
|
175
|
+
createShaderModule: () => ({ ok: true, value: {} as never }),
|
|
176
|
+
});
|
|
177
|
+
const device = (storageBuffer: boolean): RhiDevice =>
|
|
178
|
+
({
|
|
179
|
+
caps: {
|
|
180
|
+
backendKind: 'webgpu',
|
|
181
|
+
storageBuffer,
|
|
182
|
+
rgba16floatRenderable: true,
|
|
183
|
+
samplerAliasing: true,
|
|
184
|
+
},
|
|
185
|
+
limits: {
|
|
186
|
+
maxStorageBuffersPerShaderStage: storageBuffer ? 8 : 0,
|
|
187
|
+
maxSampledTexturesPerShaderStage: 32,
|
|
188
|
+
maxTextureArrayLayers: 64,
|
|
189
|
+
maxUniformBuffersPerShaderStage: 4,
|
|
190
|
+
},
|
|
191
|
+
}) as unknown as RhiDevice;
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
const active = new ShaderRegistry({ device: registryDevice(), manifestUrl });
|
|
195
|
+
const activeAssets = new AssetRegistry(active);
|
|
196
|
+
await prepareMaterialShaders(device(false), () => active, activeAssets, new Map());
|
|
197
|
+
active.installMaterialArtifact('game::custom', {
|
|
198
|
+
source: 'custom-variant',
|
|
199
|
+
paramSchema: [],
|
|
200
|
+
receipt: uniformReceipt,
|
|
201
|
+
});
|
|
202
|
+
const activeArtifact = active.findMaterialArtifact('game::variant');
|
|
203
|
+
expect(activeArtifact.ok).toBe(true);
|
|
204
|
+
if (!activeArtifact.ok) return;
|
|
205
|
+
expect(activeArtifact.value.source).toBe('uniform-variant');
|
|
206
|
+
expect(activeArtifact.value.receipt).toEqual(uniformReceipt);
|
|
207
|
+
|
|
208
|
+
URL.revokeObjectURL(manifestUrl);
|
|
209
|
+
const candidate = active.forkForDevice(registryDevice());
|
|
210
|
+
const candidateAssets = new AssetRegistry(candidate);
|
|
211
|
+
const uvSetCounts = new Map<string, number>();
|
|
212
|
+
await prepareMaterialShaders(device(true), () => candidate, candidateAssets, uvSetCounts);
|
|
213
|
+
const candidateArtifact = candidate.findMaterialArtifact('game::variant');
|
|
214
|
+
expect(candidateArtifact.ok).toBe(true);
|
|
215
|
+
if (!candidateArtifact.ok) return;
|
|
216
|
+
expect(candidateArtifact.value.source).toBe('storage-variant');
|
|
217
|
+
expect(candidateArtifact.value.receipt).toEqual(storageReceipt);
|
|
218
|
+
expect(uvSetCounts.get('game::variant')).toBe(3);
|
|
219
|
+
expect(candidate.findMaterialArtifact('game::custom').ok).toBe(false);
|
|
220
|
+
|
|
221
|
+
const customArtifact = active.findMaterialArtifact('game::custom');
|
|
222
|
+
expect(customArtifact.ok).toBe(true);
|
|
223
|
+
if (!customArtifact.ok) return;
|
|
224
|
+
candidate.installMaterialArtifact('game::custom', customArtifact.value);
|
|
225
|
+
const candidateCustom = candidate.findMaterialArtifact('game::custom');
|
|
226
|
+
expect(candidateCustom.ok && candidateCustom.value.source).toBe('custom-variant');
|
|
227
|
+
expect(activeArtifact.value.source).toBe('uniform-variant');
|
|
228
|
+
} finally {
|
|
229
|
+
URL.revokeObjectURL(manifestUrl);
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
|
|
131
233
|
it('requests the extended-lighting sampled-texture limit when the adapter supports it', () => {
|
|
132
234
|
const features = new Set<GPUFeatureName>();
|
|
133
235
|
expect(
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { AssetRegistry, HANDLE_CUBE, RuntimeMaterialValue } from '@forgeax/engine-assets-runtime';
|
|
2
|
+
import { World } from '@forgeax/engine-ecs';
|
|
3
|
+
import { Transform } from '@forgeax/engine-scene';
|
|
4
|
+
import { ShaderRegistry } from '@forgeax/engine-shader';
|
|
5
|
+
import type { MaterialAsset } from '@forgeax/engine-types';
|
|
6
|
+
import { expect, it } from 'vitest';
|
|
7
|
+
import {
|
|
8
|
+
MATERIAL_CONTEXT,
|
|
9
|
+
materialRecordFixture,
|
|
10
|
+
} from '../../../assets-runtime/src/__tests__/fixtures/material-publication';
|
|
11
|
+
import { MeshFilter, MeshRenderer } from '../components';
|
|
12
|
+
import { extractFrames, resolveMaterialSnapshot } from '../render-system-extract';
|
|
13
|
+
|
|
14
|
+
it('keeps cooked Forward and ShadowCaster programs when World values change or a catalogue entry is replaced', () => {
|
|
15
|
+
const assets = new AssetRegistry(new ShaderRegistry({ manifestUrl: undefined }));
|
|
16
|
+
const world = new World();
|
|
17
|
+
const record = materialRecordFixture({
|
|
18
|
+
parameters: [{ name: 'roughness', type: 'f32' }],
|
|
19
|
+
values: { roughness: 0.8 },
|
|
20
|
+
passes: [
|
|
21
|
+
{ name: 'Forward', program: { module: 'game::material' } },
|
|
22
|
+
{ name: 'ShadowCaster', program: { module: 'game::shadow' } },
|
|
23
|
+
],
|
|
24
|
+
});
|
|
25
|
+
const base: MaterialAsset = { kind: 'material', ...record.resolved };
|
|
26
|
+
assets.catalog(record.guid, base).unwrap();
|
|
27
|
+
if (!record.sourceClosure || !record.parameterContract)
|
|
28
|
+
throw new Error('Missing cooked metadata');
|
|
29
|
+
assets.recordMaterialReadiness(record.guid, {
|
|
30
|
+
status: 'Ready',
|
|
31
|
+
guid: record.guid,
|
|
32
|
+
materialGuid: record.guid,
|
|
33
|
+
publicationGeneration: record.publicationGeneration,
|
|
34
|
+
specializationKey: record.specializationKey,
|
|
35
|
+
artifactDigest: record.artifactDigest,
|
|
36
|
+
sourceClosure: record.sourceClosure,
|
|
37
|
+
parameterContract: record.parameterContract,
|
|
38
|
+
record,
|
|
39
|
+
programs: record.programs,
|
|
40
|
+
});
|
|
41
|
+
const handle = world.internSharedRef('MaterialAsset', base);
|
|
42
|
+
world
|
|
43
|
+
.spawn(
|
|
44
|
+
{ component: Transform, data: {} },
|
|
45
|
+
{ component: MeshFilter, data: { assetHandle: HANDLE_CUBE } },
|
|
46
|
+
{ component: MeshRenderer, data: { materials: [handle] } },
|
|
47
|
+
)
|
|
48
|
+
.unwrap();
|
|
49
|
+
world.update(0).unwrap();
|
|
50
|
+
const frameRead = () =>
|
|
51
|
+
extractFrames([world], 0, assets, undefined, undefined, {
|
|
52
|
+
materialContext: MATERIAL_CONTEXT,
|
|
53
|
+
cull: 'none',
|
|
54
|
+
}).renderables[0]?.materials[0];
|
|
55
|
+
const read = () =>
|
|
56
|
+
resolveMaterialSnapshot(handle, world, assets, undefined, undefined, MATERIAL_CONTEXT);
|
|
57
|
+
const before = read();
|
|
58
|
+
expect(frameRead()?.materialProgramKeys).toEqual(before.materialProgramKeys);
|
|
59
|
+
expect(Object.keys(before.materialProgramKeys ?? {})).toEqual(['Forward', 'ShadowCaster']);
|
|
60
|
+
const content = world
|
|
61
|
+
.spawn({
|
|
62
|
+
component: RuntimeMaterialValue,
|
|
63
|
+
data: {
|
|
64
|
+
asset: handle,
|
|
65
|
+
parameter: 'roughness',
|
|
66
|
+
kind: 0,
|
|
67
|
+
value: [0.25],
|
|
68
|
+
},
|
|
69
|
+
})
|
|
70
|
+
.unwrap();
|
|
71
|
+
expect(read().roughness).toBe(0.25);
|
|
72
|
+
expect(frameRead()?.materialProgramKeys).toEqual(before.materialProgramKeys);
|
|
73
|
+
expect(read().materialProgramKeys).toEqual(before.materialProgramKeys);
|
|
74
|
+
assets.catalog(record.guid, { ...base, values: { roughness: 0.6 } }).unwrap();
|
|
75
|
+
expect(read().materialProgramKeys).toEqual(before.materialProgramKeys);
|
|
76
|
+
world.despawn(content).unwrap();
|
|
77
|
+
expect(read().roughness).toBeCloseTo(0.8);
|
|
78
|
+
expect(read().materialProgramKeys).toEqual(before.materialProgramKeys);
|
|
79
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { deriveVertexBufferLayout } from '@forgeax/engine-geometry';
|
|
2
2
|
import { describe, expect, it } from 'vitest';
|
|
3
3
|
import {
|
|
4
4
|
BILLBOARD_MATERIAL_INSTANCE_VERTEX_BUFFERS,
|
|
@@ -17,11 +17,31 @@ const FLOAT32_COMPONENT_COUNT = {
|
|
|
17
17
|
describe('prepared feature vertex layout SSOT', () => {
|
|
18
18
|
it('derives the mesh vertex stream from geometry layout ownership', () => {
|
|
19
19
|
const [expected] = toGpuVertexBufferLayouts(
|
|
20
|
-
deriveVertexBufferLayout(
|
|
20
|
+
deriveVertexBufferLayout({
|
|
21
|
+
position: new Float32Array(),
|
|
22
|
+
normal: new Float32Array(),
|
|
23
|
+
uv: new Float32Array(),
|
|
24
|
+
tangent: new Float32Array(),
|
|
25
|
+
uv1: new Float32Array(),
|
|
26
|
+
color: new Float32Array(),
|
|
27
|
+
}),
|
|
21
28
|
);
|
|
22
29
|
const [actual] = MESH_GEOMETRY_MATERIAL_INSTANCE_VERTEX_BUFFERS;
|
|
23
30
|
|
|
24
|
-
|
|
31
|
+
if (expected === undefined) throw new Error('Missing geometry descriptor');
|
|
32
|
+
expect(actual).toEqual({
|
|
33
|
+
...expected,
|
|
34
|
+
stepMode: 'vertex',
|
|
35
|
+
attributes: expected.attributes.map((attribute) => ({
|
|
36
|
+
...attribute,
|
|
37
|
+
shaderLocation:
|
|
38
|
+
attribute.shaderLocation === 6
|
|
39
|
+
? 15
|
|
40
|
+
: attribute.shaderLocation === 13
|
|
41
|
+
? 14
|
|
42
|
+
: attribute.shaderLocation,
|
|
43
|
+
})),
|
|
44
|
+
});
|
|
25
45
|
});
|
|
26
46
|
|
|
27
47
|
it.each([
|
|
@@ -570,6 +570,8 @@ async function makeWebGPURenderer(
|
|
|
570
570
|
const getShader = (): ShaderCatalog => {
|
|
571
571
|
const state = candidateShaderState ?? activeShaderState;
|
|
572
572
|
if (state.shaderInstance === null) {
|
|
573
|
+
const activeCatalog = activeShaderState.shaderInstance;
|
|
574
|
+
// Reuse validated manifest CPU rows; candidate preparation selects variants.
|
|
573
575
|
// feat-20260608-create-app-param-surface-trim / M2 / D-2 q5-A:
|
|
574
576
|
// shaderManifestUrl moved to BundlerOptions (third arg). The fallback
|
|
575
577
|
// literal '/shaders/manifest.json' stays here so the LO 1.1
|
|
@@ -578,15 +580,16 @@ async function makeWebGPURenderer(
|
|
|
578
580
|
// the zero-entry opt-in: explicitly passing
|
|
579
581
|
// `bundler: { shaderManifestUrl: undefined }` retains the
|
|
580
582
|
// old "no manifest fetched" mode used by tests / camera-only worlds.
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
583
|
+
state.shaderInstance =
|
|
584
|
+
state === candidateShaderState && activeCatalog !== null
|
|
585
|
+
? activeCatalog.forkForDevice(getShaderModuleAdapter())
|
|
586
|
+
: new ShaderCatalog({
|
|
587
|
+
device: getShaderModuleAdapter(),
|
|
588
|
+
manifestUrl:
|
|
589
|
+
internals.bundler !== undefined && 'shaderManifestUrl' in internals.bundler
|
|
590
|
+
? internals.bundler.shaderManifestUrl
|
|
591
|
+
: '/shaders/manifest.json',
|
|
592
|
+
});
|
|
590
593
|
}
|
|
591
594
|
return state.shaderInstance;
|
|
592
595
|
};
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
type VertexLayoutProjection,
|
|
9
9
|
} from '@forgeax/engine-geometry';
|
|
10
10
|
import type { VertexAttributeMap } from '@forgeax/engine-types';
|
|
11
|
+
import { PARTICLE_MESH_VERTEX_BUFFER } from '../features/particle-mesh-layout';
|
|
11
12
|
import { RENDER_FEATURE_VERTEX_LAYOUTS } from '../features/prepared-graphics';
|
|
12
13
|
|
|
13
14
|
type FeatureFloatVertexFormat = 'float32x2' | 'float32x3' | 'float32x4';
|
|
@@ -101,14 +102,6 @@ const MESH_GEOMETRY_MATERIAL_INSTANCE_SPEC = {
|
|
|
101
102
|
export const PREPARED_INSTANCE_VERTEX_ATTRS = buildMeshAttributeMapForUvSets(2);
|
|
102
103
|
export const PREPARED_MATERIAL_INSTANCE_VERTEX_ATTRS = SKIN_VERTEX_ATTRIBUTE_MAP;
|
|
103
104
|
|
|
104
|
-
function canonicalMeshVertexBufferLayout(): GPUVertexBufferLayout {
|
|
105
|
-
const [buffer] = toGpuVertexBufferLayouts(deriveVertexBufferLayout(DEFAULT_VERTEX_ATTRIBUTE_MAP));
|
|
106
|
-
if (buffer === undefined) {
|
|
107
|
-
throw new Error('The canonical mesh vertex layout must contain one vertex buffer');
|
|
108
|
-
}
|
|
109
|
-
return { ...buffer, stepMode: 'vertex' };
|
|
110
|
-
}
|
|
111
|
-
|
|
112
105
|
export const POSITION_SIZE_COLOR_INSTANCE_VERTEX_BUFFERS = [
|
|
113
106
|
defineFeatureVertexBuffer(POSITION_SIZE_COLOR_INSTANCE_SPEC),
|
|
114
107
|
] satisfies readonly GPUVertexBufferLayout[];
|
|
@@ -127,7 +120,7 @@ export const TOPOLOGY_SEGMENT_INSTANCE_VERTEX_BUFFERS = [
|
|
|
127
120
|
* particle transform/material data rather than MeshAsset attributes.
|
|
128
121
|
*/
|
|
129
122
|
export const MESH_GEOMETRY_MATERIAL_INSTANCE_VERTEX_BUFFERS = [
|
|
130
|
-
|
|
123
|
+
PARTICLE_MESH_VERTEX_BUFFER,
|
|
131
124
|
defineFeatureVertexBuffer(MESH_GEOMETRY_MATERIAL_INSTANCE_SPEC),
|
|
132
125
|
] satisfies readonly GPUVertexBufferLayout[];
|
|
133
126
|
|
|
@@ -176,6 +176,7 @@ export class DeviceScope {
|
|
|
176
176
|
this.clearChildren();
|
|
177
177
|
this._clearResources();
|
|
178
178
|
this.lifecycleState = 'retired';
|
|
179
|
+
this.parent?.detachChild(this);
|
|
179
180
|
}
|
|
180
181
|
|
|
181
182
|
abandon(): void {
|
|
@@ -183,6 +184,7 @@ export class DeviceScope {
|
|
|
183
184
|
this.lifecycleState = 'abandoned';
|
|
184
185
|
this.clearChildren();
|
|
185
186
|
this._clearResources();
|
|
187
|
+
this.parent?.detachChild(this);
|
|
186
188
|
}
|
|
187
189
|
|
|
188
190
|
dispose(): void {
|
|
@@ -190,6 +192,7 @@ export class DeviceScope {
|
|
|
190
192
|
this.lifecycleState = 'disposed';
|
|
191
193
|
this.clearChildren();
|
|
192
194
|
this._clearResources();
|
|
195
|
+
this.parent?.detachChild(this);
|
|
193
196
|
}
|
|
194
197
|
|
|
195
198
|
async replace(
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { deriveVertexLayoutProjection } from '@forgeax/engine-geometry';
|
|
2
|
+
|
|
3
|
+
// Mesh attributes keep geometry's canonical packing. Only locations change:
|
|
4
|
+
// the particle instance occupies 4..9 and custom material inputs occupy 10..13.
|
|
5
|
+
export const PARTICLE_MESH_DEFAULTS = {
|
|
6
|
+
position: [0, 0, 0],
|
|
7
|
+
normal: [0, 0, 1],
|
|
8
|
+
uv: [0, 0],
|
|
9
|
+
tangent: [1, 0, 0, 1],
|
|
10
|
+
uv1: [0, 0],
|
|
11
|
+
color: [1, 1, 1, 1],
|
|
12
|
+
} as const;
|
|
13
|
+
export const PARTICLE_MESH_GEOMETRY = deriveVertexLayoutProjection(
|
|
14
|
+
Object.fromEntries(Object.keys(PARTICLE_MESH_DEFAULTS).map((key) => [key, new Float32Array()])),
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
export const PARTICLE_MESH_VERTEX_BUFFER = {
|
|
18
|
+
arrayStride: PARTICLE_MESH_GEOMETRY.arrayStride,
|
|
19
|
+
stepMode: 'vertex' as const,
|
|
20
|
+
attributes: PARTICLE_MESH_GEOMETRY.attributes.map(({ key, shaderLocation, offset, format }) => ({
|
|
21
|
+
shaderLocation: key === 'color' ? 14 : key === 'uv1' ? 15 : shaderLocation,
|
|
22
|
+
offset,
|
|
23
|
+
format,
|
|
24
|
+
})),
|
|
25
|
+
};
|
package/src/features/types.ts
CHANGED
|
@@ -180,7 +180,11 @@ export interface RenderFeature<FrameData> {
|
|
|
180
180
|
* extracted frame and never during declarative planning.
|
|
181
181
|
*/
|
|
182
182
|
onFrameSubmitted?(data: FrameData, submission?: RenderFeatureSubmission): void;
|
|
183
|
-
/**
|
|
183
|
+
/**
|
|
184
|
+
* Consume source-owned intents after submission; feedback must be structured-cloneable.
|
|
185
|
+
* A sealed successor may have been extracted before this acknowledgment. Ordered
|
|
186
|
+
* intent producers must identify already-submitted work instead of replaying it.
|
|
187
|
+
*/
|
|
184
188
|
onSourceFrameSubmitted?(data: FrameData, feedback: unknown): void;
|
|
185
189
|
/**
|
|
186
190
|
* Discard frame-owned producer state when graph admission or submission is
|
|
@@ -1359,6 +1359,7 @@ export interface PreparedGpuDrivenFrame {
|
|
|
1359
1359
|
readonly projectShadow?: (
|
|
1360
1360
|
graph: RenderGraphBuilder<RenderPipelineFrame>,
|
|
1361
1361
|
identity: ShadowViewIdentity,
|
|
1362
|
+
forceCompute?: boolean,
|
|
1362
1363
|
) => Result<ShadowViewProjection | undefined, RenderGraphError>;
|
|
1363
1364
|
readonly updateShadowViews?: (
|
|
1364
1365
|
views: readonly Omit<ShadowViewUpdateInput, 'sourcePlan' | 'scene'>[],
|
|
@@ -2572,7 +2573,12 @@ export class GpuDrivenProduction {
|
|
|
2572
2573
|
?.inspect()
|
|
2573
2574
|
.map(
|
|
2574
2575
|
(entry) =>
|
|
2575
|
-
|
|
2576
|
+
// Cache decisions and logical update generations are frame-local
|
|
2577
|
+
// producer state. They must not feed the compiled graph key: a
|
|
2578
|
+
// graph replacement may intentionally invalidate a shadow view
|
|
2579
|
+
// once without creating a graph-rebuild feedback loop. Resource
|
|
2580
|
+
// and source-plan generations remain real graph dependencies.
|
|
2581
|
+
`${shadowViewIdentityKey(entry.identity)}:${entry.sourceRevision}:${entry.resourceGeneration}`,
|
|
2576
2582
|
)
|
|
2577
2583
|
.join('|') ?? '';
|
|
2578
2584
|
const production = this;
|
|
@@ -2689,10 +2695,10 @@ export class GpuDrivenProduction {
|
|
|
2689
2695
|
frameNumber,
|
|
2690
2696
|
),
|
|
2691
2697
|
}),
|
|
2692
|
-
projectShadow: (graph, identity) => {
|
|
2698
|
+
projectShadow: (graph, identity, forceCompute = false) => {
|
|
2693
2699
|
if (!shadowViewPool.isActive(identity) || shadowViewPool.submission(identity) === undefined)
|
|
2694
2700
|
return ok(undefined);
|
|
2695
|
-
return shadowViewPool.project(graph, identity);
|
|
2701
|
+
return shadowViewPool.project(graph, identity, forceCompute);
|
|
2696
2702
|
},
|
|
2697
2703
|
updateShadowViews: (views) =>
|
|
2698
2704
|
shadowFiltered.plan.batches.length > 0
|
|
@@ -395,14 +395,39 @@ export class ShadowViewStatePool {
|
|
|
395
395
|
project<FrameCtx extends RenderGraphFrame>(
|
|
396
396
|
builder: RenderGraphBuilder<FrameCtx>,
|
|
397
397
|
identity: ShadowViewIdentity,
|
|
398
|
+
forceCompute = false,
|
|
398
399
|
): Result<ShadowViewProjection, RenderGraphError> {
|
|
399
400
|
const record = this.records.get(identityKey(identity));
|
|
400
401
|
if (record === undefined) return err(graphStateError(identity));
|
|
401
402
|
const cacheHit = record.cache === 'hit' && !record.invalidated;
|
|
403
|
+
// A graph replacement must carry the shadow compute passes even when the
|
|
404
|
+
// view itself is a cache hit. The graph owns the pass list, so omitting
|
|
405
|
+
// compute work while importing a replacement graph would leave its
|
|
406
|
+
// indirect arguments stale. The normal pool contract still skips work on
|
|
407
|
+
// a cache hit; graph projection opts in explicitly at that boundary.
|
|
408
|
+
const includeCompute = forceCompute || !cacheHit;
|
|
409
|
+
let forceComputePending = forceCompute;
|
|
410
|
+
let lastFrame: FrameCtx | undefined;
|
|
411
|
+
let executeForFrame = false;
|
|
412
|
+
const executeCompute = (frame: FrameCtx): boolean => {
|
|
413
|
+
if (frame !== lastFrame) {
|
|
414
|
+
lastFrame = frame;
|
|
415
|
+
const currentRecord = this.records.get(identityKey(record.identity));
|
|
416
|
+
executeForFrame =
|
|
417
|
+
forceComputePending ||
|
|
418
|
+
currentRecord === undefined ||
|
|
419
|
+
currentRecord.cache !== 'hit' ||
|
|
420
|
+
currentRecord.invalidated;
|
|
421
|
+
forceComputePending = false;
|
|
422
|
+
}
|
|
423
|
+
return executeForFrame;
|
|
424
|
+
};
|
|
402
425
|
const projected = record.view.addPasses(
|
|
403
426
|
builder,
|
|
404
427
|
shadowViewLabelPrefix(record.identity),
|
|
405
|
-
|
|
428
|
+
includeCompute,
|
|
429
|
+
undefined,
|
|
430
|
+
includeCompute ? executeCompute : undefined,
|
|
406
431
|
);
|
|
407
432
|
if (!projected.ok) return projected;
|
|
408
433
|
return ok({
|
|
@@ -411,7 +436,7 @@ export class ShadowViewStatePool {
|
|
|
411
436
|
generation: record.generation,
|
|
412
437
|
plan: record.plan,
|
|
413
438
|
view: record.view,
|
|
414
|
-
passNames:
|
|
439
|
+
passNames: includeCompute ? passNames(record.identity) : Object.freeze([]),
|
|
415
440
|
graphResources: projected.value,
|
|
416
441
|
});
|
|
417
442
|
}
|