@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.
Files changed (87) hide show
  1. package/README.md +17 -0
  2. package/dist/assembly/webgpu-vertex-layouts.d.ts.map +1 -1
  3. package/dist/{chunk-MXTWKZO4.mjs → chunk-2OZAS3WU.mjs} +225 -4
  4. package/dist/chunk-2OZAS3WU.mjs.map +1 -0
  5. package/dist/{chunk-G6HDNGY7.mjs → chunk-BZQ4CDRF.mjs} +24 -230
  6. package/dist/chunk-BZQ4CDRF.mjs.map +1 -0
  7. package/dist/{chunk-LNIUIKQ7.mjs → chunk-CJOV2XLO.mjs} +77 -19
  8. package/dist/chunk-CJOV2XLO.mjs.map +1 -0
  9. package/dist/{chunk-3XUBWRY2.mjs → chunk-D6WSBXJ2.mjs} +4 -4
  10. package/dist/{chunk-3XUBWRY2.mjs.map → chunk-D6WSBXJ2.mjs.map} +1 -1
  11. package/dist/{chunk-LM5IE7GF.mjs → chunk-DRF6OULX.mjs} +3 -3
  12. package/dist/{chunk-LM5IE7GF.mjs.map → chunk-DRF6OULX.mjs.map} +1 -1
  13. package/dist/{chunk-SGPUJW3F.mjs → chunk-OB5WAFYH.mjs} +3 -3
  14. package/dist/{chunk-SGPUJW3F.mjs.map → chunk-OB5WAFYH.mjs.map} +1 -1
  15. package/dist/{chunk-MZ4C7EHF.mjs → chunk-OEYKKMXG.mjs} +5 -5
  16. package/dist/{chunk-MZ4C7EHF.mjs.map → chunk-OEYKKMXG.mjs.map} +1 -1
  17. package/dist/construct-renderer.mjs +130 -71
  18. package/dist/construct-renderer.mjs.map +1 -1
  19. package/dist/device/device-scope.d.ts.map +1 -1
  20. package/dist/features/particle-mesh-layout.d.ts +19 -0
  21. package/dist/features/particle-mesh-layout.d.ts.map +1 -0
  22. package/dist/features/types.d.ts +5 -1
  23. package/dist/features/types.d.ts.map +1 -1
  24. package/dist/gpu-driven/production-raster.d.ts +1 -1
  25. package/dist/gpu-driven/production-raster.d.ts.map +1 -1
  26. package/dist/gpu-driven/shadow-views.d.ts +1 -1
  27. package/dist/gpu-driven/shadow-views.d.ts.map +1 -1
  28. package/dist/gpu-driven/view-gpu.d.ts +1 -1
  29. package/dist/gpu-driven/view-gpu.d.ts.map +1 -1
  30. package/dist/index.mjs +48 -36
  31. package/dist/index.mjs.map +1 -1
  32. package/dist/internal.mjs +5 -5
  33. package/dist/pbr-pipeline.d.ts.map +1 -1
  34. package/dist/publication/publisher.d.ts +1 -0
  35. package/dist/publication/publisher.d.ts.map +1 -1
  36. package/dist/record/frame.d.ts.map +1 -1
  37. package/dist/record/prepared-material-bindings.d.ts +1 -2
  38. package/dist/record/prepared-material-bindings.d.ts.map +1 -1
  39. package/dist/record/render-context.d.ts +2 -0
  40. package/dist/record/render-context.d.ts.map +1 -1
  41. package/dist/record/shadow-pass.d.ts +2 -2
  42. package/dist/record/shadow-pass.d.ts.map +1 -1
  43. package/dist/record/typed-frame-graph.d.ts.map +1 -1
  44. package/dist/recovery/render-system-candidate.d.ts +1 -8
  45. package/dist/recovery/render-system-candidate.d.ts.map +1 -1
  46. package/dist/render-system-extract-tail.d.ts.map +1 -1
  47. package/dist/render-system-extract.d.ts.map +1 -1
  48. package/dist/render-system.d.ts.map +1 -1
  49. package/dist/temporal/index.mjs +4 -4
  50. package/dist/typed-shadow-passes.d.ts.map +1 -1
  51. package/package.json +21 -21
  52. package/src/__tests__/device-scope-lifecycle.unit.test.ts +26 -0
  53. package/src/__tests__/gpu-driven-production.integration.test.ts +18 -1
  54. package/src/__tests__/gpu-driven-shadow-views.unit.test.ts +23 -0
  55. package/src/__tests__/material-vertex-texture.browser.test.ts +7 -0
  56. package/src/__tests__/material-vertex-texture.dawn.test.ts +7 -0
  57. package/src/__tests__/material-vertex-texture.fixture.ts +31 -0
  58. package/src/__tests__/recovery-candidate-prepare.contract.test.ts +5 -0
  59. package/src/__tests__/renderer-factory-material-contract.unit.test.ts +106 -4
  60. package/src/__tests__/runtime-material-program.integration.test.ts +79 -0
  61. package/src/__tests__/vertex-layouts-ssot.unit.test.ts +23 -3
  62. package/src/assembly/webgpu-renderer.ts +12 -9
  63. package/src/assembly/webgpu-vertex-layouts.ts +2 -9
  64. package/src/device/device-scope.ts +3 -0
  65. package/src/features/particle-mesh-layout.ts +25 -0
  66. package/src/features/types.ts +5 -1
  67. package/src/gpu-driven/production-raster.ts +9 -3
  68. package/src/gpu-driven/shadow-views.ts +27 -2
  69. package/src/gpu-driven/view-gpu.ts +5 -0
  70. package/src/pbr-pipeline.ts +4 -1
  71. package/src/publication/__tests__/features.integration.test.ts +51 -0
  72. package/src/publication/__tests__/publication.integration.test.ts +45 -0
  73. package/src/publication/publisher.ts +56 -29
  74. package/src/record/__tests__/prepared-runtime-material.integration.test.ts +134 -0
  75. package/src/record/frame.ts +45 -6
  76. package/src/record/prepared-material-bindings.ts +11 -37
  77. package/src/record/render-context.ts +2 -0
  78. package/src/record/shadow-pass.ts +65 -4
  79. package/src/record/typed-frame-graph.ts +5 -1
  80. package/src/recovery/render-system-candidate.ts +1 -9
  81. package/src/render-system-extract-tail.ts +15 -6
  82. package/src/render-system-extract.ts +9 -2
  83. package/src/render-system.ts +0 -1
  84. package/src/typed-shadow-passes.ts +36 -5
  85. package/dist/chunk-G6HDNGY7.mjs.map +0 -1
  86. package/dist/chunk-LNIUIKQ7.mjs.map +0 -1
  87. 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,7 @@
1
+ import { it } from 'vitest';
2
+ import { verifyMaterialVertexTexture } from './material-vertex-texture.fixture';
3
+
4
+ it(
5
+ 'admits explicit-LOD vertex sampling in the native material user layout',
6
+ verifyMaterialVertexTexture,
7
+ );
@@ -0,0 +1,7 @@
1
+ import { it } from 'vitest';
2
+ import { verifyMaterialVertexTexture } from './material-vertex-texture.fixture';
3
+
4
+ it(
5
+ 'admits explicit-LOD vertex sampling in the native material user layout',
6
+ verifyMaterialVertexTexture,
7
+ );
@@ -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 type {
5
- MaterialShaderManifestEntry,
6
- MaterialShaderManifestVariant,
7
- ShaderCatalog,
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 { DEFAULT_VERTEX_ATTRIBUTE_MAP, deriveVertexBufferLayout } from '@forgeax/engine-geometry';
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(DEFAULT_VERTEX_ATTRIBUTE_MAP),
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
- expect(actual).toEqual({ ...expected, stepMode: 'vertex' });
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
- const bundler = internals.bundler;
582
- const explicitUrl = bundler?.shaderManifestUrl;
583
- state.shaderInstance = new ShaderCatalog({
584
- device: getShaderModuleAdapter(),
585
- manifestUrl:
586
- bundler !== undefined && 'shaderManifestUrl' in bundler
587
- ? explicitUrl
588
- : '/shaders/manifest.json',
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
- canonicalMeshVertexBufferLayout(),
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
+ };
@@ -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
- /** Consume source-owned intents after submission; feedback must be structured-cloneable. */
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
- `${shadowViewIdentityKey(entry.identity)}:${entry.cache}:${entry.generation}:${entry.sourceRevision}:${entry.resourceGeneration}`,
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
- !cacheHit,
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: cacheHit ? Object.freeze([]) : passNames(record.identity),
439
+ passNames: includeCompute ? passNames(record.identity) : Object.freeze([]),
415
440
  graphResources: projected.value,
416
441
  });
417
442
  }