@forgeax/engine-render 0.1.35 → 0.1.37

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 (101) hide show
  1. package/README.md +28 -0
  2. package/dist/assembly/host-contract.d.ts +2 -2
  3. package/dist/assembly/host-contract.d.ts.map +1 -1
  4. package/dist/assembly/webgpu-vertex-layouts.d.ts.map +1 -1
  5. package/dist/{chunk-MZ4C7EHF.mjs → chunk-3F5XYSN3.mjs} +5 -5
  6. package/dist/{chunk-MZ4C7EHF.mjs.map → chunk-3F5XYSN3.mjs.map} +1 -1
  7. package/dist/{chunk-G6HDNGY7.mjs → chunk-3O2BBKEL.mjs} +24 -230
  8. package/dist/chunk-3O2BBKEL.mjs.map +1 -0
  9. package/dist/{chunk-MXTWKZO4.mjs → chunk-ARLAEJ45.mjs} +225 -4
  10. package/dist/chunk-ARLAEJ45.mjs.map +1 -0
  11. package/dist/{chunk-3XUBWRY2.mjs → chunk-E4NJYUJ2.mjs} +4 -4
  12. package/dist/{chunk-3XUBWRY2.mjs.map → chunk-E4NJYUJ2.mjs.map} +1 -1
  13. package/dist/{chunk-LM5IE7GF.mjs → chunk-N7NVXQWI.mjs} +3 -3
  14. package/dist/{chunk-LM5IE7GF.mjs.map → chunk-N7NVXQWI.mjs.map} +1 -1
  15. package/dist/{chunk-SGPUJW3F.mjs → chunk-NMSOOXBB.mjs} +3 -3
  16. package/dist/{chunk-SGPUJW3F.mjs.map → chunk-NMSOOXBB.mjs.map} +1 -1
  17. package/dist/{chunk-LNIUIKQ7.mjs → chunk-SJIPVWNB.mjs} +79 -21
  18. package/dist/chunk-SJIPVWNB.mjs.map +1 -0
  19. package/dist/construct-renderer.mjs +141 -74
  20. package/dist/construct-renderer.mjs.map +1 -1
  21. package/dist/device/device-scope.d.ts.map +1 -1
  22. package/dist/features/particle-mesh-layout.d.ts +19 -0
  23. package/dist/features/particle-mesh-layout.d.ts.map +1 -0
  24. package/dist/features/types.d.ts +5 -1
  25. package/dist/features/types.d.ts.map +1 -1
  26. package/dist/gpu-driven/production-raster.d.ts +1 -1
  27. package/dist/gpu-driven/production-raster.d.ts.map +1 -1
  28. package/dist/gpu-driven/shadow-views.d.ts +1 -1
  29. package/dist/gpu-driven/shadow-views.d.ts.map +1 -1
  30. package/dist/gpu-driven/view-gpu.d.ts +1 -1
  31. package/dist/gpu-driven/view-gpu.d.ts.map +1 -1
  32. package/dist/ibl/IblPipelineCache.d.ts.map +1 -1
  33. package/dist/index.mjs +48 -36
  34. package/dist/index.mjs.map +1 -1
  35. package/dist/internal.mjs +5 -5
  36. package/dist/pbr-pipeline.d.ts.map +1 -1
  37. package/dist/publication/publisher.d.ts +1 -0
  38. package/dist/publication/publisher.d.ts.map +1 -1
  39. package/dist/record/frame.d.ts.map +1 -1
  40. package/dist/record/prepared-material-bindings.d.ts +1 -2
  41. package/dist/record/prepared-material-bindings.d.ts.map +1 -1
  42. package/dist/record/render-context.d.ts +2 -0
  43. package/dist/record/render-context.d.ts.map +1 -1
  44. package/dist/record/shadow-pass.d.ts +2 -2
  45. package/dist/record/shadow-pass.d.ts.map +1 -1
  46. package/dist/record/typed-frame-graph.d.ts.map +1 -1
  47. package/dist/recovery/render-system-candidate.d.ts +1 -8
  48. package/dist/recovery/render-system-candidate.d.ts.map +1 -1
  49. package/dist/render-contract.d.ts +2 -2
  50. package/dist/render-contract.d.ts.map +1 -1
  51. package/dist/render-system-extract-tail.d.ts.map +1 -1
  52. package/dist/render-system-extract.d.ts.map +1 -1
  53. package/dist/render-system.d.ts +2 -1
  54. package/dist/render-system.d.ts.map +1 -1
  55. package/dist/scene/render-scene.d.ts +2 -1
  56. package/dist/scene/render-scene.d.ts.map +1 -1
  57. package/dist/temporal/index.mjs +4 -4
  58. package/dist/typed-shadow-passes.d.ts.map +1 -1
  59. package/package.json +21 -21
  60. package/src/__tests__/device-scope-lifecycle.unit.test.ts +26 -0
  61. package/src/__tests__/gpu-driven-production.integration.test.ts +41 -1
  62. package/src/__tests__/gpu-driven-shadow-views.unit.test.ts +23 -0
  63. package/src/__tests__/ibl/precompute-submission.unit.test.ts +123 -0
  64. package/src/__tests__/ibl-residency-publication.unit.test.ts +0 -16
  65. package/src/__tests__/material-vertex-texture.browser.test.ts +7 -0
  66. package/src/__tests__/material-vertex-texture.dawn.test.ts +7 -0
  67. package/src/__tests__/material-vertex-texture.fixture.ts +31 -0
  68. package/src/__tests__/recovery-candidate-prepare.contract.test.ts +5 -0
  69. package/src/__tests__/renderer-factory-material-contract.unit.test.ts +106 -4
  70. package/src/__tests__/runtime-material-program.integration.test.ts +79 -0
  71. package/src/__tests__/vertex-layouts-ssot.unit.test.ts +23 -3
  72. package/src/assembly/host-contract.ts +2 -2
  73. package/src/assembly/webgpu-renderer.ts +12 -9
  74. package/src/assembly/webgpu-vertex-layouts.ts +2 -9
  75. package/src/device/device-scope.ts +3 -0
  76. package/src/features/particle-mesh-layout.ts +25 -0
  77. package/src/features/types.ts +5 -1
  78. package/src/gpu-driven/production-raster.ts +23 -6
  79. package/src/gpu-driven/shadow-views.ts +27 -2
  80. package/src/gpu-driven/view-gpu.ts +5 -0
  81. package/src/ibl/IblPipelineCache.ts +4 -3
  82. package/src/pbr-pipeline.ts +4 -1
  83. package/src/publication/__tests__/features.integration.test.ts +51 -0
  84. package/src/publication/__tests__/publication.integration.test.ts +59 -0
  85. package/src/publication/publisher.ts +56 -29
  86. package/src/record/__tests__/prepared-runtime-material.integration.test.ts +134 -0
  87. package/src/record/frame.ts +45 -6
  88. package/src/record/prepared-material-bindings.ts +11 -37
  89. package/src/record/render-context.ts +2 -0
  90. package/src/record/shadow-pass.ts +65 -4
  91. package/src/record/typed-frame-graph.ts +5 -1
  92. package/src/recovery/render-system-candidate.ts +1 -9
  93. package/src/render-contract.ts +2 -2
  94. package/src/render-system-extract-tail.ts +15 -6
  95. package/src/render-system-extract.ts +9 -2
  96. package/src/render-system.ts +2 -3
  97. package/src/scene/render-scene.ts +4 -2
  98. package/src/typed-shadow-passes.ts +36 -5
  99. package/dist/chunk-G6HDNGY7.mjs.map +0 -1
  100. package/dist/chunk-LNIUIKQ7.mjs.map +0 -1
  101. package/dist/chunk-MXTWKZO4.mjs.map +0 -1
@@ -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([
@@ -14,7 +14,7 @@ import type { ObservationUnavailableError, RenderError } from '../errors/render'
14
14
  import type { RenderFeature, RenderFeatureDiagnostics } from '../features/types';
15
15
  import type { GpuDrivenProductionInspection, LodOcclusionInspection } from '../inspection-types';
16
16
  import type { MeshMaterialBindingObservation } from '../mesh-material-bindings';
17
- import type { PublishedRenderFrameInput } from '../publication/contract';
17
+ import type { PublishedRenderFrameInput, RenderPublicationIdentity } from '../publication/contract';
18
18
  import type { FrameObservation, FrameObservationOptions } from '../record/frame';
19
19
  import type { CurrentGraphTarget, GraphTargetCaptureRequest } from '../record/frame-snapshot';
20
20
  import type {
@@ -110,7 +110,7 @@ export interface RendererHostImplementation {
110
110
  destroyRenderTarget(target: RenderTarget): RenderResult<void, RenderError>;
111
111
  setProfile(profile: RenderProfile): RenderResult<void, RenderError>;
112
112
  /** Detached world bounds for one record in the last extracted World; undefined if unavailable. */
113
- bounds(world: World, entity: number): RenderSceneBounds | undefined;
113
+ bounds(world: World | RenderPublicationIdentity, entity: number): RenderSceneBounds | undefined;
114
114
  inspect(): RenderInspection;
115
115
  /**
116
116
  * Internal bounded inspection for high-cardinality producers. The public
@@ -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'>[],
@@ -1404,7 +1405,7 @@ interface GpuDrivenResidencyValidationCache extends GpuDrivenResidencyValidation
1404
1405
 
1405
1406
  interface GpuDrivenCpuValidationTelemetryCache {
1406
1407
  readonly cacheKey: string;
1407
- readonly validated: readonly { readonly source: RenderableSnapshot }[];
1408
+ readonly validatedSources: readonly RenderableSnapshot[];
1408
1409
  readonly gpuOwnedDrawKeys: ReadonlySet<string>;
1409
1410
  readonly worldKeys: readonly number[] | undefined;
1410
1411
  readonly gpuOwnedDrawCount: number;
@@ -1420,6 +1421,17 @@ interface GpuDrivenCpuValidationTelemetryCache {
1420
1421
  readonly blockedDrawItems: number;
1421
1422
  }
1422
1423
 
1424
+ function sameValidatedSourceSequence(
1425
+ expected: readonly RenderableSnapshot[],
1426
+ actual: readonly { readonly source: RenderableSnapshot }[],
1427
+ ): boolean {
1428
+ if (expected.length !== actual.length) return false;
1429
+ for (let index = 0; index < expected.length; index += 1) {
1430
+ if (expected[index] !== actual[index]?.source) return false;
1431
+ }
1432
+ return true;
1433
+ }
1434
+
1423
1435
  function sameIdentitySequence<T extends object>(
1424
1436
  expected: readonly T[],
1425
1437
  actual: readonly T[],
@@ -2572,7 +2584,12 @@ export class GpuDrivenProduction {
2572
2584
  ?.inspect()
2573
2585
  .map(
2574
2586
  (entry) =>
2575
- `${shadowViewIdentityKey(entry.identity)}:${entry.cache}:${entry.generation}:${entry.sourceRevision}:${entry.resourceGeneration}`,
2587
+ // Cache decisions and logical update generations are frame-local
2588
+ // producer state. They must not feed the compiled graph key: a
2589
+ // graph replacement may intentionally invalidate a shadow view
2590
+ // once without creating a graph-rebuild feedback loop. Resource
2591
+ // and source-plan generations remain real graph dependencies.
2592
+ `${shadowViewIdentityKey(entry.identity)}:${entry.sourceRevision}:${entry.resourceGeneration}`,
2576
2593
  )
2577
2594
  .join('|') ?? '';
2578
2595
  const production = this;
@@ -2689,10 +2706,10 @@ export class GpuDrivenProduction {
2689
2706
  frameNumber,
2690
2707
  ),
2691
2708
  }),
2692
- projectShadow: (graph, identity) => {
2709
+ projectShadow: (graph, identity, forceCompute = false) => {
2693
2710
  if (!shadowViewPool.isActive(identity) || shadowViewPool.submission(identity) === undefined)
2694
2711
  return ok(undefined);
2695
- return shadowViewPool.project(graph, identity);
2712
+ return shadowViewPool.project(graph, identity, forceCompute);
2696
2713
  },
2697
2714
  updateShadowViews: (views) =>
2698
2715
  shadowFiltered.plan.batches.length > 0
@@ -3828,7 +3845,7 @@ export class GpuDrivenProduction {
3828
3845
  cacheKey !== undefined &&
3829
3846
  cached !== undefined &&
3830
3847
  cached.cacheKey === cacheKey &&
3831
- cached.validated === validated &&
3848
+ sameValidatedSourceSequence(cached.validatedSources, validated) &&
3832
3849
  cached.gpuOwnedDrawKeys === gpuOwnedDrawKeys &&
3833
3850
  cached.worldKeys === worldKeys
3834
3851
  ) {
@@ -3914,7 +3931,7 @@ export class GpuDrivenProduction {
3914
3931
  if (cacheKey !== undefined) {
3915
3932
  this.cpuValidationTelemetryCache = {
3916
3933
  cacheKey,
3917
- validated,
3934
+ validatedSources: validated.map((row) => row.source),
3918
3935
  gpuOwnedDrawKeys,
3919
3936
  worldKeys,
3920
3937
  gpuOwnedDrawCount: this.gpuOwnedDrawItems,
@@ -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
  }
@@ -982,6 +982,7 @@ export class GpuDrivenView {
982
982
  labelPrefix = 'gpu-driven',
983
983
  includeCompute = true,
984
984
  surfaceSubmissionObservation?: () => SurfaceSubmissionCandidate | undefined,
985
+ executeIf?: (frame: FrameCtx) => boolean,
985
986
  ): Result<GpuDrivenViewGraphResources, RenderGraphError> {
986
987
  const buffers = this.buffers;
987
988
  const bindGroup = this.bindGroup;
@@ -1099,6 +1100,7 @@ export class GpuDrivenView {
1099
1100
  { resource: view.value, usage: 'uniform-read' },
1100
1101
  { resource: counters.value, usage: 'storage-read-write' },
1101
1102
  ],
1103
+ ...(executeIf === undefined ? {} : { executeIf }),
1102
1104
  encode: ({ pass }) => {
1103
1105
  pass.setPipeline(this.resetPipeline);
1104
1106
  pass.setBindGroup(0, bindGroup);
@@ -1116,6 +1118,7 @@ export class GpuDrivenView {
1116
1118
  { resource: counters.value, usage: 'storage-read-write' },
1117
1119
  { resource: visible.value, usage: 'storage-read-write' },
1118
1120
  ],
1121
+ ...(executeIf === undefined ? {} : { executeIf }),
1119
1122
  encode: ({ pass }) => {
1120
1123
  pass.setPipeline(this.cullPipeline);
1121
1124
  pass.setBindGroup(0, bindGroup);
@@ -1130,6 +1133,7 @@ export class GpuDrivenView {
1130
1133
  { resource: counters.value, usage: 'storage-read' },
1131
1134
  { resource: indirect.value, usage: 'storage-read-write' },
1132
1135
  ],
1136
+ ...(executeIf === undefined ? {} : { executeIf }),
1133
1137
  encode: ({ pass }) => {
1134
1138
  pass.setPipeline(this.finalizePipeline);
1135
1139
  pass.setBindGroup(0, bindGroup);
@@ -1148,6 +1152,7 @@ export class GpuDrivenView {
1148
1152
  { resource: indirect.value, usage: 'copy-src' },
1149
1153
  { resource: lodReadback.value, usage: 'copy-dst' },
1150
1154
  ],
1155
+ ...(executeIf === undefined ? {} : { executeIf }),
1151
1156
  encode: ({ encoder, resources }) => {
1152
1157
  // A cached graph may run while an observer is awaiting mapAsync on the
1153
1158
  // previous copy. WebGPU forbids submitting a copy into a
@@ -1031,12 +1031,13 @@ export async function runIblPrecompute(
1031
1031
  pass.setVertexBuffer(0, opts.cubeVertexBuffer);
1032
1032
  pass.draw(6, 1, face * 6, 0);
1033
1033
  pass.end();
1034
+
1035
+ // Bound software-GPU work just as for irradiance; preserve every sample.
1036
+ const faceStage = await submitStage(`prefilter-mip-${mip}-face-${face}`, true);
1037
+ if (!faceStage.ok) return err(faceStage.error);
1034
1038
  }
1035
1039
  }
1036
1040
 
1037
- const prefilterStage = await submitStage('prefilter', true);
1038
- if (!prefilterStage.ok) return err(prefilterStage.error);
1039
-
1040
1041
  // (d) brdf-lut: fullscreen triangle.
1041
1042
  {
1042
1043
  const pass: RhiRenderPassEncoder = encoder.beginRenderPass({
@@ -158,7 +158,10 @@ export function buildPbrMaterialUserRegionEntries(
158
158
  let entries = derived.bglEntries.map(
159
159
  (entry): GPUBindGroupLayoutEntry => ({
160
160
  binding: entry.binding,
161
- visibility: entry.visibility,
161
+ visibility:
162
+ entry.texture !== undefined || entry.sampler !== undefined
163
+ ? GPU_SHADER_STAGE_VERTEX | GPU_SHADER_STAGE_FRAGMENT
164
+ : entry.visibility,
162
165
  ...(entry.buffer === undefined ? {} : { buffer: entry.buffer }),
163
166
  ...(entry.sampler === undefined ? {} : { sampler: entry.sampler }),
164
167
  ...(entry.texture === undefined ? {} : { texture: entry.texture }),
@@ -120,3 +120,54 @@ it('publishes LOD GUID dependencies and rejects a missing lower-detail asset bef
120
120
  candidate.discard();
121
121
  publisher.dispose();
122
122
  });
123
+
124
+ it('keeps two sealed publications and their feedback independent of later source mutation', async () => {
125
+ const world = new World();
126
+ const assets = new AssetRegistry(new ShaderRegistry({ manifestUrl: undefined }));
127
+ let value = 1;
128
+ const consumed: unknown[] = [];
129
+ const feature: RenderFeature<unknown> = {
130
+ identity: 'sealed-events',
131
+ extract: () => ok({ value }),
132
+ plan: () => ok({ resources: [], passes: [] }),
133
+ onSourceFrameSubmitted: (data) => consumed.push(data),
134
+ };
135
+ const publisher = createRenderPublisher(
136
+ world,
137
+ assets,
138
+ { source: 'sealed', epoch: 1 },
139
+ undefined,
140
+ [feature],
141
+ );
142
+ const seal = () => {
143
+ const candidate = publisher.prepare(value).unwrap();
144
+ const packet = structuredClone(candidate.packet, {
145
+ transfer: renderPublicationTransfers(candidate.packet),
146
+ });
147
+ candidate.accept();
148
+ return packet;
149
+ };
150
+ try {
151
+ const first = seal();
152
+ value = 2;
153
+ const second = seal();
154
+ await Promise.resolve();
155
+ value = 99;
156
+ expect(second.features[0]?.data).toEqual({ value: 2 });
157
+ expect(publisher.prepare(3).ok).toBe(false);
158
+ expect(publisher.recycle(2, renderPublicationTransfers(second)).ok).toBe(false);
159
+ publisher.acknowledgeFeatures(1, [{ identity: feature.identity, feedback: null }]).unwrap();
160
+ publisher.recycle(1, renderPublicationTransfers(first)).unwrap();
161
+ expect(
162
+ publisher.acknowledgeFeatures(1, [{ identity: feature.identity, feedback: null }]).ok,
163
+ ).toBe(false);
164
+ publisher.acknowledgeFeatures(2, [{ identity: feature.identity, feedback: null }]).unwrap();
165
+ expect(consumed).toEqual([{ value: 1 }, { value: 2 }]);
166
+ publisher.recycle(2, renderPublicationTransfers(second)).unwrap();
167
+ const third = seal();
168
+ expect(third.base).toBe(2);
169
+ expect(third.features[0]?.data).toEqual({ value: 99 });
170
+ } finally {
171
+ publisher.dispose();
172
+ }
173
+ });