@forgeax/engine-render 0.1.24 → 0.1.26

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 (167) hide show
  1. package/README.md +118 -1
  2. package/dist/assembly/factory.d.ts +1 -1
  3. package/dist/assembly/factory.d.ts.map +1 -1
  4. package/dist/assembly/material/assembly.d.ts +4 -17
  5. package/dist/assembly/material/assembly.d.ts.map +1 -1
  6. package/dist/assembly/material/standard-layer-projection.d.ts.map +1 -1
  7. package/dist/assembly/material/surface-projection.d.ts +24 -0
  8. package/dist/assembly/material/surface-projection.d.ts.map +1 -0
  9. package/dist/assembly/material-shader-policy.d.ts +20 -0
  10. package/dist/assembly/material-shader-policy.d.ts.map +1 -1
  11. package/dist/assembly/webgpu-ready.d.ts.map +1 -1
  12. package/dist/assembly/webgpu-renderer.d.ts +1 -2
  13. package/dist/assembly/webgpu-renderer.d.ts.map +1 -1
  14. package/dist/assembly/webgpu-vertex-layouts.d.ts +89 -0
  15. package/dist/assembly/webgpu-vertex-layouts.d.ts.map +1 -0
  16. package/dist/assets/asset-decoders.d.ts.map +1 -1
  17. package/dist/authoring.mjs +1 -1
  18. package/dist/{chunk-Q752SHI5.mjs → chunk-ADHHVYLW.mjs} +3 -3
  19. package/dist/chunk-ADHHVYLW.mjs.map +1 -0
  20. package/dist/{chunk-TFH5I7JJ.mjs → chunk-E34VL5VI.mjs} +3 -3
  21. package/dist/{chunk-TFH5I7JJ.mjs.map → chunk-E34VL5VI.mjs.map} +1 -1
  22. package/dist/{chunk-YKB5DQV7.mjs → chunk-GF523LHF.mjs} +89 -21
  23. package/dist/chunk-GF523LHF.mjs.map +1 -0
  24. package/dist/{chunk-KZFTK6DO.mjs → chunk-HKXTW355.mjs} +7 -4
  25. package/dist/chunk-HKXTW355.mjs.map +1 -0
  26. package/dist/{chunk-UQYS7FRM.mjs → chunk-KM2NOX2I.mjs} +4 -4
  27. package/dist/{chunk-UQYS7FRM.mjs.map → chunk-KM2NOX2I.mjs.map} +1 -1
  28. package/dist/{chunk-YRUYGA4A.mjs → chunk-TMIASV2N.mjs} +944 -577
  29. package/dist/chunk-TMIASV2N.mjs.map +1 -0
  30. package/dist/{chunk-Z3YYH2MB.mjs → chunk-YUQXSEHG.mjs} +160 -73
  31. package/dist/chunk-YUQXSEHG.mjs.map +1 -0
  32. package/dist/{chunk-RR5L4SRS.mjs → chunk-ZZ4YQ474.mjs} +2 -2
  33. package/dist/{chunk-RR5L4SRS.mjs.map → chunk-ZZ4YQ474.mjs.map} +1 -1
  34. package/dist/components/instances.d.ts +13 -31
  35. package/dist/components/instances.d.ts.map +1 -1
  36. package/dist/construct-renderer.mjs +1012 -149
  37. package/dist/construct-renderer.mjs.map +1 -1
  38. package/dist/extract/contracts.d.ts +6 -6
  39. package/dist/extract/contracts.d.ts.map +1 -1
  40. package/dist/gpu-driven/production-raster.d.ts +6 -1
  41. package/dist/gpu-driven/production-raster.d.ts.map +1 -1
  42. package/dist/gpu-scene.d.ts +4 -0
  43. package/dist/gpu-scene.d.ts.map +1 -1
  44. package/dist/index.d.ts +1 -0
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.mjs +27 -9
  47. package/dist/index.mjs.map +1 -1
  48. package/dist/instance-buffer-cache.d.ts +7 -1
  49. package/dist/instance-buffer-cache.d.ts.map +1 -1
  50. package/dist/instances.d.ts +101 -0
  51. package/dist/instances.d.ts.map +1 -0
  52. package/dist/internal.mjs +5 -5
  53. package/dist/materials.d.ts +63 -2
  54. package/dist/materials.d.ts.map +1 -1
  55. package/dist/pipeline-builder.d.ts.map +1 -1
  56. package/dist/pipeline-spec-types.d.ts +2 -0
  57. package/dist/pipeline-spec-types.d.ts.map +1 -1
  58. package/dist/pipeline-spec.d.ts.map +1 -1
  59. package/dist/record/frame-snapshot.d.ts +13 -2
  60. package/dist/record/frame-snapshot.d.ts.map +1 -1
  61. package/dist/record/frame.d.ts.map +1 -1
  62. package/dist/record/main-pass-geometry.d.ts +23 -4
  63. package/dist/record/main-pass-geometry.d.ts.map +1 -1
  64. package/dist/record/main-pass-material.d.ts +20 -2
  65. package/dist/record/main-pass-material.d.ts.map +1 -1
  66. package/dist/record/main-pass-sprite-draws.d.ts +3 -3
  67. package/dist/record/main-pass-sprite-draws.d.ts.map +1 -1
  68. package/dist/record/main-pass.d.ts.map +1 -1
  69. package/dist/record/material-uniforms.d.ts +4 -0
  70. package/dist/record/material-uniforms.d.ts.map +1 -0
  71. package/dist/record/render-context.d.ts +4 -3
  72. package/dist/record/render-context.d.ts.map +1 -1
  73. package/dist/record/shadow-pass.d.ts +10 -1
  74. package/dist/record/shadow-pass.d.ts.map +1 -1
  75. package/dist/recovery/render-system-candidate.d.ts +1 -1
  76. package/dist/recovery/render-system-candidate.d.ts.map +1 -1
  77. package/dist/render-contract.d.ts +4 -0
  78. package/dist/render-contract.d.ts.map +1 -1
  79. package/dist/render-system-extract-tail.d.ts +6 -0
  80. package/dist/render-system-extract-tail.d.ts.map +1 -1
  81. package/dist/render-system-extract.d.ts +44 -9
  82. package/dist/render-system-extract.d.ts.map +1 -1
  83. package/dist/render-system.d.ts +4 -1
  84. package/dist/render-system.d.ts.map +1 -1
  85. package/dist/scene/render-scene-types.d.ts +2 -0
  86. package/dist/scene/render-scene-types.d.ts.map +1 -1
  87. package/dist/scene/render-scene.d.ts +12 -1
  88. package/dist/scene/render-scene.d.ts.map +1 -1
  89. package/dist/systems/skin-palette-allocator.d.ts +6 -0
  90. package/dist/systems/skin-palette-allocator.d.ts.map +1 -1
  91. package/dist/temporal/index.mjs +5 -5
  92. package/package.json +20 -20
  93. package/src/__tests__/fixtures/ai-weapon-spirit/README.md +20 -0
  94. package/src/__tests__/fixtures/ai-weapon-spirit/low-poly-toon.wgsl +178 -0
  95. package/src/__tests__/fixtures/ai-weapon-spirit/material.json +97 -0
  96. package/src/__tests__/fixtures/ai-weapon-spirit/painterly-surface.wgsl +55 -0
  97. package/src/__tests__/gpu-driven-view-gpu-evidence.ts +3 -3
  98. package/src/__tests__/gpu-driven-view.browser.test.ts +11 -3
  99. package/src/__tests__/gpu-scene.unit.test.ts +38 -2
  100. package/src/__tests__/instances-store.unit.test.ts +46 -0
  101. package/src/__tests__/instances-world-ownership.integration.test.ts +62 -0
  102. package/src/__tests__/material-contract-inventory.unit.test.ts +48 -13
  103. package/src/__tests__/{material-static-dynamic-route.unit.test.ts → material-layer-projection.unit.test.ts} +30 -47
  104. package/src/__tests__/material-pass-policy.unit.test.ts +69 -0
  105. package/src/__tests__/material-program-dispatch.unit.test.ts +48 -0
  106. package/src/__tests__/material-snapshot-mutation.integration.test.ts +69 -0
  107. package/src/__tests__/material-surface-authoring.test-d.ts +62 -0
  108. package/src/__tests__/materials-transmission-validation.unit.test.ts +24 -0
  109. package/src/__tests__/mesh-buffer-usage-surface.unit.test.ts +0 -4
  110. package/src/__tests__/renderer-factory-material-contract.unit.test.ts +44 -2
  111. package/src/__tests__/skin-motion-regression.unit.test.ts +33 -0
  112. package/src/__tests__/skinned-shadow-caster.test.ts +30 -2
  113. package/src/__tests__/surface-adjacent-regressions.integration.test.ts +60 -0
  114. package/src/__tests__/surface-standard-pipeline.browser.test.ts +20 -0
  115. package/src/__tests__/surface-standard-pipeline.dawn.test.ts +20 -0
  116. package/src/__tests__/surface-standard-pipeline.fixture.ts +132 -0
  117. package/src/__tests__/surface-variant-provenance.integration.test.ts +33 -0
  118. package/src/__tests__/volumetric-fog-budget-recovery.integration.test.ts +1 -1
  119. package/src/assembly/factory.ts +2 -3
  120. package/src/assembly/material/__tests__/standard-layer-plan-projection.integration.test.ts +18 -0
  121. package/src/assembly/material/__tests__/surface-pass-projection.integration.test.ts +62 -0
  122. package/src/assembly/material/assembly.ts +8 -40
  123. package/src/assembly/material/standard-layer-projection.ts +3 -1
  124. package/src/assembly/material/surface-projection.ts +91 -0
  125. package/src/assembly/material-shader-policy.ts +88 -10
  126. package/src/assembly/recovery/renderer-recover.ts +1 -1
  127. package/src/assembly/webgpu-ready.ts +3 -16
  128. package/src/assembly/webgpu-renderer.ts +106 -166
  129. package/src/assembly/webgpu-vertex-layouts.ts +132 -0
  130. package/src/assets/asset-decoders.ts +27 -4
  131. package/src/components/instances.ts +14 -197
  132. package/src/extract/contracts.ts +6 -6
  133. package/src/gpu-driven/production-raster.ts +34 -1
  134. package/src/gpu-scene.ts +84 -29
  135. package/src/index.ts +1 -0
  136. package/src/instance-buffer-cache.ts +14 -1
  137. package/src/instances.ts +288 -0
  138. package/src/materials.ts +109 -14
  139. package/src/pipeline-builder.ts +14 -10
  140. package/src/pipeline-spec-types.ts +2 -0
  141. package/src/pipeline-spec.ts +13 -17
  142. package/src/record/frame-snapshot.ts +19 -2
  143. package/src/record/frame.ts +38 -3
  144. package/src/record/main-pass-geometry.ts +837 -337
  145. package/src/record/main-pass-material.ts +87 -2
  146. package/src/record/main-pass-sprite-draws.ts +66 -169
  147. package/src/record/main-pass.ts +24 -246
  148. package/src/record/material-uniforms.ts +186 -0
  149. package/src/record/render-context.ts +8 -0
  150. package/src/record/shadow-pass.ts +120 -115
  151. package/src/recovery/render-system-candidate.ts +46 -5
  152. package/src/render-contract.ts +4 -0
  153. package/src/render-system-extract-tail.ts +153 -57
  154. package/src/render-system-extract.ts +110 -73
  155. package/src/render-system.ts +59 -5
  156. package/src/scene/render-scene-types.ts +2 -0
  157. package/src/scene/render-scene.ts +579 -36
  158. package/src/systems/skin-palette-allocator.ts +129 -7
  159. package/src/transmission/__tests__/surface-compatibility.integration.test.ts +32 -0
  160. package/dist/assembly/material/pipeline-projection.d.ts +0 -30
  161. package/dist/assembly/material/pipeline-projection.d.ts.map +0 -1
  162. package/dist/chunk-KZFTK6DO.mjs.map +0 -1
  163. package/dist/chunk-Q752SHI5.mjs.map +0 -1
  164. package/dist/chunk-YKB5DQV7.mjs.map +0 -1
  165. package/dist/chunk-YRUYGA4A.mjs.map +0 -1
  166. package/dist/chunk-Z3YYH2MB.mjs.map +0 -1
  167. package/src/assembly/material/pipeline-projection.ts +0 -63
package/src/gpu-scene.ts CHANGED
@@ -225,6 +225,10 @@ export class GpuScene {
225
225
  drawTemplate: [],
226
226
  material: [],
227
227
  };
228
+ /** Transform rows whose previous value advances only after submit. */
229
+ private readonly pendingTemporalTransforms = new Set<number>();
230
+ /** Previous rows whose post-submit upload must be retried before the next draw. */
231
+ private readonly pendingTemporalUploads = new Set<number>();
228
232
 
229
233
  private constructor(
230
234
  private readonly device: RhiDevice,
@@ -273,31 +277,47 @@ export class GpuScene {
273
277
  }
274
278
 
275
279
  sync(delta: RenderSceneApplyResult): Result<GpuSceneSyncResult, RhiError> {
280
+ for (const transformIndex of this.pendingTemporalUploads) {
281
+ this.writesByTable.transform.push(transformIndex);
282
+ }
283
+ const pendingWrites = TABLE_NAMES.some((name) => this.writesByTable[name].length > 0);
276
284
  if (
277
285
  delta.createdSlots.length === 0 &&
278
286
  delta.updatedSlots.length === 0 &&
279
287
  delta.recreatedSlots.length === 0 &&
280
- delta.removedSlots.length === 0
288
+ delta.removedSlots.length === 0 &&
289
+ !pendingWrites
281
290
  ) {
282
291
  this.noChangeFrames += 1;
283
292
  return ok({ ranges: 0, bytes: 0, grew: false, cleared: 0 });
284
293
  }
285
294
  const writesByTable = this.writesByTable;
286
- for (const name of TABLE_NAMES) writesByTable[name].length = 0;
287
- if (!this.identityUploaded) writesByTable.transform.push(0);
295
+ if (!this.identityUploaded && !writesByTable.transform.includes(0)) {
296
+ writesByTable.transform.push(0);
297
+ }
288
298
  let highestChangedSlot = -1;
289
- for (const records of [delta.createdSlots, delta.recreatedSlots]) {
299
+ const contentUpdatedSlots = delta.contentUpdatedSlots ?? [];
300
+ const contentUpdatedSlotIds = new Set(contentUpdatedSlots.map((record) => record.slot));
301
+ const allocationResets = new Set<number>();
302
+ // RenderScene may recycle a released CPU slot for a different identity in
303
+ // the same delta. Clear the old GPU allocation before ensuring the new
304
+ // record; otherwise the later removal pass clears the newly allocated
305
+ // record and the next transform-only update sees no allocation.
306
+ for (const record of delta.removedSlots) {
307
+ if (record.slot > highestChangedSlot) highestChangedSlot = record.slot;
308
+ this.clearSlot(record.slot, writesByTable);
309
+ }
310
+ for (const records of [delta.createdSlots, contentUpdatedSlots, delta.recreatedSlots]) {
290
311
  for (const record of records) {
312
+ const previous = this.allocations[record.slot];
291
313
  this.ensureAllocation(record, writesByTable);
314
+ if (previous !== this.allocations[record.slot]) allocationResets.add(record.slot);
292
315
  if (record.slot > highestChangedSlot) highestChangedSlot = record.slot;
293
316
  }
294
317
  }
295
318
  for (const record of delta.updatedSlots) {
296
319
  if (record.slot > highestChangedSlot) highestChangedSlot = record.slot;
297
320
  }
298
- for (const record of delta.removedSlots) {
299
- if (record.slot > highestChangedSlot) highestChangedSlot = record.slot;
300
- }
301
321
  const requiredCapacity = Math.max(
302
322
  highestChangedSlot + 1,
303
323
  this.instances.requiredCapacity(),
@@ -312,11 +332,18 @@ export class GpuScene {
312
332
  }
313
333
 
314
334
  for (const record of delta.createdSlots) this.writeSlot(record, true, writesByTable);
315
- for (const record of delta.updatedSlots) this.writeRootTransform(record, false, writesByTable);
335
+ for (const record of contentUpdatedSlots) {
336
+ this.writeSlot(record, allocationResets.has(record.slot), writesByTable);
337
+ }
338
+ for (const record of delta.updatedSlots) {
339
+ if (contentUpdatedSlotIds.has(record.slot)) continue;
340
+ this.writeRootTransform(record, false, writesByTable);
341
+ }
316
342
  for (const record of delta.recreatedSlots) this.writeSlot(record, true, writesByTable);
317
- for (const record of delta.removedSlots) this.clearSlot(record.slot, writesByTable);
318
343
  const uploaded = this.uploadRows(writesByTable);
319
344
  if (!uploaded.ok) return uploaded;
345
+ this.pendingTemporalUploads.clear();
346
+ for (const name of TABLE_NAMES) writesByTable[name].length = 0;
320
347
  this.identityUploaded = true;
321
348
  this.clearedSlots += delta.removedSlots.length;
322
349
  return ok({
@@ -329,27 +356,47 @@ export class GpuScene {
329
356
 
330
357
  /** Publish current transforms as previous only after a successful submit. */
331
358
  commitTemporalFrame(enabled = true): Result<void, RhiError> {
332
- if (!enabled) return ok(undefined);
333
- const nextTransform = this.tableBytes.transform.slice(0);
334
- const next = new Uint8Array(nextTransform);
335
- const current = this.transformBytes;
359
+ if (!enabled) {
360
+ this.pendingTemporalTransforms.clear();
361
+ this.pendingTemporalUploads.clear();
362
+ return ok(undefined);
363
+ }
364
+ if (this.pendingTemporalTransforms.size === 0) return ok(undefined);
336
365
  const currentOffset = offset(TRANSFORM, 'currentWorld');
337
366
  const previousOffset = offset(TRANSFORM, 'previousWorld');
338
- for (let index = 0; index < this.capacity; index += 1) {
339
- next.set(
340
- current.subarray(
367
+ const ranges = coalesceSlots([...this.pendingTemporalTransforms]);
368
+ for (const range of ranges) {
369
+ for (let index = range.start; index < range.end; index += 1) {
370
+ this.pendingTemporalUploads.add(index);
371
+ }
372
+ }
373
+ for (const range of ranges) {
374
+ for (let index = range.start; index < range.end; index += 1) {
375
+ this.transformBytes.copyWithin(
376
+ index * TRANSFORM.stride + previousOffset,
341
377
  index * TRANSFORM.stride + currentOffset,
342
378
  index * TRANSFORM.stride + currentOffset + 64,
343
- ),
344
- index * TRANSFORM.stride + previousOffset,
379
+ );
380
+ }
381
+ }
382
+ for (const range of ranges) {
383
+ const firstOffset = range.start * TRANSFORM.stride;
384
+ const lastOffset = range.end * TRANSFORM.stride;
385
+ const uploaded = this.device.queue.writeBuffer(
386
+ this.buffers.transform,
387
+ firstOffset,
388
+ this.transformBytes.subarray(firstOffset, lastOffset),
345
389
  );
390
+ if (!uploaded.ok) return uploaded;
346
391
  }
347
- const uploaded = this.device.queue.writeBuffer(this.buffers.transform, 0, next);
348
- if (!uploaded.ok) return uploaded;
349
- this.tableBytes.transform = nextTransform;
350
- this.refreshTransformViews();
351
- this.uploadRanges += 1;
352
- this.uploadBytes += next.byteLength;
392
+ this.pendingTemporalTransforms.clear();
393
+ this.pendingTemporalUploads.clear();
394
+ const bytes = ranges.reduce(
395
+ (total, range) => total + (range.end - range.start) * TRANSFORM.stride,
396
+ 0,
397
+ );
398
+ this.uploadRanges += ranges.length;
399
+ this.uploadBytes += bytes;
353
400
  return ok(undefined);
354
401
  }
355
402
 
@@ -359,6 +406,9 @@ export class GpuScene {
359
406
  this.transforms.reset(1);
360
407
  this.draws.reset();
361
408
  this.materials.reset();
409
+ this.pendingTemporalTransforms.clear();
410
+ this.pendingTemporalUploads.clear();
411
+ for (const name of TABLE_NAMES) this.writesByTable[name].length = 0;
362
412
  const writesByTable: Record<GpuSceneTableName, number[]> = {
363
413
  primitive: [],
364
414
  instance: [],
@@ -520,7 +570,8 @@ export class GpuScene {
520
570
  const localCurrentOffset = localOffset + offset(TRANSFORM, 'currentWorld');
521
571
  const localPreviousOffset = localOffset + offset(TRANSFORM, 'previousWorld');
522
572
  writeMat4(transform, localCurrentOffset, localWorld);
523
- writeMat4(transform, localPreviousOffset, localWorld);
573
+ if (resetPrevious) writeMat4(transform, localPreviousOffset, localWorld);
574
+ else this.pendingTemporalTransforms.add(transformIndex);
524
575
  writes.transform.push(transformIndex);
525
576
  }
526
577
  writes.instance.push(instanceIndex);
@@ -631,14 +682,16 @@ export class GpuScene {
631
682
  const allocation = this.allocations[record.slot];
632
683
  if (allocation === undefined) throw new RangeError('GPU Scene allocation unavailable');
633
684
  const transformOffset = allocation.transformStart * TRANSFORM.stride;
634
- const currentOffset = transformOffset + offset(TRANSFORM, 'currentWorld');
635
685
  const previousOffset = transformOffset + offset(TRANSFORM, 'previousWorld');
636
686
  if (resetPrevious) {
637
687
  writeMat4(this.transformView, previousOffset, record.snapshot.transform.world);
638
- } else {
639
- this.transformBytes.copyWithin(previousOffset, currentOffset, currentOffset + 64);
640
688
  }
641
- writeMat4(this.transformView, currentOffset, record.snapshot.transform.world);
689
+ writeMat4(
690
+ this.transformView,
691
+ transformOffset + offset(TRANSFORM, 'currentWorld'),
692
+ record.snapshot.transform.world,
693
+ );
694
+ if (!resetPrevious) this.pendingTemporalTransforms.add(allocation.transformStart);
642
695
  writes.transform.push(allocation.transformStart);
643
696
  }
644
697
 
@@ -743,6 +796,8 @@ export class GpuScene {
743
796
  TRANSFORM.stride,
744
797
  ).fill(0);
745
798
  writes.transform.push(transformIndex);
799
+ this.pendingTemporalTransforms.delete(transformIndex);
800
+ this.pendingTemporalUploads.delete(transformIndex);
746
801
  }
747
802
  for (let ordinal = 0; ordinal < allocation.drawCount; ordinal += 1) {
748
803
  const drawIndex = allocation.drawStart + ordinal;
package/src/index.ts CHANGED
@@ -196,6 +196,7 @@ export type {
196
196
  ReflectionProbeSelectionInspection,
197
197
  TransmissionInspection,
198
198
  } from './inspection-types';
199
+ export type { InstanceCollectionInspection } from './instances';
199
200
  export {
200
201
  type MaterialColorInput3,
201
202
  type MaterialColorInput4,
@@ -23,12 +23,16 @@ import type { GpuBuffer } from './gpu-resource';
23
23
  * and routes dispose through the RHI shim's destroy bookkeeping SSOT. The
24
24
  * two `uploaded*` fields are the cache invalidation fingerprint -- when the
25
25
  * archetype version bumps or the byte length changes, the record stage
26
- * allocates a fresh GpuBuffer and replaces the entry.
26
+ * allocates a fresh GpuBuffer and replaces the entry. Explicit collection
27
+ * entries are keyed by renderer-owned collection id at the outer map; the
28
+ * legacy entity key remains only for sprite/fold snapshots.
27
29
  */
28
30
  export interface InstanceBufferCacheEntry {
29
31
  readonly buffer: GpuBuffer;
30
32
  readonly uploadedByteLength: number;
31
33
  readonly uploadedArchVersion: number;
34
+ /** Renderer-owned collection revision uploaded into this buffer. */
35
+ readonly uploadedRevision?: number;
32
36
  }
33
37
 
34
38
  function destroyInstanceBufferEntries(
@@ -85,6 +89,15 @@ export function disposeInstanceBuffers(
85
89
  map.clear();
86
90
  }
87
91
 
92
+ /** Destroy the renderer-owned buffers used by internal large-instance chunks. */
93
+ export function disposeInstanceBufferChunks(
94
+ map: Map<string, InstanceBufferCacheEntry>,
95
+ errorRegistry?: InstanceBufferCacheErrorSink,
96
+ ): void {
97
+ destroyInstanceBufferEntries(map.values(), errorRegistry);
98
+ map.clear();
99
+ }
100
+
88
101
  /** Destroy transient uniform-fallback chunks allocated for the previous frame. */
89
102
  export function disposeTransientInstanceBuffers(
90
103
  entries: InstanceBufferCacheEntry[],
@@ -0,0 +1,288 @@
1
+ import type { World } from '@forgeax/engine-ecs';
2
+ import type { RhiErrorCode } from '@forgeax/engine-rhi';
3
+
4
+ const MATRIX_STRIDE = 16;
5
+
6
+ export type InstanceCollectionId = number & {
7
+ readonly __forgeaxInstanceCollectionId: unique symbol;
8
+ };
9
+
10
+ export interface InstanceCollectionInfo {
11
+ readonly collectionId: InstanceCollectionId;
12
+ readonly count: number;
13
+ readonly revision: number;
14
+ }
15
+
16
+ export interface InstanceUploadRange {
17
+ start: number;
18
+ end: number;
19
+ }
20
+
21
+ export interface InstanceCollectionSnapshot extends InstanceCollectionInfo {
22
+ readonly transforms: Float32Array;
23
+ }
24
+
25
+ /** The renderer-owned submission lane selected for one collection. */
26
+ export type InstanceSubmissionLane =
27
+ | 'unresident'
28
+ | 'direct-storage'
29
+ | 'chunked-storage'
30
+ | 'direct-uniform'
31
+ | 'chunked-uniform'
32
+ | 'unavailable';
33
+
34
+ /** Backend identity carried by collection residency and failure facts. */
35
+ export type InstanceBackendKind = 'webgpu' | 'wgpu-native' | 'wgpu-webgl2' | 'null' | 'unknown';
36
+
37
+ /** Closed record-stage failure vocabulary for collection residency. */
38
+ export type InstanceCollectionFailureCode = RhiErrorCode | 'queue-write-buffer-failed';
39
+
40
+ /** Typed producer facts retained when a collection cannot be admitted. */
41
+ export interface InstanceCollectionFailureFacts {
42
+ readonly requestedBytes: number;
43
+ readonly supportedBytes: number | undefined;
44
+ readonly backend: InstanceBackendKind;
45
+ readonly owner: 'renderer.instances';
46
+ readonly cause: string;
47
+ readonly recovery: string;
48
+ }
49
+
50
+ /** Detached, bounded collection evidence exposed by Renderer.inspect(). */
51
+ export interface InstanceCollectionInspection extends InstanceCollectionInfo {
52
+ readonly residentGeneration: number | undefined;
53
+ readonly lane: InstanceSubmissionLane;
54
+ readonly uploadRanges: readonly InstanceUploadRange[];
55
+ readonly uploadedBytes: number;
56
+ readonly requestedBytes: number;
57
+ readonly supportedBytes: number | undefined;
58
+ readonly backend: InstanceBackendKind;
59
+ readonly owner: 'renderer.instances';
60
+ readonly error:
61
+ | {
62
+ readonly code: InstanceCollectionFailureCode;
63
+ readonly expected: string;
64
+ readonly hint: string;
65
+ readonly detail: InstanceCollectionFailureFacts;
66
+ }
67
+ | undefined;
68
+ }
69
+
70
+ export interface InstanceResidency {
71
+ readonly frameNumber: number;
72
+ readonly residentGeneration: number | undefined;
73
+ readonly lane: InstanceSubmissionLane;
74
+ readonly uploadRanges: readonly InstanceUploadRange[];
75
+ readonly uploadedBytes: number;
76
+ readonly requestedBytes: number;
77
+ readonly supportedBytes: number | undefined;
78
+ readonly backend: InstanceBackendKind;
79
+ readonly error: InstanceCollectionInspection['error'];
80
+ }
81
+
82
+ function appendRange(ranges: InstanceUploadRange[], start: number, end: number): void {
83
+ if (start >= end) return;
84
+ // Updates are allowed to arrive in any order. Insert at the first range
85
+ // whose end reaches the new interval, then consume every range that starts
86
+ // before the merged end. This preserves the complete union for both
87
+ // adjacent and overlapping writes (for example [10, 12) then [8, 11)).
88
+ let index = 0;
89
+ while (index < ranges.length) {
90
+ const existing = ranges[index];
91
+ if (existing === undefined || existing.end >= start) break;
92
+ index += 1;
93
+ }
94
+ while (index < ranges.length) {
95
+ const existing = ranges[index];
96
+ if (existing === undefined || existing.start > end) break;
97
+ start = Math.min(start, existing.start);
98
+ end = Math.max(end, existing.end);
99
+ ranges.splice(index, 1);
100
+ }
101
+ ranges.splice(index, 0, { start, end });
102
+ }
103
+
104
+ interface CollectionRecord extends InstanceCollectionInfo {
105
+ readonly world: World;
106
+ readonly entity: number;
107
+ readonly transforms: Float32Array;
108
+ }
109
+
110
+ /** Rebuildable per-renderer projection. World remains the authoring authority. */
111
+ export class InstanceProjectionStore {
112
+ private nextId = 1;
113
+ private identities = new WeakMap<World, Map<number, InstanceCollectionId>>();
114
+ private readonly records = new Map<InstanceCollectionId, CollectionRecord>();
115
+
116
+ project(world: World, entity: number, transforms: ArrayLike<number>): InstanceCollectionSnapshot {
117
+ let entities = this.identities.get(world);
118
+ if (entities === undefined) {
119
+ entities = new Map();
120
+ this.identities.set(world, entities);
121
+ }
122
+ let collectionId = entities.get(entity);
123
+ if (collectionId === undefined) {
124
+ collectionId = this.nextId++ as InstanceCollectionId;
125
+ entities.set(entity, collectionId);
126
+ }
127
+ const previous = this.records.get(collectionId);
128
+ const unchanged =
129
+ previous !== undefined &&
130
+ previous.transforms.length === transforms.length &&
131
+ previous.transforms.every((value, index) => Object.is(value, transforms[index]));
132
+ const record: CollectionRecord = unchanged
133
+ ? previous
134
+ : {
135
+ collectionId,
136
+ world,
137
+ entity,
138
+ transforms: new Float32Array(transforms),
139
+ count: transforms.length / MATRIX_STRIDE,
140
+ revision: (previous?.revision ?? 0) + 1,
141
+ };
142
+ this.records.set(collectionId, record);
143
+ // Every consumer gets a complete snapshot. Reading never acknowledges an
144
+ // upload; each GPU resident records its own successfully uploaded revision.
145
+ return { ...this.info(record), transforms: record.transforms };
146
+ }
147
+
148
+ retain(ids: ReadonlySet<InstanceCollectionId>): void {
149
+ for (const [id, record] of this.records) {
150
+ if (ids.has(id)) continue;
151
+ this.identities.get(record.world)?.delete(record.entity);
152
+ this.records.delete(id);
153
+ }
154
+ }
155
+
156
+ /** Release one producer-owned collection when its render identity is removed. */
157
+ release(world: World, entity: number): void {
158
+ const entities = this.identities.get(world);
159
+ const collectionId = entities?.get(entity);
160
+ if (collectionId === undefined) return;
161
+ entities?.delete(entity);
162
+ this.records.delete(collectionId);
163
+ }
164
+
165
+ dispose(): void {
166
+ this.records.clear();
167
+ this.identities = new WeakMap();
168
+ }
169
+
170
+ /**
171
+ * Return detached residency facts for every live collection.
172
+ *
173
+ * @internal Joins CPU projection identity with the active device residency;
174
+ * callers only receive immutable PODs and never GPU handles.
175
+ */
176
+ _inspections(
177
+ residency: ReadonlyMap<InstanceCollectionId, InstanceResidency> | undefined,
178
+ frameNumber: number,
179
+ ): readonly InstanceCollectionInspection[] {
180
+ return Object.freeze(
181
+ [...this.records.values()].map((record) =>
182
+ this.projectInspection(record, residency?.get(record.collectionId), frameNumber),
183
+ ),
184
+ );
185
+ }
186
+
187
+ private info(record: CollectionRecord): InstanceCollectionInfo {
188
+ return Object.freeze({
189
+ collectionId: record.collectionId,
190
+ count: record.count,
191
+ revision: record.revision,
192
+ });
193
+ }
194
+
195
+ private projectInspection(
196
+ record: CollectionRecord,
197
+ residency: InstanceResidency | undefined,
198
+ frameNumber: number,
199
+ ): InstanceCollectionInspection {
200
+ return Object.freeze({
201
+ ...this.info(record),
202
+ residentGeneration: residency?.residentGeneration,
203
+ lane: residency?.lane ?? 'unresident',
204
+ uploadRanges: Object.freeze(
205
+ residency?.frameNumber === frameNumber
206
+ ? residency.uploadRanges.map((range) => ({ ...range }))
207
+ : [],
208
+ ),
209
+ uploadedBytes: residency?.frameNumber === frameNumber ? residency.uploadedBytes : 0,
210
+ requestedBytes: residency?.requestedBytes ?? record.count * MATRIX_STRIDE * 4,
211
+ supportedBytes: residency?.supportedBytes,
212
+ backend: residency?.backend ?? 'unknown',
213
+ owner: 'renderer.instances',
214
+ error: residency?.error,
215
+ });
216
+ }
217
+ }
218
+
219
+ /** @internal Renderer record owner reports the accepted collection lane. */
220
+ export function recordInstanceResidency(
221
+ residency: Map<InstanceCollectionId, InstanceResidency>,
222
+ input: {
223
+ readonly collectionId: InstanceCollectionId;
224
+ readonly frameNumber: number;
225
+ readonly residentGeneration: number;
226
+ readonly lane: Exclude<InstanceSubmissionLane, 'unresident' | 'unavailable'>;
227
+ readonly uploadRanges: readonly InstanceUploadRange[];
228
+ readonly uploadedBytes: number;
229
+ readonly requestedBytes: number;
230
+ readonly supportedBytes: number | undefined;
231
+ readonly backend: InstanceBackendKind;
232
+ },
233
+ ): void {
234
+ const previous = residency.get(input.collectionId);
235
+ const sameFrame =
236
+ previous !== undefined &&
237
+ previous.frameNumber === input.frameNumber &&
238
+ previous.error === undefined;
239
+ const uploadRanges = sameFrame
240
+ ? [...previous.uploadRanges].reduce(
241
+ (ranges, range) => {
242
+ appendRange(ranges, range.start, range.end);
243
+ return ranges;
244
+ },
245
+ input.uploadRanges.map((range) => ({ ...range })),
246
+ )
247
+ : input.uploadRanges.map((range) => ({ ...range }));
248
+ residency.set(input.collectionId, {
249
+ frameNumber: input.frameNumber,
250
+ residentGeneration: input.residentGeneration,
251
+ lane: input.lane,
252
+ uploadRanges,
253
+ uploadedBytes: sameFrame ? previous.uploadedBytes + input.uploadedBytes : input.uploadedBytes,
254
+ requestedBytes: input.requestedBytes,
255
+ supportedBytes: input.supportedBytes,
256
+ backend: input.backend,
257
+ error: undefined,
258
+ });
259
+ }
260
+
261
+ /** @internal Renderer record owner reports a typed admission failure. */
262
+ export function recordInstanceFailure(
263
+ residency: Map<InstanceCollectionId, InstanceResidency>,
264
+ input: {
265
+ readonly collectionId: InstanceCollectionId;
266
+ readonly code: InstanceCollectionFailureCode;
267
+ readonly expected: string;
268
+ readonly hint: string;
269
+ readonly facts: InstanceCollectionFailureFacts;
270
+ },
271
+ ): void {
272
+ residency.set(input.collectionId, {
273
+ frameNumber: -1,
274
+ residentGeneration: undefined,
275
+ lane: 'unavailable',
276
+ uploadRanges: [],
277
+ uploadedBytes: 0,
278
+ requestedBytes: input.facts.requestedBytes,
279
+ supportedBytes: input.facts.supportedBytes,
280
+ backend: input.facts.backend,
281
+ error: {
282
+ code: input.code,
283
+ expected: input.expected,
284
+ hint: input.hint,
285
+ detail: { ...input.facts },
286
+ },
287
+ });
288
+ }