@forgeax/engine-render 0.1.26 → 0.1.28

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 (83) hide show
  1. package/README.md +215 -0
  2. package/dist/assembly/dynamic-geometry-host.d.ts +31 -0
  3. package/dist/assembly/dynamic-geometry-host.d.ts.map +1 -0
  4. package/dist/assembly/host-contract.d.ts +11 -0
  5. package/dist/assembly/host-contract.d.ts.map +1 -1
  6. package/dist/assembly/renderer-facade.d.ts.map +1 -1
  7. package/dist/assembly/webgpu-renderer.d.ts.map +1 -1
  8. package/dist/authoring.mjs +2 -2
  9. package/dist/{chunk-FJ6P52EE.mjs → chunk-ILQ5MEZF.mjs} +3 -17
  10. package/dist/chunk-ILQ5MEZF.mjs.map +1 -0
  11. package/dist/{chunk-HKXTW355.mjs → chunk-IQMKJLNU.mjs} +3 -3
  12. package/dist/{chunk-HKXTW355.mjs.map → chunk-IQMKJLNU.mjs.map} +1 -1
  13. package/dist/{chunk-KM2NOX2I.mjs → chunk-J52NMQKR.mjs} +462 -6
  14. package/dist/chunk-J52NMQKR.mjs.map +1 -0
  15. package/dist/{chunk-ZZ4YQ474.mjs → chunk-J6KACNOH.mjs} +2 -2
  16. package/dist/{chunk-ZZ4YQ474.mjs.map → chunk-J6KACNOH.mjs.map} +1 -1
  17. package/dist/{chunk-ADHHVYLW.mjs → chunk-JAASCMXA.mjs} +3 -3
  18. package/dist/{chunk-ADHHVYLW.mjs.map → chunk-JAASCMXA.mjs.map} +1 -1
  19. package/dist/{chunk-TMIASV2N.mjs → chunk-MBU4ZVE7.mjs} +20 -12
  20. package/dist/chunk-MBU4ZVE7.mjs.map +1 -0
  21. package/dist/{chunk-E34VL5VI.mjs → chunk-MYJ5RPJO.mjs} +3 -3
  22. package/dist/{chunk-E34VL5VI.mjs.map → chunk-MYJ5RPJO.mjs.map} +1 -1
  23. package/dist/{chunk-JDWARUOI.mjs → chunk-RBDBZ5R7.mjs} +17 -3
  24. package/dist/chunk-RBDBZ5R7.mjs.map +1 -0
  25. package/dist/{chunk-YUQXSEHG.mjs → chunk-VSL23LZE.mjs} +26 -12
  26. package/dist/chunk-VSL23LZE.mjs.map +1 -0
  27. package/dist/construct-renderer.mjs +862 -106
  28. package/dist/construct-renderer.mjs.map +1 -1
  29. package/dist/device/gpu-residency.d.ts +10 -5
  30. package/dist/device/gpu-residency.d.ts.map +1 -1
  31. package/dist/device/mesh-residency-lifetime.d.ts +21 -0
  32. package/dist/device/mesh-residency-lifetime.d.ts.map +1 -0
  33. package/dist/dynamic-geometry.d.ts +125 -0
  34. package/dist/dynamic-geometry.d.ts.map +1 -0
  35. package/dist/index.d.ts +3 -0
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.mjs +7 -6
  38. package/dist/index.mjs.map +1 -1
  39. package/dist/internal.mjs +7 -7
  40. package/dist/record/frame.d.ts +1 -1
  41. package/dist/record/frame.d.ts.map +1 -1
  42. package/dist/record/main-pass-geometry.d.ts.map +1 -1
  43. package/dist/record/render-context.d.ts +2 -0
  44. package/dist/record/render-context.d.ts.map +1 -1
  45. package/dist/record/typed-frame-graph.d.ts.map +1 -1
  46. package/dist/render-contract.d.ts +12 -0
  47. package/dist/render-contract.d.ts.map +1 -1
  48. package/dist/render-system-extract-tail.d.ts.map +1 -1
  49. package/dist/render-system-extract.d.ts +6 -0
  50. package/dist/render-system-extract.d.ts.map +1 -1
  51. package/dist/render-system.d.ts +7 -0
  52. package/dist/render-system.d.ts.map +1 -1
  53. package/dist/temporal/index.mjs +4 -4
  54. package/package.json +20 -20
  55. package/src/__tests__/dynamic-geometry-host.unit.test.ts +1123 -0
  56. package/src/__tests__/dynamic-geometry.unit.test.ts +233 -0
  57. package/src/__tests__/factory-contract.integration.test.ts +6 -14
  58. package/src/__tests__/mesh-submission-lifetime.unit.test.ts +53 -0
  59. package/src/__tests__/renderer-factory-material-contract.unit.test.ts +1 -1
  60. package/src/assembly/dynamic-geometry-host.ts +839 -0
  61. package/src/assembly/host-contract.ts +26 -0
  62. package/src/assembly/renderer-facade.ts +17 -0
  63. package/src/assembly/webgpu-renderer.ts +49 -2
  64. package/src/components/sprite-animation.ts +1 -1
  65. package/src/device/gpu-residency.ts +62 -62
  66. package/src/device/mesh-residency-lifetime.ts +73 -0
  67. package/src/dynamic-geometry.ts +736 -0
  68. package/src/index.ts +16 -0
  69. package/src/record/__tests__/standard-pbr-ubo-layout.unit.test.ts +68 -1
  70. package/src/record/frame.ts +2 -0
  71. package/src/record/main-pass-geometry.ts +13 -4
  72. package/src/record/main-pass-sprite-draws.ts +2 -0
  73. package/src/record/render-context.ts +2 -0
  74. package/src/record/typed-frame-graph.ts +2 -9
  75. package/src/render-contract.ts +24 -0
  76. package/src/render-system-extract-tail.ts +3 -2
  77. package/src/render-system-extract.ts +30 -1
  78. package/src/render-system.ts +42 -0
  79. package/dist/chunk-FJ6P52EE.mjs.map +0 -1
  80. package/dist/chunk-JDWARUOI.mjs.map +0 -1
  81. package/dist/chunk-KM2NOX2I.mjs.map +0 -1
  82. package/dist/chunk-TMIASV2N.mjs.map +0 -1
  83. package/dist/chunk-YUQXSEHG.mjs.map +0 -1
@@ -0,0 +1,1123 @@
1
+ import { FixedUpdate, World } from '@forgeax/engine-ecs';
2
+ import { rhi } from '@forgeax/engine-rhi-null';
3
+ import { Transform } from '@forgeax/engine-scene';
4
+ import type { Handle, MeshAsset } from '@forgeax/engine-types';
5
+ import { describe, expect, it, vi } from 'vitest';
6
+ import { createDynamicGeometryHost } from '../assembly/dynamic-geometry-host';
7
+ import { MeshFilter } from '../components/mesh-filter';
8
+ import { MeshRenderer } from '../components/mesh-renderer';
9
+ import { GpuResidencyCache } from '../device/gpu-residency';
10
+ import { MeshResidencyLifetime } from '../device/mesh-residency-lifetime';
11
+
12
+ function residencyLease(handle: number, invalidated: number[]) {
13
+ const lifetime = new MeshResidencyLifetime(() => invalidated.push(handle));
14
+ return lifetime.retain(() => lifetime.retire());
15
+ }
16
+
17
+ import { createDynamicGeometryLifecycle } from '../dynamic-geometry';
18
+
19
+ const mesh = (): MeshAsset => ({
20
+ kind: 'mesh',
21
+ vertices: new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]),
22
+ indices: new Uint16Array([0, 1, 2]),
23
+ attributes: { position: new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]) },
24
+ aabb: new Float32Array([0, 0, 0, 1, 1, 0]),
25
+ submeshes: [
26
+ { indexOffset: 0, indexCount: 3, vertexCount: 3, topology: 'triangle-list', materialSlot: 0 },
27
+ ],
28
+ materialSlots: [{ slotName: 'default', sourceKey: 'default' }],
29
+ });
30
+
31
+ describe('dynamic geometry ECS publication host', () => {
32
+ it.each([
33
+ 'supersession',
34
+ 'cancel',
35
+ 'duplicate-cancel',
36
+ 'detach',
37
+ ] as const)('keeps unpublished submitted allocations bounded through %s', async (mode) => {
38
+ const world = new World();
39
+ const device = (await (await rhi.requestAdapter()).unwrap().requestDevice()).unwrap();
40
+ const store = new GpuResidencyCache();
41
+ store.configureGpuDevice(
42
+ device,
43
+ undefined,
44
+ () => {
45
+ throw new Error('unused cubemap');
46
+ },
47
+ device.caps,
48
+ );
49
+ const lifecycle = createDynamicGeometryLifecycle();
50
+ const host = createDynamicGeometryHost({
51
+ lifecycle,
52
+ attachedWorlds: new Set([world]),
53
+ getGpuStore: () => store,
54
+ currentGeneration: () => 1,
55
+ isConsumedByRenderFrame: () => false,
56
+ });
57
+ const original = world.allocSharedRef('MeshAsset', mesh());
58
+ const entity = world
59
+ .spawn(
60
+ { component: Transform, data: {} },
61
+ { component: MeshFilter, data: { assetHandle: original } },
62
+ { component: MeshRenderer, data: { materials: [] } },
63
+ )
64
+ .unwrap();
65
+ let finish!: () => void;
66
+ const pending = new Promise<void>((resolve) => {
67
+ finish = resolve;
68
+ });
69
+ const handles: Handle<'MeshAsset', 'shared'>[] = [];
70
+ let latest: import('../dynamic-geometry').DynamicGeometryCandidate | undefined;
71
+ for (let revision = 1; revision <= 40; revision++) {
72
+ const source = mesh();
73
+ const handle = world.allocSharedRef('MeshAsset', source);
74
+ const prepared = host.prepareDynamicGeometry({
75
+ world,
76
+ entity,
77
+ mesh: source,
78
+ meshHandle: handle,
79
+ revision,
80
+ });
81
+ if (!prepared.ok) {
82
+ expect(revision).toBe(33);
83
+ expect(prepared.error.code).toBe('dynamic-geometry-budget-exceeded');
84
+ world.sharedRefs.release(handle).unwrap();
85
+ break;
86
+ }
87
+ latest = host.acceptDynamicGeometry(prepared.value, { world, fixedStep: 0 }).unwrap();
88
+ handles.push(handle);
89
+ store.trackMeshSubmission(pending);
90
+ world.sharedRefs.release(handle).unwrap();
91
+ if (mode === 'cancel' || mode === 'duplicate-cancel') {
92
+ host.cancelDynamicGeometry(latest).unwrap();
93
+ if (mode === 'duplicate-cancel') expect(host.cancelDynamicGeometry(latest).ok).toBe(false);
94
+ } else if (mode === 'detach') {
95
+ host.invalidateDynamicGeometryWorld(world);
96
+ host.invalidateDynamicGeometryWorld(world);
97
+ world.set(entity, MeshFilter, { assetHandle: original }).unwrap();
98
+ }
99
+ }
100
+ expect(handles).toHaveLength(32);
101
+ expect(lifecycle.inspect().meshBytes).toBeGreaterThan(0);
102
+ expect(handles.every((handle) => world.sharedRefs.resolve(handle).ok)).toBe(true);
103
+ if (mode === 'supersession' && latest !== undefined)
104
+ host.cancelDynamicGeometry(latest).unwrap();
105
+ finish();
106
+ await pending;
107
+ await vi.waitFor(() => expect(lifecycle.inspect().meshBytes).toBe(0));
108
+ store.destroyAll();
109
+ });
110
+
111
+ it('keeps the old ECS binding visible while staging and restores it on cancellation', () => {
112
+ const world = new World();
113
+ const oldMesh = mesh();
114
+ const oldHandle = world.allocSharedRef('MeshAsset', oldMesh);
115
+ const nextMesh = mesh();
116
+ nextMesh.vertices[0] = 4;
117
+ const nextHandle = world.allocSharedRef('MeshAsset', nextMesh);
118
+ const entity = world
119
+ .spawn(
120
+ { component: Transform, data: {} },
121
+ { component: MeshFilter, data: { assetHandle: oldHandle } },
122
+ { component: MeshRenderer, data: { materials: [] } },
123
+ )
124
+ .unwrap();
125
+ // Simulate an asset producer releasing its allocation while the ECS
126
+ // component remains the only visible binding. Candidate preparation must
127
+ // retain that old binding before the ECS swap so cancellation can restore
128
+ // a live handle instead of reporting success with a stale MeshFilter.
129
+ expect(world.sharedRefs.release(oldHandle).ok).toBe(true);
130
+ const invalidated: number[] = [];
131
+ const host = createDynamicGeometryHost({
132
+ lifecycle: createDynamicGeometryLifecycle(),
133
+ attachedWorlds: new Set([world]),
134
+ getGpuStore: () =>
135
+ ({
136
+ ensureResident: () => ({ ok: true as const, value: {} }),
137
+ retainMeshResidency: (handle: number) => residencyLease(handle, invalidated),
138
+ invalidateMesh: (handle: number) => invalidated.push(handle),
139
+ }) as unknown as GpuResidencyCache,
140
+ currentGeneration: () => 1,
141
+ isConsumedByRenderFrame: () => true,
142
+ });
143
+
144
+ const prepared = host.prepareDynamicGeometry({
145
+ world,
146
+ entity,
147
+ mesh: nextMesh,
148
+ meshHandle: nextHandle,
149
+ revision: 2,
150
+ });
151
+ expect(prepared.ok).toBe(true);
152
+ expect(world.sharedRefs.refcount(nextHandle)).toBe(2);
153
+ expect(world.sharedRefs.resolve(oldHandle).ok).toBe(true);
154
+ expect(world.get(entity, MeshFilter).unwrap().assetHandle).toBe(oldHandle);
155
+ if (!prepared.ok) return;
156
+
157
+ const accepted = host.acceptDynamicGeometry(prepared.value, { world, fixedStep: 0 });
158
+ expect(accepted.ok).toBe(true);
159
+ expect(world.sharedRefs.refcount(nextHandle)).toBe(3);
160
+ expect(world.get(entity, MeshFilter).unwrap().assetHandle).toBe(nextHandle);
161
+ if (!accepted.ok) return;
162
+
163
+ expect(host.cancelDynamicGeometry(accepted.value).ok).toBe(true);
164
+ expect(world.sharedRefs.refcount(nextHandle)).toBe(1);
165
+ expect(world.sharedRefs.resolve(oldHandle).ok).toBe(true);
166
+ expect(world.get(entity, MeshFilter).unwrap().assetHandle).toBe(oldHandle);
167
+ expect(invalidated).toEqual([nextHandle]);
168
+ });
169
+
170
+ it('refuses an external binding change instead of clobbering the live ECS state', () => {
171
+ const world = new World();
172
+ const oldHandle = world.allocSharedRef('MeshAsset', mesh());
173
+ const nextMesh = mesh();
174
+ const nextHandle = world.allocSharedRef('MeshAsset', nextMesh);
175
+ const externalHandle = world.allocSharedRef('MeshAsset', mesh());
176
+ const entity = world
177
+ .spawn(
178
+ { component: Transform, data: {} },
179
+ { component: MeshFilter, data: { assetHandle: oldHandle } },
180
+ { component: MeshRenderer, data: { materials: [] } },
181
+ )
182
+ .unwrap();
183
+ const invalidated: number[] = [];
184
+ const host = createDynamicGeometryHost({
185
+ lifecycle: createDynamicGeometryLifecycle(),
186
+ attachedWorlds: new Set([world]),
187
+ getGpuStore: () =>
188
+ ({
189
+ ensureResident: () => ({ ok: true as const, value: {} }),
190
+ retainMeshResidency: (handle: number) => residencyLease(handle, invalidated),
191
+ invalidateMesh: (handle: number) => invalidated.push(handle),
192
+ }) as unknown as GpuResidencyCache,
193
+ currentGeneration: () => 1,
194
+ isConsumedByRenderFrame: () => true,
195
+ });
196
+ const prepared = host.prepareDynamicGeometry({
197
+ world,
198
+ entity,
199
+ mesh: nextMesh,
200
+ meshHandle: nextHandle,
201
+ revision: 2,
202
+ });
203
+ expect(prepared.ok).toBe(true);
204
+ if (!prepared.ok) return;
205
+ world.set(entity, MeshFilter, { assetHandle: externalHandle }).unwrap();
206
+ const accepted = host.acceptDynamicGeometry(prepared.value, { world, fixedStep: 0 });
207
+ expect(accepted).toMatchObject({ ok: false, error: { code: 'dynamic-geometry-invalid' } });
208
+ expect(world.get(entity, MeshFilter).unwrap().assetHandle).toBe(externalHandle);
209
+ expect(host.cancelDynamicGeometry(prepared.value).ok).toBe(true);
210
+ expect(invalidated).toEqual([nextHandle]);
211
+ });
212
+
213
+ it('refuses cancellation after an accepted candidate is replaced by a foreign binding', () => {
214
+ const world = new World();
215
+ const oldHandle = world.allocSharedRef('MeshAsset', mesh());
216
+ const nextMesh = mesh();
217
+ nextMesh.vertices[0] = 9;
218
+ const nextHandle = world.allocSharedRef('MeshAsset', nextMesh);
219
+ const foreignHandle = world.allocSharedRef('MeshAsset', mesh());
220
+ const entity = world
221
+ .spawn(
222
+ { component: Transform, data: {} },
223
+ { component: MeshFilter, data: { assetHandle: oldHandle } },
224
+ { component: MeshRenderer, data: { materials: [] } },
225
+ )
226
+ .unwrap();
227
+ const invalidated: number[] = [];
228
+ const host = createDynamicGeometryHost({
229
+ lifecycle: createDynamicGeometryLifecycle(),
230
+ attachedWorlds: new Set([world]),
231
+ getGpuStore: () =>
232
+ ({
233
+ ensureResident: () => ({ ok: true as const, value: {} }),
234
+ retainMeshResidency: (handle: number) => residencyLease(handle, invalidated),
235
+ invalidateMesh: (handle: number) => invalidated.push(handle),
236
+ }) as unknown as GpuResidencyCache,
237
+ currentGeneration: () => 1,
238
+ isConsumedByRenderFrame: () => true,
239
+ });
240
+ const prepared = host.prepareDynamicGeometry({
241
+ world,
242
+ entity,
243
+ mesh: nextMesh,
244
+ meshHandle: nextHandle,
245
+ revision: 2,
246
+ });
247
+ expect(prepared.ok).toBe(true);
248
+ if (!prepared.ok) return;
249
+ const accepted = host.acceptDynamicGeometry(prepared.value, { world, fixedStep: 0 });
250
+ expect(accepted.ok).toBe(true);
251
+ if (!accepted.ok) return;
252
+
253
+ expect(world.set(entity, MeshFilter, { assetHandle: foreignHandle }).ok).toBe(true);
254
+ expect(host.cancelDynamicGeometry(accepted.value)).toMatchObject({
255
+ ok: false,
256
+ error: { code: 'dynamic-geometry-invalid' },
257
+ });
258
+ expect(world.get(entity, MeshFilter).unwrap().assetHandle).toBe(foreignHandle);
259
+ // The old binding remains retained while cancellation is refused, so the
260
+ // host can restore it after the foreign owner reconciles the entity.
261
+ expect(world.sharedRefs.resolve(oldHandle).ok).toBe(true);
262
+ expect(invalidated).toEqual([]);
263
+
264
+ expect(world.set(entity, MeshFilter, { assetHandle: nextHandle }).ok).toBe(true);
265
+ expect(host.cancelDynamicGeometry(accepted.value).ok).toBe(true);
266
+ expect(world.get(entity, MeshFilter).unwrap().assetHandle).toBe(oldHandle);
267
+ expect(invalidated).toEqual([nextHandle]);
268
+ });
269
+
270
+ it('rejects forged owner credentials before cancellation or retirement can touch ECS', () => {
271
+ const world = new World();
272
+ const oldHandle = world.allocSharedRef('MeshAsset', mesh());
273
+ const nextMesh = mesh();
274
+ nextMesh.vertices[0] = 8;
275
+ const nextHandle = world.allocSharedRef('MeshAsset', nextMesh);
276
+ const entity = world
277
+ .spawn(
278
+ { component: Transform, data: {} },
279
+ { component: MeshFilter, data: { assetHandle: oldHandle } },
280
+ { component: MeshRenderer, data: { materials: [] } },
281
+ )
282
+ .unwrap();
283
+ const invalidated: number[] = [];
284
+ const lifecycle = createDynamicGeometryLifecycle();
285
+ const host = createDynamicGeometryHost({
286
+ lifecycle,
287
+ attachedWorlds: new Set([world]),
288
+ getGpuStore: () =>
289
+ ({
290
+ ensureResident: () => ({ ok: true as const, value: {} }),
291
+ retainMeshResidency: (handle: number) => residencyLease(handle, invalidated),
292
+ invalidateMesh: (handle: number) => invalidated.push(handle),
293
+ }) as unknown as GpuResidencyCache,
294
+ currentGeneration: () => 1,
295
+ isConsumedByRenderFrame: () => true,
296
+ });
297
+ const prepared = host.prepareDynamicGeometry({
298
+ world,
299
+ entity,
300
+ mesh: nextMesh,
301
+ meshHandle: nextHandle,
302
+ revision: 1,
303
+ });
304
+ expect(prepared.ok).toBe(true);
305
+ if (!prepared.ok) return;
306
+ const accepted = host.acceptDynamicGeometry(prepared.value, { world, fixedStep: 0 });
307
+ expect(accepted.ok).toBe(true);
308
+ if (!accepted.ok) return;
309
+ const forged = Object.freeze({ ...accepted.value, owner: {} });
310
+
311
+ expect(host.cancelDynamicGeometry(forged)).toMatchObject({
312
+ ok: false,
313
+ error: { code: 'dynamic-geometry-receipt-mismatch' },
314
+ });
315
+ expect(host.retireDynamicGeometry(forged)).toMatchObject({
316
+ ok: false,
317
+ error: { code: 'dynamic-geometry-receipt-mismatch' },
318
+ });
319
+ expect(world.get(entity, MeshFilter).unwrap().assetHandle).toBe(nextHandle);
320
+ expect(host.dynamicGeometryReceipt(accepted.value)).toBeUndefined();
321
+ expect(lifecycle.inspect()).toMatchObject({ accepted: 1, published: 0 });
322
+ expect(host.cancelDynamicGeometry(accepted.value).ok).toBe(true);
323
+ expect(invalidated).toEqual([nextHandle]);
324
+ });
325
+
326
+ it('derives an omitted publication tick from the attached World FixedTime', () => {
327
+ const invalidated: number[] = [];
328
+ const world = new World();
329
+ expect(
330
+ world.addSystem(FixedUpdate, { name: 'advance-fixed-time', queries: [], fn: () => {} }).ok,
331
+ ).toBe(true);
332
+ expect(world.update(1 / 60).ok).toBe(true);
333
+ const oldHandle = world.allocSharedRef('MeshAsset', mesh());
334
+ const nextMesh = mesh();
335
+ nextMesh.vertices[0] = 7;
336
+ const nextHandle = world.allocSharedRef('MeshAsset', nextMesh);
337
+ const entity = world
338
+ .spawn(
339
+ { component: Transform, data: {} },
340
+ { component: MeshFilter, data: { assetHandle: oldHandle } },
341
+ { component: MeshRenderer, data: { materials: [] } },
342
+ )
343
+ .unwrap();
344
+ const host = createDynamicGeometryHost({
345
+ lifecycle: createDynamicGeometryLifecycle(),
346
+ attachedWorlds: new Set([world]),
347
+ getGpuStore: () =>
348
+ ({
349
+ ensureResident: () => ({ ok: true as const, value: {} }),
350
+ retainMeshResidency: (handle: number) => residencyLease(handle, invalidated),
351
+ }) as unknown as GpuResidencyCache,
352
+ currentGeneration: () => 1,
353
+ isConsumedByRenderFrame: () => true,
354
+ });
355
+ const prepared = host.prepareDynamicGeometry({
356
+ world,
357
+ entity,
358
+ mesh: nextMesh,
359
+ meshHandle: nextHandle,
360
+ revision: 1,
361
+ });
362
+ expect(prepared.ok).toBe(true);
363
+ if (!prepared.ok) return;
364
+ const accepted = host.acceptDynamicGeometry(prepared.value, { world, fixedStep: 1 });
365
+ expect(accepted.ok).toBe(true);
366
+ if (!accepted.ok) return;
367
+
368
+ const receipts = host.publishDynamicGeometry({ frameId: 1, deviceGeneration: 1 }, [world]);
369
+ expect(receipts).toHaveLength(1);
370
+ expect(receipts[0]).toMatchObject({ fixedStep: 1, publicationFixedStep: 1 });
371
+ });
372
+
373
+ it('keeps real RhiNull residency behind a pending receipt across topology replacement', async () => {
374
+ const adapter = (await rhi.requestAdapter()).unwrap();
375
+ const device = (await adapter.requestDevice()).unwrap();
376
+ const store = new GpuResidencyCache();
377
+ store.configureGpuDevice(
378
+ device,
379
+ undefined,
380
+ () => {
381
+ throw new Error('cubemap registration is not part of this test');
382
+ },
383
+ device.caps,
384
+ );
385
+ const invalidated: number[] = [];
386
+ const invalidateMesh = store.invalidateMesh.bind(store);
387
+ vi.spyOn(store, 'invalidateMesh').mockImplementation((handle, worldId = 0) => {
388
+ invalidated.push(handle);
389
+ invalidateMesh(handle, worldId);
390
+ });
391
+ const world = new World();
392
+ const oldHandle = world.allocSharedRef('MeshAsset', mesh());
393
+ const firstMesh = mesh();
394
+ firstMesh.vertices[0] = 2;
395
+ const firstHandle = world.allocSharedRef('MeshAsset', firstMesh);
396
+ const entity = world
397
+ .spawn(
398
+ { component: Transform, data: {} },
399
+ { component: MeshFilter, data: { assetHandle: oldHandle } },
400
+ { component: MeshRenderer, data: { materials: [] } },
401
+ )
402
+ .unwrap();
403
+ const host = createDynamicGeometryHost({
404
+ lifecycle: createDynamicGeometryLifecycle(),
405
+ attachedWorlds: new Set([world]),
406
+ getGpuStore: () => store,
407
+ currentGeneration: () => 1,
408
+ isConsumedByRenderFrame: () => true,
409
+ });
410
+ const first = host.prepareDynamicGeometry({
411
+ world,
412
+ entity,
413
+ mesh: firstMesh,
414
+ meshHandle: firstHandle,
415
+ revision: 1,
416
+ topologyRevision: 1,
417
+ });
418
+ expect(first.ok).toBe(true);
419
+ if (!first.ok) return;
420
+ const firstAccepted = host.acceptDynamicGeometry(first.value, { world, fixedStep: 0 });
421
+ expect(firstAccepted.ok).toBe(true);
422
+ if (!firstAccepted.ok) return;
423
+ let completeFirst!: () => void;
424
+ const firstCompleted = new Promise<void>((resolve) => {
425
+ completeFirst = resolve;
426
+ });
427
+ expect(
428
+ host.publishDynamicGeometry(
429
+ { frameId: 1, deviceGeneration: 1, completed: firstCompleted },
430
+ [world],
431
+ 0,
432
+ ),
433
+ ).toHaveLength(1);
434
+ expect(store.getMeshGpuHandles(firstHandle, world)).toBeDefined();
435
+ let completeSecond!: () => void;
436
+ const secondCompleted = new Promise<void>((resolve) => {
437
+ completeSecond = resolve;
438
+ });
439
+ expect(
440
+ host.publishDynamicGeometry(
441
+ { frameId: 2, deviceGeneration: 1, completed: secondCompleted },
442
+ [world],
443
+ 0,
444
+ ),
445
+ ).toHaveLength(1);
446
+ expect(host.dynamicGeometryReceipt(firstAccepted.value)).toMatchObject({ frameId: 2 });
447
+ expect(host.retireDynamicGeometry(firstAccepted.value)).toMatchObject({
448
+ ok: false,
449
+ error: { code: 'dynamic-geometry-invalid' },
450
+ });
451
+ expect(host.dynamicGeometryReceipt(firstAccepted.value)).toBeDefined();
452
+
453
+ const secondMesh = mesh();
454
+ secondMesh.vertices[0] = 3;
455
+ const secondHandle = world.allocSharedRef('MeshAsset', secondMesh);
456
+ const second = host.prepareDynamicGeometry({
457
+ world,
458
+ entity,
459
+ mesh: secondMesh,
460
+ meshHandle: secondHandle,
461
+ revision: 2,
462
+ topologyRevision: 2,
463
+ });
464
+ expect(second.ok).toBe(true);
465
+ if (!second.ok) return;
466
+ const secondAccepted = host.acceptDynamicGeometry(second.value, { world, fixedStep: 0 });
467
+ expect(secondAccepted.ok).toBe(true);
468
+ if (!secondAccepted.ok) return;
469
+ expect(host.dynamicGeometryReceipt(firstAccepted.value)).toBeDefined();
470
+
471
+ expect(host.retireDynamicGeometry(firstAccepted.value).ok).toBe(true);
472
+ expect(invalidated).toEqual([]);
473
+ completeFirst();
474
+ await Promise.resolve();
475
+ expect(invalidated).toEqual([]);
476
+ completeSecond();
477
+ await Promise.resolve();
478
+ expect(invalidated).toEqual([firstHandle]);
479
+ expect(host.cancelDynamicGeometry(secondAccepted.value).ok).toBe(true);
480
+ });
481
+
482
+ it('tracks a live entity after another entity advances topology', async () => {
483
+ const adapter = (await rhi.requestAdapter()).unwrap();
484
+ const device = (await adapter.requestDevice()).unwrap();
485
+ const store = new GpuResidencyCache();
486
+ store.configureGpuDevice(
487
+ device,
488
+ undefined,
489
+ () => {
490
+ throw new Error('cubemap registration is not part of this test');
491
+ },
492
+ device.caps,
493
+ );
494
+ const invalidated: number[] = [];
495
+ const invalidateMesh = store.invalidateMesh.bind(store);
496
+ vi.spyOn(store, 'invalidateMesh').mockImplementation((handle, worldId = 0) => {
497
+ invalidated.push(handle);
498
+ invalidateMesh(handle, worldId);
499
+ });
500
+ const world = new World();
501
+ const oldA = world.allocSharedRef('MeshAsset', mesh());
502
+ const candidateAMesh = mesh();
503
+ candidateAMesh.vertices[0] = 10;
504
+ const candidateA = world.allocSharedRef('MeshAsset', candidateAMesh);
505
+ const oldB = world.allocSharedRef('MeshAsset', mesh());
506
+ const candidateBMesh = mesh();
507
+ candidateBMesh.vertices[0] = 11;
508
+ const candidateB = world.allocSharedRef('MeshAsset', candidateBMesh);
509
+ const entityA = world
510
+ .spawn(
511
+ { component: Transform, data: {} },
512
+ { component: MeshFilter, data: { assetHandle: oldA } },
513
+ { component: MeshRenderer, data: { materials: [] } },
514
+ )
515
+ .unwrap();
516
+ const entityB = world
517
+ .spawn(
518
+ { component: Transform, data: {} },
519
+ { component: MeshFilter, data: { assetHandle: oldB } },
520
+ { component: MeshRenderer, data: { materials: [] } },
521
+ )
522
+ .unwrap();
523
+ const host = createDynamicGeometryHost({
524
+ lifecycle: createDynamicGeometryLifecycle(),
525
+ attachedWorlds: new Set([world]),
526
+ getGpuStore: () => store,
527
+ currentGeneration: () => 1,
528
+ isConsumedByRenderFrame: () => true,
529
+ });
530
+ const preparedA = host.prepareDynamicGeometry({
531
+ world,
532
+ entity: entityA,
533
+ mesh: candidateAMesh,
534
+ meshHandle: candidateA,
535
+ revision: 1,
536
+ topologyRevision: 1,
537
+ });
538
+ expect(preparedA.ok).toBe(true);
539
+ if (!preparedA.ok) return;
540
+ const acceptedA = host.acceptDynamicGeometry(preparedA.value, { world, fixedStep: 0 });
541
+ expect(acceptedA.ok).toBe(true);
542
+ if (!acceptedA.ok) return;
543
+
544
+ let completeFirst!: () => void;
545
+ const firstCompleted = new Promise<void>((resolve) => {
546
+ completeFirst = resolve;
547
+ });
548
+ expect(
549
+ host.publishDynamicGeometry(
550
+ { frameId: 1, deviceGeneration: 1, completed: firstCompleted },
551
+ [world],
552
+ 0,
553
+ ),
554
+ ).toHaveLength(1);
555
+
556
+ const preparedB = host.prepareDynamicGeometry({
557
+ world,
558
+ entity: entityB,
559
+ mesh: candidateBMesh,
560
+ meshHandle: candidateB,
561
+ revision: 2,
562
+ topologyRevision: 2,
563
+ });
564
+ expect(preparedB.ok).toBe(true);
565
+ if (!preparedB.ok) return;
566
+ const acceptedB = host.acceptDynamicGeometry(preparedB.value, { world, fixedStep: 0 });
567
+ expect(acceptedB.ok).toBe(true);
568
+ if (!acceptedB.ok) return;
569
+
570
+ let completeSecond!: () => void;
571
+ const secondCompleted = new Promise<void>((resolve) => {
572
+ completeSecond = resolve;
573
+ });
574
+ const frameTwoReceipts = host.publishDynamicGeometry(
575
+ { frameId: 2, deviceGeneration: 1, completed: secondCompleted },
576
+ [world],
577
+ 0,
578
+ );
579
+ expect(frameTwoReceipts.map((receipt) => receipt.candidateId)).toEqual(
580
+ expect.arrayContaining([acceptedA.value.candidateId, acceptedB.value.candidateId]),
581
+ );
582
+ expect(host.dynamicGeometryReceipt(acceptedA.value)).toMatchObject({ frameId: 2 });
583
+ expect(store.getMeshGpuHandles(candidateA, world)).toBeDefined();
584
+
585
+ expect(world.set(entityA, MeshFilter, { assetHandle: oldA }).ok).toBe(true);
586
+ expect(host.retireDynamicGeometry(acceptedA.value).ok).toBe(true);
587
+ expect(invalidated).toEqual([]);
588
+ completeFirst();
589
+ await Promise.resolve();
590
+ expect(invalidated).toEqual([]);
591
+ completeSecond();
592
+ await Promise.resolve();
593
+ expect(invalidated).toContain(candidateA);
594
+
595
+ expect(world.set(entityB, MeshFilter, { assetHandle: oldB }).ok).toBe(true);
596
+ expect(host.retireDynamicGeometry(acceptedB.value).ok).toBe(true);
597
+ expect(invalidated).toContain(candidateB);
598
+ });
599
+
600
+ it('keeps shared residency for another candidate while a receipt is pending', async () => {
601
+ const adapter = (await rhi.requestAdapter()).unwrap();
602
+ const device = (await adapter.requestDevice()).unwrap();
603
+ const store = new GpuResidencyCache();
604
+ store.configureGpuDevice(
605
+ device,
606
+ undefined,
607
+ () => {
608
+ throw new Error('cubemap registration is not part of this test');
609
+ },
610
+ device.caps,
611
+ );
612
+ const invalidated: number[] = [];
613
+ const invalidateMesh = store.invalidateMesh.bind(store);
614
+ vi.spyOn(store, 'invalidateMesh').mockImplementation((handle, worldId = 0) => {
615
+ invalidated.push(handle);
616
+ invalidateMesh(handle, worldId);
617
+ });
618
+ const world = new World();
619
+ const oldA = world.allocSharedRef('MeshAsset', mesh());
620
+ const oldB = world.allocSharedRef('MeshAsset', mesh());
621
+ const sharedMesh = mesh();
622
+ sharedMesh.vertices[0] = 12;
623
+ const sharedHandle = world.allocSharedRef('MeshAsset', sharedMesh);
624
+ const entityA = world
625
+ .spawn(
626
+ { component: Transform, data: {} },
627
+ { component: MeshFilter, data: { assetHandle: oldA } },
628
+ { component: MeshRenderer, data: { materials: [] } },
629
+ )
630
+ .unwrap();
631
+ const entityB = world
632
+ .spawn(
633
+ { component: Transform, data: {} },
634
+ { component: MeshFilter, data: { assetHandle: oldB } },
635
+ { component: MeshRenderer, data: { materials: [] } },
636
+ )
637
+ .unwrap();
638
+ const host = createDynamicGeometryHost({
639
+ lifecycle: createDynamicGeometryLifecycle(),
640
+ attachedWorlds: new Set([world]),
641
+ getGpuStore: () => store,
642
+ currentGeneration: () => 1,
643
+ isConsumedByRenderFrame: () => true,
644
+ });
645
+ const preparedA = host.prepareDynamicGeometry({
646
+ world,
647
+ entity: entityA,
648
+ mesh: sharedMesh,
649
+ meshHandle: sharedHandle,
650
+ revision: 1,
651
+ });
652
+ expect(preparedA.ok).toBe(true);
653
+ if (!preparedA.ok) return;
654
+ const acceptedA = host.acceptDynamicGeometry(preparedA.value, { world, fixedStep: 0 });
655
+ expect(acceptedA.ok).toBe(true);
656
+ if (!acceptedA.ok) return;
657
+ let completeA!: () => void;
658
+ const pendingA = new Promise<void>((resolve) => {
659
+ completeA = resolve;
660
+ });
661
+ expect(
662
+ host.publishDynamicGeometry(
663
+ { frameId: 1, deviceGeneration: 1, completed: pendingA },
664
+ [world],
665
+ 0,
666
+ ),
667
+ ).toHaveLength(1);
668
+
669
+ const preparedB = host.prepareDynamicGeometry({
670
+ world,
671
+ entity: entityB,
672
+ mesh: sharedMesh,
673
+ meshHandle: sharedHandle,
674
+ revision: 2,
675
+ });
676
+ expect(preparedB.ok).toBe(true);
677
+ if (!preparedB.ok) return;
678
+ expect(world.set(entityA, MeshFilter, { assetHandle: oldA }).ok).toBe(true);
679
+ expect(host.retireDynamicGeometry(acceptedA.value).ok).toBe(true);
680
+
681
+ // B only owns a prepared candidate. Cancelling it releases B's lease but
682
+ // cannot evict H while A's submitted frame still owns the cache entry.
683
+ expect(host.cancelDynamicGeometry(preparedB.value).ok).toBe(true);
684
+ expect(store.getMeshGpuHandles(sharedHandle, world)).toBeDefined();
685
+ expect(invalidated).toEqual([]);
686
+
687
+ completeA();
688
+ await Promise.resolve();
689
+ expect(store.getMeshGpuHandles(sharedHandle, world)).toBeUndefined();
690
+ expect(invalidated).toEqual([sharedHandle]);
691
+ });
692
+
693
+ it('keeps pending retirements inside the count and byte budget', async () => {
694
+ const adapter = (await rhi.requestAdapter()).unwrap();
695
+ const device = (await adapter.requestDevice()).unwrap();
696
+ const store = new GpuResidencyCache();
697
+ store.configureGpuDevice(
698
+ device,
699
+ undefined,
700
+ () => {
701
+ throw new Error('cubemap registration is not part of this test');
702
+ },
703
+ device.caps,
704
+ );
705
+ const invalidated: number[] = [];
706
+ const invalidateMesh = store.invalidateMesh.bind(store);
707
+ vi.spyOn(store, 'invalidateMesh').mockImplementation((handle, worldId = 0) => {
708
+ invalidated.push(handle);
709
+ invalidateMesh(handle, worldId);
710
+ });
711
+ const lifecycle = createDynamicGeometryLifecycle();
712
+ const world = new World();
713
+ const liveHandle = world.allocSharedRef('MeshAsset', mesh());
714
+ const entity = world
715
+ .spawn(
716
+ { component: Transform, data: {} },
717
+ { component: MeshFilter, data: { assetHandle: liveHandle } },
718
+ { component: MeshRenderer, data: { materials: [] } },
719
+ )
720
+ .unwrap();
721
+ const host = createDynamicGeometryHost({
722
+ lifecycle,
723
+ attachedWorlds: new Set([world]),
724
+ getGpuStore: () => store,
725
+ currentGeneration: () => 1,
726
+ isConsumedByRenderFrame: () => true,
727
+ });
728
+ const completions: Array<() => void> = [];
729
+ const handles: Handle<'MeshAsset', 'shared'>[] = [];
730
+ let candidateBytes = 0;
731
+ for (let revision = 1; revision <= 32; revision += 1) {
732
+ const candidateMesh = mesh();
733
+ candidateMesh.vertices[0] = revision;
734
+ const handle = world.allocSharedRef('MeshAsset', candidateMesh);
735
+ const prepared = host.prepareDynamicGeometry({
736
+ world,
737
+ entity,
738
+ mesh: candidateMesh,
739
+ meshHandle: handle,
740
+ revision,
741
+ });
742
+ expect(prepared.ok).toBe(true);
743
+ if (!prepared.ok) return;
744
+ candidateBytes = prepared.value.meshBytes;
745
+ const accepted = host.acceptDynamicGeometry(prepared.value, { world, fixedStep: 0 });
746
+ expect(accepted.ok).toBe(true);
747
+ if (!accepted.ok) return;
748
+ let complete!: () => void;
749
+ const pending = new Promise<void>((resolve) => {
750
+ complete = resolve;
751
+ });
752
+ completions.push(complete);
753
+ expect(
754
+ host.publishDynamicGeometry(
755
+ { frameId: revision, deviceGeneration: 1, completed: pending },
756
+ [world],
757
+ 0,
758
+ ),
759
+ ).toHaveLength(1);
760
+ expect(world.set(entity, MeshFilter, { assetHandle: liveHandle }).ok).toBe(true);
761
+ expect(host.retireDynamicGeometry(accepted.value).ok).toBe(true);
762
+ handles.push(handle);
763
+ }
764
+
765
+ const blockedMesh = mesh();
766
+ const blockedHandle = world.allocSharedRef('MeshAsset', blockedMesh);
767
+ expect(
768
+ host.prepareDynamicGeometry({
769
+ world,
770
+ entity,
771
+ mesh: blockedMesh,
772
+ meshHandle: blockedHandle,
773
+ revision: 33,
774
+ }),
775
+ ).toMatchObject({
776
+ ok: false,
777
+ error: { code: 'dynamic-geometry-budget-exceeded' },
778
+ });
779
+ expect(world.sharedRefs.release(blockedHandle).ok).toBe(true);
780
+ expect(lifecycle.inspect()).toMatchObject({
781
+ prepared: 0,
782
+ accepted: 0,
783
+ published: 0,
784
+ retired: 32,
785
+ meshBytes: candidateBytes * 32,
786
+ });
787
+ expect(
788
+ handles.filter((handle) => store.getMeshGpuHandles(handle, world) !== undefined),
789
+ ).toHaveLength(32);
790
+
791
+ completions.forEach((complete) => {
792
+ complete();
793
+ });
794
+ await Promise.resolve();
795
+ await vi.waitFor(() => expect(lifecycle.inspect().meshBytes).toBe(0));
796
+ expect(invalidated).toHaveLength(32);
797
+ expect(handles.every((handle) => store.getMeshGpuHandles(handle, world) === undefined)).toBe(
798
+ true,
799
+ );
800
+ });
801
+
802
+ it('supersedes an older un-published candidate after a newer ECS swap', () => {
803
+ const world = new World();
804
+ const oldHandle = world.allocSharedRef('MeshAsset', mesh());
805
+ const firstMesh = mesh();
806
+ firstMesh.vertices[0] = 2;
807
+ const firstHandle = world.allocSharedRef('MeshAsset', firstMesh);
808
+ const secondMesh = mesh();
809
+ secondMesh.vertices[0] = 3;
810
+ const secondHandle = world.allocSharedRef('MeshAsset', secondMesh);
811
+ const entity = world
812
+ .spawn(
813
+ { component: Transform, data: {} },
814
+ { component: MeshFilter, data: { assetHandle: oldHandle } },
815
+ { component: MeshRenderer, data: { materials: [] } },
816
+ )
817
+ .unwrap();
818
+ const invalidated: number[] = [];
819
+ const host = createDynamicGeometryHost({
820
+ lifecycle: createDynamicGeometryLifecycle(),
821
+ attachedWorlds: new Set([world]),
822
+ getGpuStore: () =>
823
+ ({
824
+ ensureResident: () => ({ ok: true as const, value: {} }),
825
+ retainMeshResidency: (handle: number) => residencyLease(handle, invalidated),
826
+ invalidateMesh: (handle: number) => invalidated.push(handle),
827
+ }) as unknown as GpuResidencyCache,
828
+ currentGeneration: () => 1,
829
+ isConsumedByRenderFrame: () => true,
830
+ });
831
+ const first = host.prepareDynamicGeometry({
832
+ world,
833
+ entity,
834
+ mesh: firstMesh,
835
+ meshHandle: firstHandle,
836
+ revision: 1,
837
+ });
838
+ expect(first.ok).toBe(true);
839
+ if (!first.ok) return;
840
+ expect(host.acceptDynamicGeometry(first.value, { world, fixedStep: 0 }).ok).toBe(true);
841
+ const second = host.prepareDynamicGeometry({
842
+ world,
843
+ entity,
844
+ mesh: secondMesh,
845
+ meshHandle: secondHandle,
846
+ revision: 2,
847
+ });
848
+ expect(second.ok).toBe(true);
849
+ if (!second.ok) return;
850
+ const accepted = host.acceptDynamicGeometry(second.value, { world, fixedStep: 0 });
851
+ expect(accepted.ok).toBe(true);
852
+ expect(world.get(entity, MeshFilter).unwrap().assetHandle).toBe(secondHandle);
853
+ expect(host.cancelDynamicGeometry(first.value)).toMatchObject({
854
+ ok: false,
855
+ error: { code: 'dynamic-geometry-candidate-not-found' },
856
+ });
857
+ expect(invalidated).toContain(firstHandle);
858
+ });
859
+
860
+ it('preserves another entity candidate when a newer topology is accepted', () => {
861
+ const world = new World();
862
+ const firstLive = world.allocSharedRef('MeshAsset', mesh());
863
+ const firstCandidateMesh = mesh();
864
+ firstCandidateMesh.vertices[0] = 5;
865
+ const firstCandidateHandle = world.allocSharedRef('MeshAsset', firstCandidateMesh);
866
+ const secondLive = world.allocSharedRef('MeshAsset', mesh());
867
+ const secondCandidateMesh = mesh();
868
+ secondCandidateMesh.vertices[0] = 6;
869
+ const secondCandidateHandle = world.allocSharedRef('MeshAsset', secondCandidateMesh);
870
+ const firstEntity = world
871
+ .spawn(
872
+ { component: Transform, data: {} },
873
+ { component: MeshFilter, data: { assetHandle: firstLive } },
874
+ { component: MeshRenderer, data: { materials: [] } },
875
+ )
876
+ .unwrap();
877
+ const secondEntity = world
878
+ .spawn(
879
+ { component: Transform, data: {} },
880
+ { component: MeshFilter, data: { assetHandle: secondLive } },
881
+ { component: MeshRenderer, data: { materials: [] } },
882
+ )
883
+ .unwrap();
884
+ const invalidated: number[] = [];
885
+ const host = createDynamicGeometryHost({
886
+ lifecycle: createDynamicGeometryLifecycle(),
887
+ attachedWorlds: new Set([world]),
888
+ getGpuStore: () =>
889
+ ({
890
+ ensureResident: () => ({ ok: true as const, value: {} }),
891
+ retainMeshResidency: (handle: number) => residencyLease(handle, invalidated),
892
+ invalidateMesh: (handle: number) => invalidated.push(handle),
893
+ }) as unknown as GpuResidencyCache,
894
+ currentGeneration: () => 1,
895
+ isConsumedByRenderFrame: () => true,
896
+ });
897
+ const first = host.prepareDynamicGeometry({
898
+ world,
899
+ entity: firstEntity,
900
+ mesh: firstCandidateMesh,
901
+ meshHandle: firstCandidateHandle,
902
+ revision: 1,
903
+ topologyRevision: 1,
904
+ });
905
+ expect(first.ok).toBe(true);
906
+ if (!first.ok) return;
907
+ const second = host.prepareDynamicGeometry({
908
+ world,
909
+ entity: secondEntity,
910
+ mesh: secondCandidateMesh,
911
+ meshHandle: secondCandidateHandle,
912
+ revision: 2,
913
+ topologyRevision: 2,
914
+ });
915
+ expect(second.ok).toBe(true);
916
+ if (!second.ok) return;
917
+ expect(host.acceptDynamicGeometry(second.value, { world, fixedStep: 0 }).ok).toBe(true);
918
+ expect(invalidated).not.toContain(firstCandidateHandle);
919
+ expect(host.cancelDynamicGeometry(first.value).ok).toBe(true);
920
+ expect(invalidated).toContain(firstCandidateHandle);
921
+ expect(world.sharedRefs.refcount(firstCandidateHandle)).toBe(1);
922
+ });
923
+
924
+ it('requires a live MeshFilter/MeshRenderer binding and withholds receipts until draw consumption', async () => {
925
+ const world = new World();
926
+ const sourceMesh = mesh();
927
+ const meshHandle = world.allocSharedRef('MeshAsset', sourceMesh);
928
+ const entity = world
929
+ .spawn(
930
+ { component: Transform, data: {} },
931
+ { component: MeshFilter, data: { assetHandle: meshHandle } },
932
+ { component: MeshRenderer, data: { materials: [] } },
933
+ )
934
+ .unwrap();
935
+ const attachedWorlds = new Set([world]);
936
+ const lifecycle = createDynamicGeometryLifecycle();
937
+ const invalidatedMeshes: Array<{ readonly handle: number; readonly world: World }> = [];
938
+ let consumedByFrame = false;
939
+ const gpuStore = {
940
+ ensureResident: () => ({ ok: true as const, value: {} }),
941
+ retainMeshResidency: (handle: number) => {
942
+ const lifetime = new MeshResidencyLifetime(() => invalidatedMeshes.push({ handle, world }));
943
+ return lifetime.retain(() => lifetime.retire());
944
+ },
945
+ invalidateMesh: (handle: number, invalidationWorld: World) => {
946
+ invalidatedMeshes.push({ handle, world: invalidationWorld });
947
+ },
948
+ } as unknown as GpuResidencyCache;
949
+ const host = createDynamicGeometryHost({
950
+ lifecycle,
951
+ attachedWorlds,
952
+ getGpuStore: () => gpuStore,
953
+ currentGeneration: () => 1,
954
+ isConsumedByRenderFrame: () => consumedByFrame,
955
+ });
956
+
957
+ const prepared = host.prepareDynamicGeometry({
958
+ world,
959
+ entity,
960
+ mesh: sourceMesh,
961
+ meshHandle,
962
+ revision: 1,
963
+ topologyRevision: 1,
964
+ });
965
+ expect(prepared.ok).toBe(true);
966
+ if (!prepared.ok) return;
967
+ const accepted = host.acceptDynamicGeometry(prepared.value, { world, fixedStep: 0 });
968
+ expect(accepted.ok).toBe(true);
969
+ if (!accepted.ok) return;
970
+
971
+ const replacementHandle = world.allocSharedRef('MeshAsset', mesh());
972
+ expect(world.set(entity, MeshFilter, { assetHandle: replacementHandle }).ok).toBe(true);
973
+ expect(host.publishDynamicGeometry({ frameId: 1, deviceGeneration: 1 }, [world], 0)).toEqual(
974
+ [],
975
+ );
976
+ expect(host.dynamicGeometryReceipt(accepted.value)).toBeUndefined();
977
+
978
+ expect(world.set(entity, MeshFilter, { assetHandle: meshHandle }).ok).toBe(true);
979
+ consumedByFrame = true;
980
+ let complete!: () => void;
981
+ const completed = new Promise<void>((resolve) => {
982
+ complete = resolve;
983
+ });
984
+ const receipts = host.publishDynamicGeometry(
985
+ { frameId: 2, deviceGeneration: 1, completed },
986
+ [world],
987
+ 0,
988
+ );
989
+ expect(receipts).toHaveLength(1);
990
+ expect(host.dynamicGeometryReceipt(accepted.value)).toMatchObject({ frameId: 2 });
991
+
992
+ const topologyMesh = mesh();
993
+ topologyMesh.vertices[0] = 8;
994
+ const topologyHandle = world.allocSharedRef('MeshAsset', topologyMesh);
995
+ const topologyCandidate = host.prepareDynamicGeometry({
996
+ world,
997
+ entity,
998
+ mesh: topologyMesh,
999
+ meshHandle: topologyHandle,
1000
+ revision: 2,
1001
+ topologyRevision: 2,
1002
+ });
1003
+ expect(topologyCandidate.ok).toBe(true);
1004
+ if (!topologyCandidate.ok) return;
1005
+ expect(host.acceptDynamicGeometry(topologyCandidate.value, { world, fixedStep: 0 }).ok).toBe(
1006
+ true,
1007
+ );
1008
+ // Topology invalidates temporal history, but the receipt fence remains
1009
+ // queryable until explicit retirement and FrameReceipt completion.
1010
+ expect(host.dynamicGeometryReceipt(accepted.value)).toMatchObject({ frameId: 2 });
1011
+ expect(host.cancelDynamicGeometry(accepted.value)).toMatchObject({
1012
+ ok: false,
1013
+ error: { code: 'dynamic-geometry-candidate-state' },
1014
+ });
1015
+ expect(world.get(entity, MeshFilter).unwrap().assetHandle).toBe(topologyHandle);
1016
+ // Retiring while the accepted handle is still live is safe but cannot
1017
+ // evict that binding. The consumer first swaps its ECS binding away, then
1018
+ // the FrameReceipt completion fence permits GPU destruction.
1019
+ expect(world.set(entity, MeshFilter, { assetHandle: replacementHandle }).ok).toBe(true);
1020
+ expect(host.retireDynamicGeometry(accepted.value).ok).toBe(true);
1021
+ expect(invalidatedMeshes).toEqual([]);
1022
+ complete();
1023
+ await Promise.resolve();
1024
+ expect(invalidatedMeshes).toEqual([{ handle: meshHandle, world }]);
1025
+ expect(host.dynamicGeometryReceipt(accepted.value)).toBeUndefined();
1026
+ });
1027
+
1028
+ it('rejects a candidate without an actual ECS render entity before GPU admission', () => {
1029
+ const invalidated: number[] = [];
1030
+ const world = new World();
1031
+ const sourceMesh = mesh();
1032
+ const meshHandle = world.allocSharedRef('MeshAsset', sourceMesh);
1033
+ const host = createDynamicGeometryHost({
1034
+ lifecycle: createDynamicGeometryLifecycle(),
1035
+ attachedWorlds: new Set([world]),
1036
+ getGpuStore: () =>
1037
+ ({
1038
+ ensureResident: () => ({ ok: true as const, value: {} }),
1039
+ retainMeshResidency: (handle: number) => residencyLease(handle, invalidated),
1040
+ }) as unknown as GpuResidencyCache,
1041
+ currentGeneration: () => 1,
1042
+ isConsumedByRenderFrame: () => false,
1043
+ });
1044
+
1045
+ expect(
1046
+ host.prepareDynamicGeometry({
1047
+ world,
1048
+ mesh: sourceMesh,
1049
+ meshHandle,
1050
+ revision: 1,
1051
+ }),
1052
+ ).toMatchObject({ ok: false, error: { code: 'dynamic-geometry-invalid' } });
1053
+
1054
+ const entity = world
1055
+ .spawn(
1056
+ { component: Transform, data: {} },
1057
+ { component: MeshFilter, data: { assetHandle: meshHandle } },
1058
+ { component: MeshRenderer, data: { materials: [] } },
1059
+ )
1060
+ .unwrap();
1061
+ expect(
1062
+ host.prepareDynamicGeometry({ world, entity, mesh: sourceMesh, revision: 2 }),
1063
+ ).toMatchObject({ ok: false, error: { code: 'dynamic-geometry-gpu-not-ready' } });
1064
+ });
1065
+
1066
+ it('requires active PhysicsWorld admission, not an already committed publication', () => {
1067
+ const invalidated: number[] = [];
1068
+ const world = new World();
1069
+ const sourceMesh = mesh();
1070
+ const meshHandle = world.allocSharedRef('MeshAsset', sourceMesh);
1071
+ const entity = world
1072
+ .spawn(
1073
+ { component: Transform, data: {} },
1074
+ { component: MeshFilter, data: { assetHandle: meshHandle } },
1075
+ { component: MeshRenderer, data: { materials: [] } },
1076
+ )
1077
+ .unwrap();
1078
+ let publication: { readonly revision: number; readonly fixedStep: number } | undefined;
1079
+ let admission: typeof publication;
1080
+ world.insertResource('PhysicsWorld', {
1081
+ getDerivedPublication: () => publication,
1082
+ getDerivedAdmission: () => admission,
1083
+ });
1084
+ const host = createDynamicGeometryHost({
1085
+ lifecycle: createDynamicGeometryLifecycle(),
1086
+ attachedWorlds: new Set([world]),
1087
+ getGpuStore: () =>
1088
+ ({
1089
+ ensureResident: () => ({ ok: true as const, value: {} }),
1090
+ retainMeshResidency: (handle: number) => residencyLease(handle, invalidated),
1091
+ }) as unknown as GpuResidencyCache,
1092
+ currentGeneration: () => 1,
1093
+ isConsumedByRenderFrame: () => true,
1094
+ });
1095
+ const prepared = host.prepareDynamicGeometry({
1096
+ world,
1097
+ entity,
1098
+ physicsEntity: entity,
1099
+ mesh: sourceMesh,
1100
+ meshHandle,
1101
+ revision: 1,
1102
+ });
1103
+ expect(prepared.ok).toBe(true);
1104
+ if (!prepared.ok) return;
1105
+ expect(host.acceptDynamicGeometry(prepared.value, { world, fixedStep: 0 })).toMatchObject({
1106
+ ok: false,
1107
+ error: { code: 'dynamic-geometry-ordering-required' },
1108
+ });
1109
+ publication = { revision: 1, fixedStep: 0 };
1110
+ expect(host.acceptDynamicGeometry(prepared.value, { world, fixedStep: 0 })).toMatchObject({
1111
+ ok: false,
1112
+ error: { code: 'dynamic-geometry-ordering-required' },
1113
+ });
1114
+ admission = publication;
1115
+ const accepted = host.acceptDynamicGeometry(prepared.value, { world, fixedStep: 0 });
1116
+ admission = undefined;
1117
+ expect(accepted.ok).toBe(true);
1118
+ if (!accepted.ok) return;
1119
+ expect(
1120
+ host.publishDynamicGeometry({ frameId: 1, deviceGeneration: 1 }, [world], 0),
1121
+ ).toHaveLength(1);
1122
+ });
1123
+ });