@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
@@ -2,6 +2,13 @@ import type { AssetRegistry } from '@forgeax/engine-assets-runtime';
2
2
  import type { World } from '@forgeax/engine-ecs';
3
3
  import type { Result, RhiDevice, RhiError, ShaderModule } from '@forgeax/engine-rhi';
4
4
  import type { RenderPipelineAsset } from '@forgeax/engine-types';
5
+ import type {
6
+ DynamicGeometryCandidate,
7
+ DynamicGeometryError,
8
+ DynamicGeometryOrdering,
9
+ DynamicGeometryPrepareInput,
10
+ DynamicGeometryReceipt,
11
+ } from '../dynamic-geometry';
5
12
  import type { RecoverFailure } from '../errors/recover';
6
13
  import type { ObservationUnavailableError, RenderError } from '../errors/render';
7
14
  import type { RenderFeature, RenderFeatureDiagnostics } from '../features/types';
@@ -54,6 +61,25 @@ export type RendererHostEventListener = (event: RendererHostEvent) => void;
54
61
  */
55
62
  export interface RendererHostImplementation {
56
63
  attach(world: World): RenderResult<RenderWorldLease, RenderError>;
64
+ prepareDynamicGeometry(
65
+ input: DynamicGeometryPrepareInput,
66
+ ): Result<DynamicGeometryCandidate, DynamicGeometryError>;
67
+ acceptDynamicGeometry(
68
+ candidate: DynamicGeometryCandidate,
69
+ ordering: DynamicGeometryOrdering,
70
+ ): Result<DynamicGeometryCandidate, DynamicGeometryError>;
71
+ dynamicGeometryReceipt(candidate: DynamicGeometryCandidate): DynamicGeometryReceipt | undefined;
72
+ cancelDynamicGeometry(candidate: DynamicGeometryCandidate): Result<void, DynamicGeometryError>;
73
+ retireDynamicGeometry(candidate: DynamicGeometryCandidate): Result<void, DynamicGeometryError>;
74
+ publishDynamicGeometry(
75
+ frame: {
76
+ readonly frameId: number;
77
+ readonly deviceGeneration: number;
78
+ readonly completed?: Promise<unknown>;
79
+ },
80
+ worlds?: readonly object[],
81
+ fixedStep?: number,
82
+ ): readonly DynamicGeometryReceipt[];
57
83
  createRenderTarget(descriptor: RenderTargetDescriptor): RenderResult<RenderTarget, RenderError>;
58
84
  resizeRenderTarget(
59
85
  target: RenderTarget,
@@ -3,6 +3,11 @@
3
3
  // lease/receipt contract and owns the profile/error projection helpers.
4
4
 
5
5
  import { err, ok, type RhiError } from '@forgeax/engine-rhi';
6
+ import type {
7
+ DynamicGeometryCandidate,
8
+ DynamicGeometryOrdering,
9
+ DynamicGeometryPrepareInput,
10
+ } from '../dynamic-geometry';
6
11
  import { type RecoverFailure, RecoveryFailedError } from '../errors/recover';
7
12
  import {
8
13
  type RenderError,
@@ -94,6 +99,18 @@ export function exposeRenderer(renderer: RendererHostImplementation): Renderer {
94
99
  }),
95
100
  );
96
101
  },
102
+ prepareDynamicGeometry: (input: DynamicGeometryPrepareInput) =>
103
+ renderer.prepareDynamicGeometry(input),
104
+ acceptDynamicGeometry: (
105
+ candidate: DynamicGeometryCandidate,
106
+ ordering: DynamicGeometryOrdering,
107
+ ) => renderer.acceptDynamicGeometry(candidate, ordering),
108
+ dynamicGeometryReceipt: (candidate: DynamicGeometryCandidate) =>
109
+ renderer.dynamicGeometryReceipt(candidate),
110
+ cancelDynamicGeometry: (candidate: DynamicGeometryCandidate) =>
111
+ renderer.cancelDynamicGeometry(candidate),
112
+ retireDynamicGeometry: (candidate: DynamicGeometryCandidate) =>
113
+ renderer.retireDynamicGeometry(candidate),
97
114
  createRenderTarget: (descriptor) => renderer.createRenderTarget(descriptor),
98
115
  resizeRenderTarget: (target, descriptor) => renderer.resizeRenderTarget(target, descriptor),
99
116
  createRenderTargetTextureSource: (target, options) =>
@@ -57,6 +57,7 @@ import type {
57
57
  PrimitiveTopology,
58
58
  VertexAttributeMap,
59
59
  } from '@forgeax/engine-types';
60
+ import { createDynamicGeometryLifecycle } from '../dynamic-geometry';
60
61
 
61
62
  export type { MaterialShaderManifestEntry } from '@forgeax/engine-shader';
62
63
 
@@ -165,6 +166,7 @@ import { tilemapChunkExtractSystem } from '../tilemap-chunk-extract-system';
165
166
  import type { RhiBackendPack } from './backend-contract';
166
167
  import type { BundlerOptions } from './bundler-contract';
167
168
  import { deviceOptionsForAdapter } from './device-feature-admission';
169
+ import { createDynamicGeometryHost } from './dynamic-geometry-host';
168
170
  import type { RendererAssemblyImplementation } from './host-contract';
169
171
  import {
170
172
  allowsUnlitPreparedFallback,
@@ -1109,6 +1111,7 @@ async function makeWebGPURenderer(
1109
1111
  const publishRendererGeneration = (candidate: RendererGeneration): void => {
1110
1112
  publishGeneration(generationPublication, candidate, (value) => value.scope.isAlive());
1111
1113
  activeDeviceScope = candidate.scope;
1114
+ dynamicGeometry.invalidateGeneration(activeDeviceScope.generation);
1112
1115
  internals.generationState.current = activeDeviceScope.generation;
1113
1116
  pipelineState = candidate.pipeline;
1114
1117
  const bindings = candidate.producerBindings;
@@ -2023,6 +2026,7 @@ async function makeWebGPURenderer(
2023
2026
  const fragmentEntry =
2024
2027
  authoredFragmentEntry ??
2025
2028
  (isHdr &&
2029
+ passKind === 'forward' &&
2026
2030
  (materialShaderId === 'forgeax::sprite' || materialShaderId === 'forgeax::sprite-lit')
2027
2031
  ? 'fs_main_hdr'
2028
2032
  : undefined);
@@ -2805,6 +2809,20 @@ async function makeWebGPURenderer(
2805
2809
  const attachedWorlds = new Set<World>();
2806
2810
  const attachedLeases = new Map<RenderReadLease, World>();
2807
2811
  const leasesByWorld = new Map<World, RenderReadLease>();
2812
+ const dynamicGeometry = createDynamicGeometryLifecycle();
2813
+ const dynamicGeometryHost = createDynamicGeometryHost({
2814
+ lifecycle: dynamicGeometry,
2815
+ attachedWorlds,
2816
+ getGpuStore: () => gpuStore,
2817
+ currentGeneration: () => activeDeviceScope.generation,
2818
+ onTopologyChanged: () => renderSystem.invalidateGeometryHistory(),
2819
+ isConsumedByRenderFrame: (candidate) =>
2820
+ renderSystem.isDynamicGeometryConsumed(
2821
+ candidate.world as World,
2822
+ candidate.entity ?? -1,
2823
+ candidate.meshHandle,
2824
+ ),
2825
+ });
2808
2826
  const derivedSystemNames = new Map<World, string>();
2809
2827
  const transformReleases = new Map<World, () => void>();
2810
2828
  const attachmentOwner = {};
@@ -2817,7 +2835,6 @@ async function makeWebGPURenderer(
2817
2835
  const receiptTimings = new WeakMap<FrameReceipt, Promise<VolumeTimingObservation>>();
2818
2836
  let latestReceipt: FrameReceipt | undefined;
2819
2837
  let surfaceReleased = false;
2820
-
2821
2838
  const renderer: RendererAssemblyImplementation = {
2822
2839
  attach(world: World): RenderResult<RenderWorldLease, RenderError> {
2823
2840
  const attached = this.attachScene(world);
@@ -2829,6 +2846,7 @@ async function makeWebGPURenderer(
2829
2846
  leasesByWorld.set(world, lease);
2830
2847
  return ok(lease);
2831
2848
  },
2849
+ ...dynamicGeometryHost,
2832
2850
  createRenderTarget: (descriptor) => renderTargetHost.createRenderTarget(descriptor),
2833
2851
  resizeRenderTarget: (target, descriptor) =>
2834
2852
  renderTargetHost.resizeRenderTarget(target, descriptor),
@@ -2949,6 +2967,7 @@ async function makeWebGPURenderer(
2949
2967
  }),
2950
2968
  ),
2951
2969
  ),
2970
+ dynamicGeometry: dynamicGeometry.inspect(),
2952
2971
  renderScene: renderSystem.renderScene,
2953
2972
  reflectionProbes: renderSystem.reflectionProbes,
2954
2973
  ssrDependencies: renderSystem.ssrDependencies,
@@ -3092,6 +3111,7 @@ async function makeWebGPURenderer(
3092
3111
  },
3093
3112
  detachScene(world: World): void {
3094
3113
  if (!attachedWorlds.delete(world)) return;
3114
+ dynamicGeometryHost.invalidateDynamicGeometryWorld(world);
3095
3115
  for (const [lease, leaseWorld] of attachedLeases) {
3096
3116
  if (leaseWorld === world) {
3097
3117
  lease.dispose();
@@ -3293,6 +3313,27 @@ async function makeWebGPURenderer(
3293
3313
  : worlds.map((world) => leasesByWorld.get(world)).every((lease) => lease !== undefined)
3294
3314
  ? worlds.map((world) => leasesByWorld.get(world) as RenderReadLease)
3295
3315
  : undefined;
3316
+ for (const world of worlds) {
3317
+ if (!world.hasResource('PhysicsWorld')) continue;
3318
+ const physics = world.getResource<{
3319
+ readonly getDerivedAdmission?: () => unknown;
3320
+ readonly getDerivedRecoveryState?: () => string;
3321
+ }>('PhysicsWorld');
3322
+ if (
3323
+ physics?.getDerivedAdmission?.() !== undefined ||
3324
+ physics?.getDerivedRecoveryState?.() === 'rebuild-required'
3325
+ ) {
3326
+ return err(
3327
+ new RendererOperationError('frame-input-invalid', {
3328
+ operation: 'draw',
3329
+ cause: new RendererContractFailureError(
3330
+ 'draw',
3331
+ 'paired geometry admission must finish physics step and writeback in a healthy World before draw',
3332
+ ),
3333
+ }),
3334
+ );
3335
+ }
3336
+ }
3296
3337
  if (frameRequest !== undefined && worlds.length !== frameRequest.leases.length) {
3297
3338
  return err(
3298
3339
  new RendererOperationError('world-lease-invalid', {
@@ -3479,6 +3520,8 @@ async function makeWebGPURenderer(
3479
3520
  const timingCompletion = timingHost.gpuPassTimingSubmittedWork;
3480
3521
  timingHost.gpuPassTimingSubmittedWork = undefined;
3481
3522
  const reflectionFallbackCompletion = renderSystem.reflectionFallbackCompletion;
3523
+ const queueCompletion = timingCompletion ?? internals.device.queue.onSubmittedWorkDone();
3524
+ gpuStore.trackMeshSubmission(queueCompletion);
3482
3525
  internals.pack.instrumentation?.onFrameBoundary?.();
3483
3526
  if (!isFrameRequest) {
3484
3527
  renderTargetHost.onFrameSubmitted();
@@ -3486,7 +3529,6 @@ async function makeWebGPURenderer(
3486
3529
  }
3487
3530
  const receiptFrameId = ++frameId;
3488
3531
  const receiptGeneration = activeDeviceScope.generation;
3489
- const queueCompletion = timingCompletion ?? internals.device.queue.onSubmittedWorkDone();
3490
3532
  const completion: FrameReceipt['completed'] = Promise.all([
3491
3533
  queueCompletion,
3492
3534
  reflectionFallbackCompletion ?? Promise.resolve(),
@@ -3608,6 +3650,7 @@ async function makeWebGPURenderer(
3608
3650
  );
3609
3651
  }
3610
3652
  issuedReceipts.add(receipt);
3653
+ dynamicGeometryHost.publishDynamicGeometry(receipt, worlds, frameRequest?.fixedStep);
3611
3654
  return ok(receipt);
3612
3655
  } catch (cause) {
3613
3656
  const error =
@@ -3756,6 +3799,10 @@ async function makeWebGPURenderer(
3756
3799
  dispose(): RenderResult<void, RenderError> {
3757
3800
  if (disposed) return ok(undefined);
3758
3801
  disposed = true;
3802
+ for (const world of attachedWorlds) {
3803
+ dynamicGeometryHost.invalidateDynamicGeometryWorld(world);
3804
+ }
3805
+ dynamicGeometry.dispose();
3759
3806
  for (const continuation of frameContinuations) {
3760
3807
  continuation.terminate({ code: 'disposed' });
3761
3808
  }
@@ -53,7 +53,7 @@
53
53
  //
54
54
  // 4-step recipe (charter F1 progressive disclosure — minimum walk-cycle
55
55
  // host code; full demo lands in M6 hello-sprite-atlas):
56
- // 1. Build atlas via `forgeax-engine-remote-asset atlas --input <glob> --name <prefix> --output <dir>`
56
+ // 1. Build atlas via `forgeax asset atlas --input <glob> --name <prefix> --output <dir> --root <project>`
57
57
  // (M5 build-time hook); load the emitted `<name>.atlas.png` plus
58
58
  // `<name>.atlas.meta.json` sidecar.
59
59
  // 2. Register the sprite material referencing the atlas
@@ -1,3 +1,4 @@
1
+ import { type MeshResidencyLease, MeshResidencyLifetime } from './mesh-residency-lifetime';
1
2
  // @forgeax/engine-runtime - GpuResidencyCache
2
3
  // (feat-20260601-device/gpu-residency-extraction M1; M2 added the deriveRenderData
3
4
  // projection seam — see render-data.ts).
@@ -557,6 +558,36 @@ export class GpuResidencyCache {
557
558
  // equirect source always resolves to the same cubemap (idempotent, A2).
558
559
  private readonly cubemapIdempotentMap: Map<number, Handle<'EquirectAsset', 'shared'>> = new Map();
559
560
  private readonly meshGpuHandles: Map<string | number, MeshGpuHandles> = new Map();
561
+ private readonly meshLifetimes = new WeakMap<MeshGpuHandles, MeshResidencyLifetime>();
562
+ private readonly retiredMeshes = new Set<MeshGpuHandles>();
563
+
564
+ private meshLifetime(entry: MeshGpuHandles): MeshResidencyLifetime {
565
+ let lifetime = this.meshLifetimes.get(entry);
566
+ if (lifetime === undefined) {
567
+ lifetime = new MeshResidencyLifetime(() => {
568
+ if (!entry.vertexBuffer.isDestroyed) entry.vertexBuffer.destroy();
569
+ if (entry.indexBuffer !== null && !entry.indexBuffer.isDestroyed)
570
+ entry.indexBuffer.destroy();
571
+ this.retiredMeshes.delete(entry);
572
+ });
573
+ this.meshLifetimes.set(entry, lifetime);
574
+ }
575
+ return lifetime;
576
+ }
577
+
578
+ /** All resident entries conservatively cover main, shadow and cached GPU-driven draws. */
579
+ trackMeshSubmission(completed: Promise<unknown>): void {
580
+ for (const entry of this.meshGpuHandles.values()) this.meshLifetime(entry).track(completed);
581
+ }
582
+
583
+ private retireMeshEntry(key: string | number, entry: MeshGpuHandles): void {
584
+ if (this.meshGpuHandles.get(key) === entry) {
585
+ this.meshGpuHandles.delete(key);
586
+ this.meshResidencyEpoch += 1;
587
+ }
588
+ this.retiredMeshes.add(entry);
589
+ this.meshLifetime(entry).retire();
590
+ }
560
591
 
561
592
  /**
562
593
  * Compose a cache key from the raw handle slot and its owning World. The
@@ -711,11 +742,12 @@ export class GpuResidencyCache {
711
742
  this.cubemapGpuHandles.clear();
712
743
  this.cubemapIdempotentMap.clear();
713
744
 
714
- for (const entry of this.meshGpuHandles.values()) {
745
+ for (const entry of [...this.meshGpuHandles.values(), ...this.retiredMeshes]) {
715
746
  destroyBuf(entry.vertexBuffer);
716
747
  if (entry.indexBuffer !== null) destroyBuf(entry.indexBuffer);
717
748
  }
718
749
  this.meshGpuHandles.clear();
750
+ this.retiredMeshes.clear();
719
751
  }
720
752
 
721
753
  /** Number of candidate-owned GPU resources currently held by this cache. */
@@ -730,6 +762,7 @@ export class GpuResidencyCache {
730
762
  }
731
763
  return (
732
764
  this.meshGpuHandles.size +
765
+ this.retiredMeshes.size +
733
766
  this.textureGpuHandles.size +
734
767
  this.samplerGpuHandles.size +
735
768
  cubemapResources
@@ -802,27 +835,13 @@ export class GpuResidencyCache {
802
835
  const entry = this.meshGpuHandles.get(id);
803
836
  if (entry === undefined) return { freed: 0, errors: [] };
804
837
 
805
- let freed = 0;
806
- const errors: RhiError[] = [];
807
-
808
- const destroyBuf = (gpuBuf: GpuBuffer): void => {
809
- if (!gpuBuf.isDestroyed) {
810
- const r = gpuBuf.destroy();
811
- if (r.ok) {
812
- freed += 1;
813
- } else {
814
- errors.push(r.error);
815
- if (this.errorRegistry) this.errorRegistry.fire(r.error);
816
- }
817
- }
838
+ if (this.meshLifetime(entry).owners > 0) return { freed: 0, errors: [] };
839
+ this.retireMeshEntry(id, entry);
840
+ return {
841
+ freed:
842
+ Number(entry.vertexBuffer.isDestroyed) + Number(entry.indexBuffer?.isDestroyed ?? false),
843
+ errors: [],
818
844
  };
819
-
820
- destroyBuf(entry.vertexBuffer);
821
- if (entry.indexBuffer !== null) destroyBuf(entry.indexBuffer);
822
-
823
- this.meshGpuHandles.delete(id);
824
- this.meshResidencyEpoch += 1;
825
- return { freed: freed > 0 ? 1 : 0, errors };
826
845
  }
827
846
 
828
847
  evictCubemap(id: number): { freed: number; errors: RhiError[] } {
@@ -862,7 +881,6 @@ export class GpuResidencyCache {
862
881
  releaseUnreferenced(liveSet: Set<number>): { freed: number; errors: RhiError[] } {
863
882
  let freed = 0;
864
883
  const errors: RhiError[] = [];
865
- let meshResidencyChanged = false;
866
884
  let materialResidencyChanged = false;
867
885
  const liveSlots = new Set([...liveSet].map((slot) => String(slot)));
868
886
  const isLiveKey = (key: string | number): boolean => {
@@ -916,35 +934,14 @@ export class GpuResidencyCache {
916
934
  }
917
935
  }
918
936
 
919
- for (const key of this.meshGpuHandles.keys()) {
920
- if (!isLiveKey(key)) {
921
- const entry = this.meshGpuHandles.get(key);
922
- if (entry !== undefined) {
923
- if (!entry.vertexBuffer.isDestroyed) {
924
- const r = entry.vertexBuffer.destroy();
925
- if (r.ok) {
926
- freed += 1;
927
- } else {
928
- errors.push(r.error);
929
- if (this.errorRegistry) this.errorRegistry.fire(r.error);
930
- }
931
- }
932
- if (entry.indexBuffer !== null && !entry.indexBuffer.isDestroyed) {
933
- const r = entry.indexBuffer.destroy();
934
- if (r.ok) {
935
- freed += 1;
936
- } else {
937
- errors.push(r.error);
938
- if (this.errorRegistry) this.errorRegistry.fire(r.error);
939
- }
940
- }
941
- this.meshGpuHandles.delete(key);
942
- meshResidencyChanged = true;
943
- }
937
+ for (const [key, entry] of this.meshGpuHandles) {
938
+ if (!isLiveKey(key) && this.meshLifetime(entry).owners === 0) {
939
+ this.retireMeshEntry(key, entry);
940
+ freed +=
941
+ Number(entry.vertexBuffer.isDestroyed) + Number(entry.indexBuffer?.isDestroyed ?? false);
944
942
  }
945
943
  }
946
944
 
947
- if (meshResidencyChanged) this.meshResidencyEpoch += 1;
948
945
  if (materialResidencyChanged) this.materialResourceEpoch += 1;
949
946
 
950
947
  return { freed, errors };
@@ -1096,23 +1093,26 @@ export class GpuResidencyCache {
1096
1093
  return this.meshGpuHandles.get(this.worldKey(handleSlot(handle), worldId));
1097
1094
  }
1098
1095
 
1099
- /**
1100
- * Drop one mutable shared mesh's GPU copy after ECS publishes `markChanged`.
1101
- * The next record pass resolves the same POD and rebuilds residency through
1102
- * the normal pull path, so ECS remains the payload authority.
1103
- */
1096
+ /** Candidate leases retain the concrete allocation even after cache invalidation. */
1097
+ retainMeshResidency(
1098
+ handle: Handle<'MeshAsset', 'shared'>,
1099
+ worldId: World | number = 0,
1100
+ ): MeshResidencyLease | undefined {
1101
+ const key = this.worldKey(handleSlot(handle), worldId);
1102
+ const entry = this.meshGpuHandles.get(key);
1103
+ return entry === undefined
1104
+ ? undefined
1105
+ : this.meshLifetime(entry).retain(() => {
1106
+ if (this.meshGpuHandles.get(key) === entry) this.invalidateMesh(handle, worldId);
1107
+ else this.meshLifetime(entry).retire();
1108
+ });
1109
+ }
1110
+
1111
+ /** In-place author changes immediately miss the cache; old submissions keep their allocation. */
1104
1112
  invalidateMesh(handle: number, worldId: World | number = 0): void {
1105
- // SharedRefMutation publishes the encoded handle value, while the cache
1106
- // key is derived from the decoded slot. Keep the invalidation path aligned
1107
- // with getMeshGpuHandles/ensureResident so an in-place ECS payload change
1108
- // cannot leave the old VBO resident.
1109
1113
  const key = this.worldKey(handleSlot(toShared<'MeshAsset'>(handle)), worldId);
1110
1114
  const entry = this.meshGpuHandles.get(key);
1111
- if (entry === undefined) return;
1112
- entry.vertexBuffer.destroy();
1113
- if (entry.indexBuffer !== null) entry.indexBuffer.destroy();
1114
- this.meshGpuHandles.delete(key);
1115
- this.meshResidencyEpoch += 1;
1115
+ if (entry !== undefined) this.retireMeshEntry(key, entry);
1116
1116
  }
1117
1117
 
1118
1118
  /**
@@ -0,0 +1,73 @@
1
+ /** A lease captures one GPU allocation, never a reusable World handle slot. */
2
+ export interface MeshResidencyLease {
3
+ track(completed: Promise<unknown>): void;
4
+ release(evict: boolean): Promise<void> | undefined;
5
+ }
6
+
7
+ /** Shared candidates and queue submissions have one allocation lifetime owner. */
8
+ export class MeshResidencyLifetime {
9
+ private leases = 0;
10
+ private retired = false;
11
+ private eviction: (() => void) | undefined;
12
+ private readonly submissions = new Set<Promise<unknown>>();
13
+ private readonly waiters = new Set<() => void>();
14
+
15
+ constructor(private destroy: (() => void) | undefined) {}
16
+
17
+ get owners(): number {
18
+ return this.leases;
19
+ }
20
+
21
+ retain(evict: () => void): MeshResidencyLease {
22
+ this.leases += 1;
23
+ this.eviction = undefined;
24
+ let released = false;
25
+ let completion: Promise<void> | undefined;
26
+ return {
27
+ track: (submitted) => this.track(submitted),
28
+ release: (shouldEvict) => {
29
+ if (released) return completion;
30
+ released = true;
31
+ this.leases -= 1;
32
+ if (this.leases === 0 && shouldEvict) this.eviction = evict;
33
+ completion =
34
+ this.submissions.size === 0
35
+ ? undefined
36
+ : new Promise<void>((resolve) => this.waiters.add(resolve));
37
+ this.flush();
38
+ return completion;
39
+ },
40
+ };
41
+ }
42
+
43
+ track(completed: Promise<unknown>): void {
44
+ if (this.submissions.has(completed)) return;
45
+ this.submissions.add(completed);
46
+ const finish = (): void => {
47
+ this.submissions.delete(completed);
48
+ this.flush();
49
+ };
50
+ void completed.then(finish, finish);
51
+ }
52
+
53
+ retire(): void {
54
+ this.retired = true;
55
+ this.flush();
56
+ }
57
+
58
+ private flush(): void {
59
+ if (this.submissions.size !== 0) return;
60
+ if (this.leases === 0 && this.eviction !== undefined) {
61
+ const evict = this.eviction;
62
+ this.eviction = undefined;
63
+ evict();
64
+ }
65
+ if (this.retired && this.leases === 0 && this.destroy !== undefined) {
66
+ const destroy = this.destroy;
67
+ this.destroy = undefined;
68
+ destroy();
69
+ }
70
+ for (const resolve of this.waiters) resolve();
71
+ this.waiters.clear();
72
+ }
73
+ }