@forgeax/engine-render 0.1.24 → 0.1.25

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 (135) hide show
  1. package/README.md +100 -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/surface-projection.d.ts +24 -0
  5. package/dist/assembly/material/surface-projection.d.ts.map +1 -0
  6. package/dist/assembly/material-shader-policy.d.ts +20 -0
  7. package/dist/assembly/material-shader-policy.d.ts.map +1 -1
  8. package/dist/assembly/webgpu-ready.d.ts.map +1 -1
  9. package/dist/assembly/webgpu-renderer.d.ts +1 -1
  10. package/dist/assembly/webgpu-renderer.d.ts.map +1 -1
  11. package/dist/assembly/webgpu-vertex-layouts.d.ts +89 -0
  12. package/dist/assembly/webgpu-vertex-layouts.d.ts.map +1 -0
  13. package/dist/assets/asset-decoders.d.ts.map +1 -1
  14. package/dist/authoring.mjs +1 -1
  15. package/dist/{chunk-Q752SHI5.mjs → chunk-ADHHVYLW.mjs} +3 -3
  16. package/dist/chunk-ADHHVYLW.mjs.map +1 -0
  17. package/dist/{chunk-TFH5I7JJ.mjs → chunk-E34VL5VI.mjs} +3 -3
  18. package/dist/{chunk-TFH5I7JJ.mjs.map → chunk-E34VL5VI.mjs.map} +1 -1
  19. package/dist/{chunk-KZFTK6DO.mjs → chunk-HKXTW355.mjs} +7 -4
  20. package/dist/chunk-HKXTW355.mjs.map +1 -0
  21. package/dist/{chunk-YKB5DQV7.mjs → chunk-JSEDTZYS.mjs} +87 -19
  22. package/dist/chunk-JSEDTZYS.mjs.map +1 -0
  23. package/dist/{chunk-UQYS7FRM.mjs → chunk-KM2NOX2I.mjs} +4 -4
  24. package/dist/{chunk-UQYS7FRM.mjs.map → chunk-KM2NOX2I.mjs.map} +1 -1
  25. package/dist/{chunk-Z3YYH2MB.mjs → chunk-N3RT2EUR.mjs} +94 -41
  26. package/dist/chunk-N3RT2EUR.mjs.map +1 -0
  27. package/dist/{chunk-YRUYGA4A.mjs → chunk-TCLDX7SZ.mjs} +559 -138
  28. package/dist/chunk-TCLDX7SZ.mjs.map +1 -0
  29. package/dist/{chunk-RR5L4SRS.mjs → chunk-ZZ4YQ474.mjs} +2 -2
  30. package/dist/{chunk-RR5L4SRS.mjs.map → chunk-ZZ4YQ474.mjs.map} +1 -1
  31. package/dist/components/instances.d.ts +13 -31
  32. package/dist/components/instances.d.ts.map +1 -1
  33. package/dist/construct-renderer.mjs +865 -142
  34. package/dist/construct-renderer.mjs.map +1 -1
  35. package/dist/gpu-driven/production-raster.d.ts +6 -1
  36. package/dist/gpu-driven/production-raster.d.ts.map +1 -1
  37. package/dist/gpu-scene.d.ts +4 -0
  38. package/dist/gpu-scene.d.ts.map +1 -1
  39. package/dist/index.d.ts +1 -0
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/index.mjs +27 -9
  42. package/dist/index.mjs.map +1 -1
  43. package/dist/instance-buffer-cache.d.ts +7 -1
  44. package/dist/instance-buffer-cache.d.ts.map +1 -1
  45. package/dist/instances.d.ts +101 -0
  46. package/dist/instances.d.ts.map +1 -0
  47. package/dist/internal.mjs +5 -5
  48. package/dist/materials.d.ts +63 -2
  49. package/dist/materials.d.ts.map +1 -1
  50. package/dist/pipeline-builder.d.ts.map +1 -1
  51. package/dist/pipeline-spec.d.ts.map +1 -1
  52. package/dist/record/frame-snapshot.d.ts +13 -2
  53. package/dist/record/frame-snapshot.d.ts.map +1 -1
  54. package/dist/record/frame.d.ts.map +1 -1
  55. package/dist/record/main-pass-geometry.d.ts +22 -3
  56. package/dist/record/main-pass-geometry.d.ts.map +1 -1
  57. package/dist/record/render-context.d.ts +2 -1
  58. package/dist/record/render-context.d.ts.map +1 -1
  59. package/dist/record/shadow-pass.d.ts +11 -1
  60. package/dist/record/shadow-pass.d.ts.map +1 -1
  61. package/dist/recovery/render-system-candidate.d.ts +1 -1
  62. package/dist/recovery/render-system-candidate.d.ts.map +1 -1
  63. package/dist/render-contract.d.ts +4 -0
  64. package/dist/render-contract.d.ts.map +1 -1
  65. package/dist/render-system-extract-tail.d.ts +4 -0
  66. package/dist/render-system-extract-tail.d.ts.map +1 -1
  67. package/dist/render-system-extract.d.ts +25 -1
  68. package/dist/render-system-extract.d.ts.map +1 -1
  69. package/dist/render-system.d.ts +4 -1
  70. package/dist/render-system.d.ts.map +1 -1
  71. package/dist/scene/render-scene-types.d.ts +2 -0
  72. package/dist/scene/render-scene-types.d.ts.map +1 -1
  73. package/dist/scene/render-scene.d.ts +12 -1
  74. package/dist/scene/render-scene.d.ts.map +1 -1
  75. package/dist/systems/skin-palette-allocator.d.ts +6 -0
  76. package/dist/systems/skin-palette-allocator.d.ts.map +1 -1
  77. package/dist/temporal/index.mjs +5 -5
  78. package/package.json +20 -20
  79. package/src/__tests__/gpu-scene.unit.test.ts +38 -2
  80. package/src/__tests__/instances-store.unit.test.ts +46 -0
  81. package/src/__tests__/instances-world-ownership.integration.test.ts +62 -0
  82. package/src/__tests__/material-contract-inventory.unit.test.ts +5 -13
  83. package/src/__tests__/material-pass-policy.unit.test.ts +69 -0
  84. package/src/__tests__/material-snapshot-mutation.integration.test.ts +69 -0
  85. package/src/__tests__/material-surface-authoring.test-d.ts +62 -0
  86. package/src/__tests__/materials-transmission-validation.unit.test.ts +24 -0
  87. package/src/__tests__/mesh-buffer-usage-surface.unit.test.ts +0 -4
  88. package/src/__tests__/renderer-factory-material-contract.unit.test.ts +42 -0
  89. package/src/__tests__/skin-motion-regression.unit.test.ts +33 -0
  90. package/src/__tests__/skinned-shadow-caster.test.ts +30 -2
  91. package/src/__tests__/surface-adjacent-regressions.integration.test.ts +60 -0
  92. package/src/__tests__/surface-standard-pipeline.browser.test.ts +20 -0
  93. package/src/__tests__/surface-standard-pipeline.dawn.test.ts +20 -0
  94. package/src/__tests__/surface-standard-pipeline.fixture.ts +132 -0
  95. package/src/__tests__/surface-variant-provenance.integration.test.ts +33 -0
  96. package/src/__tests__/volumetric-fog-budget-recovery.integration.test.ts +1 -1
  97. package/src/assembly/factory.ts +2 -0
  98. package/src/assembly/material/__tests__/standard-layer-plan-projection.integration.test.ts +18 -0
  99. package/src/assembly/material/__tests__/surface-pass-projection.integration.test.ts +62 -0
  100. package/src/assembly/material/surface-projection.ts +90 -0
  101. package/src/assembly/material-shader-policy.ts +88 -10
  102. package/src/assembly/recovery/renderer-recover.ts +1 -1
  103. package/src/assembly/webgpu-ready.ts +3 -16
  104. package/src/assembly/webgpu-renderer.ts +90 -158
  105. package/src/assembly/webgpu-vertex-layouts.ts +132 -0
  106. package/src/assets/asset-decoders.ts +27 -4
  107. package/src/components/instances.ts +14 -197
  108. package/src/gpu-driven/production-raster.ts +34 -1
  109. package/src/gpu-scene.ts +84 -29
  110. package/src/index.ts +1 -0
  111. package/src/instance-buffer-cache.ts +14 -1
  112. package/src/instances.ts +288 -0
  113. package/src/materials.ts +107 -12
  114. package/src/pipeline-builder.ts +14 -10
  115. package/src/pipeline-spec.ts +6 -14
  116. package/src/record/frame-snapshot.ts +19 -2
  117. package/src/record/frame.ts +31 -2
  118. package/src/record/main-pass-geometry.ts +498 -59
  119. package/src/record/main-pass-sprite-draws.ts +1 -1
  120. package/src/record/render-context.ts +4 -0
  121. package/src/record/shadow-pass.ts +46 -99
  122. package/src/recovery/render-system-candidate.ts +41 -4
  123. package/src/render-contract.ts +4 -0
  124. package/src/render-system-extract-tail.ts +107 -51
  125. package/src/render-system-extract.ts +53 -26
  126. package/src/render-system.ts +40 -5
  127. package/src/scene/render-scene-types.ts +2 -0
  128. package/src/scene/render-scene.ts +579 -36
  129. package/src/systems/skin-palette-allocator.ts +129 -7
  130. package/src/transmission/__tests__/surface-compatibility.integration.test.ts +32 -0
  131. package/dist/chunk-KZFTK6DO.mjs.map +0 -1
  132. package/dist/chunk-Q752SHI5.mjs.map +0 -1
  133. package/dist/chunk-YKB5DQV7.mjs.map +0 -1
  134. package/dist/chunk-YRUYGA4A.mjs.map +0 -1
  135. package/dist/chunk-Z3YYH2MB.mjs.map +0 -1
@@ -1,9 +1,9 @@
1
- import type { Query, World } from '@forgeax/engine-ecs';
1
+ import type { EntityHandle, Query, World } from '@forgeax/engine-ecs';
2
2
  import { componentId } from '@forgeax/engine-ecs/internal';
3
3
  import type { RenderReadLease, RenderReadVersion } from '@forgeax/engine-ecs/projection';
4
4
  import { box3, frustum, mat4 } from '@forgeax/engine-math';
5
5
  import { type RhiDevice, RhiError } from '@forgeax/engine-rhi';
6
- import { ChildOf, GlobalTransform, Transform } from '@forgeax/engine-scene';
6
+ import { ChildOf, GlobalTransform, MorphWeights, Transform } from '@forgeax/engine-scene';
7
7
  import { Skin } from '@forgeax/engine-skinning';
8
8
  import { err, ok, type Result } from '@forgeax/engine-types';
9
9
  import {
@@ -12,15 +12,18 @@ import {
12
12
  Instances,
13
13
  Layer,
14
14
  LightProbe,
15
+ Lines,
15
16
  MeshFilter,
16
17
  MeshRenderer,
17
18
  MotionBlur,
18
19
  PointLight,
19
20
  PointLightShadow,
21
+ Points,
20
22
  PostProcessParams,
21
23
  ReflectionProbe,
22
24
  SkyboxBackground,
23
25
  Skylight,
26
+ SortKey,
24
27
  SpotLight,
25
28
  SpriteInstances,
26
29
  SpriteRegionOverride,
@@ -34,6 +37,7 @@ import {
34
37
  } from '../gpu-driven/batch-topology';
35
38
  import { GpuScene } from '../gpu-scene';
36
39
  import type { PersistentRenderSceneInspection } from '../inspection-types';
40
+ import type { InstanceProjectionStore } from '../instances';
37
41
  import { fingerprintNumericArray, InstanceBoundsCache } from '../instances-derived-bounds';
38
42
  import type { PointsLinesInspection } from '../points-lines/inspection';
39
43
  import type { PointsLinesRetainedSnapshot } from '../points-lines/snapshot';
@@ -53,6 +57,7 @@ import type {
53
57
  RenderableSnapshot,
54
58
  RenderableTemporalSnapshot,
55
59
  } from '../render-system-extract';
60
+ import type { SkinPaletteAllocator } from '../systems/skin-palette-allocator';
56
61
  import {
57
62
  PersistentTransmissionDemandProjection,
58
63
  type TransmissionDemand,
@@ -97,6 +102,7 @@ interface PendingIdentity {
97
102
  readonly initial: RenderSceneSlot | undefined;
98
103
  current: RenderableSnapshot | undefined;
99
104
  removed: boolean;
105
+ contentChanged: boolean;
100
106
  }
101
107
 
102
108
  interface WorldTransformSpan {
@@ -107,6 +113,16 @@ interface WorldTransformSpan {
107
113
 
108
114
  type GlobalTransformChangeQuery = Query<readonly [typeof GlobalTransform]>;
109
115
 
116
+ /** The normal extract request used by the persistent scene owner. */
117
+ export type PersistentRenderCandidateRequest =
118
+ | 'full'
119
+ | 'none'
120
+ | {
121
+ readonly kind: 'partial';
122
+ /** Entity keys are local to the corresponding World entry. */
123
+ readonly entitiesByWorld: readonly (ReadonlySet<number> | undefined)[];
124
+ };
125
+
110
126
  export interface RenderSceneSubmissionCapture {
111
127
  readonly revision: number;
112
128
  readonly slots: readonly {
@@ -188,6 +204,9 @@ function ownSnapshot(snapshot: RenderableSnapshot): RenderableSnapshot {
188
204
  ...(snapshot.pointsLines === undefined
189
205
  ? {}
190
206
  : { pointsLines: ownPointsLinesSnapshot(snapshot.pointsLines) }),
207
+ ...(snapshot.skinJointEntities === undefined
208
+ ? {}
209
+ : { skinJointEntities: [...snapshot.skinJointEntities] }),
191
210
  };
192
211
  }
193
212
 
@@ -357,6 +376,7 @@ export class RenderScene {
357
376
  initial,
358
377
  current: initial?.snapshot,
359
378
  removed: false,
379
+ contentChanged: false,
360
380
  };
361
381
  entities.set(entityKey, pending);
362
382
  pendingOrder.push(pending);
@@ -368,6 +388,7 @@ export class RenderScene {
368
388
  const { worldId, entityKey } = operation.snapshot;
369
389
  const pending = pendingFor(worldId, entityKey);
370
390
  pending.current = ownSnapshot(operation.snapshot);
391
+ pending.contentChanged = true;
371
392
  continue;
372
393
  }
373
394
  const pending = pendingFor(operation.worldId, operation.entityKey);
@@ -389,6 +410,7 @@ export class RenderScene {
389
410
  let recreated = 0;
390
411
  const createdSlots: RenderSceneSlot[] = [];
391
412
  const updatedSlots: RenderSceneSlot[] = [];
413
+ const contentUpdatedSlots: RenderSceneSlot[] = [];
392
414
  const removedSlots: RenderSceneRecord[] = [];
393
415
  const recreatedSlots: RenderSceneSlot[] = [];
394
416
  for (const pending of pendingOrder) {
@@ -426,6 +448,7 @@ export class RenderScene {
426
448
  this.indexMaterials(updatedSlot);
427
449
  this.dirtySinceSubmitted.add(updatedSlot.slot);
428
450
  updatedSlots.push(updatedSlot);
451
+ if (pending.contentChanged) contentUpdatedSlots.push(updatedSlot);
429
452
  updated += 1;
430
453
  }
431
454
 
@@ -442,6 +465,7 @@ export class RenderScene {
442
465
  ignoredLateUpdates,
443
466
  createdSlots,
444
467
  updatedSlots,
468
+ contentUpdatedSlots,
445
469
  removedSlots,
446
470
  recreatedSlots,
447
471
  resynced: 0,
@@ -866,7 +890,7 @@ export class RenderScene {
866
890
  entityKey: snapshot.entityKey,
867
891
  meshGeneration: fingerprintNumericArray(snapshot.localAabb) ^ (snapshot.assetHandle >>> 0),
868
892
  transformGeneration: fingerprintNumericArray(snapshot.transform.world),
869
- matrixGeneration: snapshot.instances.archVersion,
893
+ matrixGeneration: snapshot.instances.revision ?? snapshot.instances.archVersion,
870
894
  meshAabb: snapshot.localAabb,
871
895
  entityWorld: snapshot.transform.world,
872
896
  transforms: snapshot.instances.transforms,
@@ -890,6 +914,9 @@ export interface PersistentRenderSceneOptions {
890
914
  readonly onGpuError?: ((error: RhiError) => void) | undefined;
891
915
  readonly onSharedRefMutation?: ((worldId: number, handle: number) => void) | undefined;
892
916
  readonly configuredQueryBudget?: number | undefined;
917
+ /** Rebuildable projection of World-owned instance matrices. */
918
+ readonly instanceCollections?: InstanceProjectionStore | undefined;
919
+ readonly getSkinPaletteAllocator?: (() => SkinPaletteAllocator | null) | undefined;
893
920
  }
894
921
 
895
922
  export interface PersistentGpuDrivenState {
@@ -971,7 +998,7 @@ interface PersistentCompositionEntry {
971
998
  readonly projection: RenderScene;
972
999
  readonly topology: BatchTopology;
973
1000
  readonly transmissionDemand: PersistentTransmissionDemandProjection;
974
- readonly structure: Pick<
1001
+ structure: Pick<
975
1002
  ExtractedFrame,
976
1003
  | 'dispatch'
977
1004
  | 'visibilityStats'
@@ -984,15 +1011,24 @@ interface PersistentCompositionEntry {
984
1011
  readonly readVersions: RenderReadVersion[];
985
1012
  readonly leaseIdentities: readonly string[];
986
1013
  readonly transformQueries: readonly GlobalTransformChangeQuery[];
1014
+ readonly renderChangeQueries: readonly ReadonlyMap<number, Query>[];
1015
+ readonly dispatchBySlot: Map<number, readonly DispatchEntry[]>;
1016
+ dispatchRevision: number;
1017
+ dispatchCache:
1018
+ | { readonly revision: number; readonly value: readonly DispatchEntry[] }
1019
+ | undefined;
1020
+ readonly skinConsumersByJoint: Map<string, Set<number>>;
1021
+ /** Renderable identities whose partial producer still needs a successful publish. */
1022
+ readonly pendingRenderableEntitiesByWorld: Set<number>[];
1023
+ /** Slots retained for recovery but excluded from direct and indirect submission. */
1024
+ readonly unavailableSlots: Set<number>;
987
1025
  catalogEpoch: number;
988
- readonly rigidOnly: boolean;
989
1026
  readonly probeProjection: ProbeBlendSceneProjection;
990
1027
  temporalDemanded: boolean;
991
1028
  gpuDrivenSceneRequired: boolean;
992
1029
  }
993
1030
 
994
1031
  const RENDER_RELEVANT_COMPONENT_IDS = new Set([
995
- componentId(Transform),
996
1032
  componentId(GlobalTransform),
997
1033
  componentId(ChildOf),
998
1034
  componentId(MeshFilter),
@@ -1003,6 +1039,10 @@ const RENDER_RELEVANT_COMPONENT_IDS = new Set([
1003
1039
  componentId(Skin),
1004
1040
  componentId(Layer),
1005
1041
  componentId(Visibility),
1042
+ componentId(MorphWeights),
1043
+ componentId(Points),
1044
+ componentId(Lines),
1045
+ componentId(SortKey),
1006
1046
  componentId(Camera),
1007
1047
  componentId(MotionBlur),
1008
1048
  componentId(DirectionalLight),
@@ -1017,6 +1057,41 @@ const RENDER_RELEVANT_COMPONENT_IDS = new Set([
1017
1057
  componentId(VolumetricFog),
1018
1058
  ]);
1019
1059
 
1060
+ // These components can change the retained renderable source. Other relevant
1061
+ // components affect frame resources and still require a composition rebuild;
1062
+ // they must not cause a renderable-only partial extraction to silently retain
1063
+ // stale lights, cameras, or environment facts.
1064
+ const RENDERABLE_SOURCE_COMPONENTS = [
1065
+ GlobalTransform,
1066
+ ChildOf,
1067
+ MeshFilter,
1068
+ MeshRenderer,
1069
+ Instances,
1070
+ SpriteInstances,
1071
+ SpriteRegionOverride,
1072
+ Skin,
1073
+ Layer,
1074
+ Visibility,
1075
+ MorphWeights,
1076
+ Points,
1077
+ Lines,
1078
+ SortKey,
1079
+ ] as const;
1080
+ const RENDERABLE_SOURCE_COMPONENT_IDS = new Set(
1081
+ RENDERABLE_SOURCE_COMPONENTS.map((component) => componentId(component)),
1082
+ );
1083
+ const VISIBILITY_DEPENDENCY_COMPONENT_IDS = new Set([
1084
+ componentId(ChildOf),
1085
+ componentId(Visibility),
1086
+ ]);
1087
+ const STRUCTURAL_MEMBERSHIP_SCAN_COMPONENT_IDS = new Set([
1088
+ componentId(ChildOf),
1089
+ componentId(Layer),
1090
+ componentId(Visibility),
1091
+ componentId(SpriteRegionOverride),
1092
+ componentId(SortKey),
1093
+ ]);
1094
+
1020
1095
  function createGlobalTransformChangeQuery(world: World): GlobalTransformChangeQuery {
1021
1096
  const result = world.query({ read: [GlobalTransform], changed: [GlobalTransform] });
1022
1097
  if (!result.ok) throw result.error;
@@ -1027,6 +1102,149 @@ function createGlobalTransformChangeQuery(world: World): GlobalTransformChangeQu
1027
1102
  return query;
1028
1103
  }
1029
1104
 
1105
+ function createRenderChangeQueries(world: World): ReadonlyMap<number, Query> {
1106
+ const queries = new Map<number, Query>();
1107
+ for (const component of RENDERABLE_SOURCE_COMPONENTS) {
1108
+ const result = world.query({ read: [component], changed: [component] });
1109
+ if (!result.ok) throw result.error;
1110
+ const query = result.value as Query;
1111
+ // The initial full extraction has already consumed this change evidence.
1112
+ // Keep the query compiled so subsequent frames iterate only rows whose
1113
+ // component version changed.
1114
+ for (const _span of query.spans().unwrap()) {
1115
+ // Establish the observation baseline.
1116
+ }
1117
+ queries.set(componentId(component), query);
1118
+ }
1119
+ return queries;
1120
+ }
1121
+
1122
+ function collectRenderableEntities(world: World): ReadonlySet<number> {
1123
+ const result = new Set<number>();
1124
+ const query = world.query({ read: [MeshRenderer], with: [Transform, MeshFilter] }).unwrap();
1125
+ for (const row of query) result.add(row.entity as number);
1126
+ return result;
1127
+ }
1128
+
1129
+ function sourceMembershipChanged(world: World, slot: RenderSceneSlot): boolean {
1130
+ const entity = slot.entityKey as EntityHandle;
1131
+ const snapshot = slot.snapshot;
1132
+ return (
1133
+ !world.hasComponent(entity, GlobalTransform) ||
1134
+ (snapshot.instances !== undefined) !== world.hasComponent(entity, Instances) ||
1135
+ (snapshot.spriteInstances !== undefined) !== world.hasComponent(entity, SpriteInstances) ||
1136
+ (snapshot.skin !== undefined) !== world.hasComponent(entity, Skin) ||
1137
+ (snapshot.morph !== undefined) !== world.hasComponent(entity, MorphWeights) ||
1138
+ (snapshot.pointsLines?.component === 'Points') !== world.hasComponent(entity, Points) ||
1139
+ (snapshot.pointsLines?.component === 'Lines') !== world.hasComponent(entity, Lines)
1140
+ );
1141
+ }
1142
+
1143
+ function skinJointKey(worldId: number, jointEntity: number): string {
1144
+ return `${worldId}:${jointEntity}`;
1145
+ }
1146
+
1147
+ function addSkinConsumer(index: Map<string, Set<number>>, snapshot: RenderableSnapshot): void {
1148
+ for (const jointEntity of snapshot.skinJointEntities ?? []) {
1149
+ const key = skinJointKey(snapshot.worldId, jointEntity);
1150
+ let consumers = index.get(key);
1151
+ if (consumers === undefined) {
1152
+ consumers = new Set<number>();
1153
+ index.set(key, consumers);
1154
+ }
1155
+ consumers.add(snapshot.entityKey);
1156
+ }
1157
+ }
1158
+
1159
+ function removeSkinConsumer(index: Map<string, Set<number>>, snapshot: RenderableSnapshot): void {
1160
+ for (const jointEntity of snapshot.skinJointEntities ?? []) {
1161
+ const key = skinJointKey(snapshot.worldId, jointEntity);
1162
+ const consumers = index.get(key);
1163
+ consumers?.delete(snapshot.entityKey);
1164
+ if (consumers?.size === 0) index.delete(key);
1165
+ }
1166
+ }
1167
+
1168
+ function dispatchEntriesBySlot(
1169
+ frame: ExtractedFrame,
1170
+ projection: RenderScene,
1171
+ ): Map<number, readonly DispatchEntry[]> {
1172
+ const bySlot = new Map<number, DispatchEntry[]>();
1173
+ const byRenderable = new Map<number, DispatchEntry[]>();
1174
+ for (const entry of frame.dispatch) {
1175
+ const list = byRenderable.get(entry.renderableIndex);
1176
+ if (list === undefined) byRenderable.set(entry.renderableIndex, [entry]);
1177
+ else list.push(entry);
1178
+ }
1179
+ for (let index = 0; index < frame.renderables.length; index += 1) {
1180
+ const renderable = frame.renderables[index];
1181
+ if (renderable === undefined) continue;
1182
+ const slot = projection.slot(renderable.worldId, renderable.entityKey);
1183
+ if (slot === undefined) continue;
1184
+ bySlot.set(slot.slot, byRenderable.get(index) ?? []);
1185
+ }
1186
+ return bySlot;
1187
+ }
1188
+
1189
+ function dispatchForProjection(
1190
+ dispatchBySlot: ReadonlyMap<number, readonly DispatchEntry[]>,
1191
+ projection: RenderScene,
1192
+ unavailableSlots: ReadonlySet<number>,
1193
+ ): readonly DispatchEntry[] {
1194
+ const dispatch: DispatchEntry[] = [];
1195
+ const renderables = projection.materialize();
1196
+ for (let renderableIndex = 0; renderableIndex < renderables.length; renderableIndex += 1) {
1197
+ const renderable = renderables[renderableIndex];
1198
+ if (renderable === undefined) continue;
1199
+ const slot = projection.slot(renderable.worldId, renderable.entityKey);
1200
+ if (slot === undefined) continue;
1201
+ if (unavailableSlots.has(slot.slot)) continue;
1202
+ for (const entry of dispatchBySlot.get(slot.slot) ?? []) {
1203
+ dispatch.push({ ...entry, renderableIndex });
1204
+ }
1205
+ }
1206
+ return dispatch;
1207
+ }
1208
+
1209
+ function topologyDeltaForAvailable(
1210
+ delta: RenderSceneApplyResult,
1211
+ unavailableSlots: ReadonlySet<number>,
1212
+ newlyUnavailable: readonly RenderSceneSlot[],
1213
+ ): RenderSceneApplyResult {
1214
+ const updatedSlots = delta.updatedSlots.filter((slot) => !unavailableSlots.has(slot.slot));
1215
+ const contentUpdatedSlots = delta.contentUpdatedSlots?.filter(
1216
+ (slot) => !unavailableSlots.has(slot.slot),
1217
+ );
1218
+ return {
1219
+ ...delta,
1220
+ updatedSlots: [
1221
+ ...updatedSlots,
1222
+ ...newlyUnavailable.map((slot) => ({
1223
+ ...slot,
1224
+ snapshot: { ...slot.snapshot, gpuDrivenDraws: [] },
1225
+ })),
1226
+ ],
1227
+ ...(contentUpdatedSlots === undefined ? {} : { contentUpdatedSlots }),
1228
+ };
1229
+ }
1230
+
1231
+ function addSharedRefConsumers(
1232
+ projection: RenderScene,
1233
+ worldId: number,
1234
+ handle: number,
1235
+ target: Set<number>,
1236
+ ): void {
1237
+ for (const slot of projection.slotsSnapshot()) {
1238
+ if (
1239
+ slot.worldId === worldId &&
1240
+ (slot.snapshot.assetHandle === handle ||
1241
+ slot.snapshot.materials.some((material) => material.materialHandle === handle))
1242
+ ) {
1243
+ target.add(slot.entityKey);
1244
+ }
1245
+ }
1246
+ }
1247
+
1030
1248
  function frameDemandsTemporal(frame: ExtractedFrame): boolean {
1031
1249
  const camera = frame.cameras[0];
1032
1250
  return camera?.antialias === 'taa' || (camera?.motionBlur?.shutterAngle ?? 0) > 0;
@@ -1090,6 +1308,10 @@ function cullPersistentFrame(
1090
1308
  for (let index = 0; index < candidates.length; index += 1) {
1091
1309
  const candidate = candidates[index];
1092
1310
  if (candidate === undefined) continue;
1311
+ if (candidate.authorVisible === false) {
1312
+ culled += 1;
1313
+ continue;
1314
+ }
1093
1315
  let isVisible = true;
1094
1316
  const candidateBounds = boundsOf(candidate, index);
1095
1317
  if (candidateBounds !== undefined) {
@@ -1395,7 +1617,7 @@ export class PersistentRenderScene {
1395
1617
  bounds.length >= 6 &&
1396
1618
  [...bounds].every((value) => Number.isFinite(value));
1397
1619
  draw = decideVisibility({
1398
- authorVisible: true,
1620
+ authorVisible: renderable.authorVisible !== false,
1399
1621
  validBounds,
1400
1622
  frustumVisible: true,
1401
1623
  lodReady: true,
@@ -1499,7 +1721,7 @@ export class PersistentRenderScene {
1499
1721
  worlds: readonly World[],
1500
1722
  owner: { readonly cameraOwner: number; readonly resourceOwner: number },
1501
1723
  catalogEpoch: number,
1502
- buildCandidateFrame: (renderables: 'full' | 'none') => ExtractedFrame,
1724
+ buildCandidateFrame: (request: PersistentRenderCandidateRequest) => ExtractedFrame,
1503
1725
  leases?: readonly RenderReadLease[],
1504
1726
  ): ExtractedFrame {
1505
1727
  this.worldEntitiesScanned = 0;
@@ -1544,11 +1766,16 @@ export class PersistentRenderScene {
1544
1766
  preserveVisibilityOnReorder,
1545
1767
  );
1546
1768
  }
1547
- if (!entry.rigidOnly) {
1548
- return this.rebuildComposition(worlds, owner, catalogEpoch, buildCandidateFrame, leases);
1549
- }
1550
-
1551
1769
  let requiresRebuild = false;
1770
+ const changedEntitiesByWorld = worlds.map(
1771
+ (_, worldId) => new Set(entry.pendingRenderableEntitiesByWorld[worldId] ?? []),
1772
+ );
1773
+ const nextReadVersions = entry.readVersions.slice();
1774
+ const structureChangedWorlds = new Set<number>();
1775
+ const structuralComponentIdsByWorld = worlds.map(() => new Set<number>());
1776
+ let refreshStructureFacts = false;
1777
+ let currentRenderableEntitiesByWorld: readonly (ReadonlySet<number> | undefined)[] | undefined;
1778
+ const removedSlots: RenderSceneSlot[] = [];
1552
1779
  for (let worldId = 0; worldId < worlds.length; worldId += 1) {
1553
1780
  const world = worlds[worldId];
1554
1781
  if (world === undefined) continue;
@@ -1569,35 +1796,294 @@ export class PersistentRenderScene {
1569
1796
  requiresRebuild = true;
1570
1797
  break;
1571
1798
  }
1572
- if (read.status !== 'ok') {
1573
- requiresRebuild = true;
1574
- break;
1799
+ if (read.status === 'rebuild') {
1800
+ structureChangedWorlds.add(worldId);
1801
+ refreshStructureFacts = true;
1575
1802
  }
1576
1803
  const changes = read.world;
1577
1804
  const shared = read.sharedRefs;
1578
1805
  if (shared.records.length > 0) {
1579
1806
  for (const record of shared.records) {
1580
1807
  this.options.onSharedRefMutation?.(worldId, record.handle);
1808
+ const changed = changedEntitiesByWorld[worldId];
1809
+ if (changed !== undefined)
1810
+ addSharedRefConsumers(entry.projection, worldId, record.handle, changed);
1581
1811
  }
1582
- requiresRebuild = true;
1583
- break;
1584
1812
  }
1585
1813
  for (const changedComponentId of changes.changedComponentIds) {
1814
+ if (read.status === 'rebuild') {
1815
+ structuralComponentIdsByWorld[worldId]?.add(changedComponentId);
1816
+ }
1586
1817
  if (!RENDER_RELEVANT_COMPONENT_IDS.has(changedComponentId)) continue;
1587
- if (
1588
- changedComponentId !== componentId(Transform) &&
1589
- changedComponentId !== componentId(GlobalTransform)
1590
- ) {
1818
+ if (!RENDERABLE_SOURCE_COMPONENT_IDS.has(changedComponentId)) {
1591
1819
  requiresRebuild = true;
1592
1820
  break;
1593
1821
  }
1822
+ const changed = changedEntitiesByWorld[worldId];
1823
+ if (changed === undefined) continue;
1824
+ if (VISIBILITY_DEPENDENCY_COMPONENT_IDS.has(changedComponentId)) {
1825
+ refreshStructureFacts = true;
1826
+ // Visibility and parent changes affect the effective state of a
1827
+ // subtree. The normal update path evaluates the current rows; the
1828
+ // set is deliberately allowed to be broad, while empty work still
1829
+ // exits naturally without a candidate loop.
1830
+ for (const entity of collectRenderableEntities(world)) changed.add(entity);
1831
+ continue;
1832
+ }
1833
+ if (changedComponentId === componentId(GlobalTransform)) {
1834
+ const query = entry.renderChangeQueries[worldId]?.get(changedComponentId);
1835
+ for (const row of query ?? []) {
1836
+ const consumers = entry.skinConsumersByJoint.get(
1837
+ skinJointKey(worldId, row.entity as number),
1838
+ );
1839
+ for (const consumer of consumers ?? []) changed.add(consumer);
1840
+ }
1841
+ continue;
1842
+ }
1843
+ const query = entry.renderChangeQueries[worldId]?.get(changedComponentId);
1844
+ if (query !== undefined) {
1845
+ for (const row of query) changed.add(row.entity as number);
1846
+ }
1594
1847
  }
1595
1848
  if (requiresRebuild) break;
1596
- entry.readVersions[worldId] = read.version;
1849
+ nextReadVersions[worldId] = read.version;
1597
1850
  }
1598
1851
  if (requiresRebuild) {
1599
1852
  return this.rebuildComposition(worlds, owner, catalogEpoch, buildCandidateFrame, leases);
1600
1853
  }
1854
+
1855
+ if (structureChangedWorlds.size > 0) {
1856
+ this.visibilityFacets.clear();
1857
+ this.invalidateActiveEntityRevision();
1858
+ const currentByWorld = worlds.map((world) => collectRenderableEntities(world));
1859
+ currentRenderableEntitiesByWorld = currentByWorld;
1860
+ for (let worldId = 0; worldId < currentByWorld.length; worldId += 1) {
1861
+ const world = worlds[worldId];
1862
+ const current = currentByWorld[worldId];
1863
+ if (world === undefined || current === undefined) continue;
1864
+ const changed = changedEntitiesByWorld[worldId];
1865
+ if (changed !== undefined && structureChangedWorlds.has(worldId)) {
1866
+ // A joint can disappear without leaving a row in the joint's
1867
+ // GlobalTransform change query. Wake every dependent renderable so
1868
+ // the next partial extraction validates the dangling reference and
1869
+ // either republishes or retires its palette data.
1870
+ for (const [jointKey, consumers] of entry.skinConsumersByJoint) {
1871
+ const separator = jointKey.indexOf(':');
1872
+ const indexedWorldId = Number(jointKey.slice(0, separator));
1873
+ if (indexedWorldId !== worldId) continue;
1874
+ const jointEntity = Number(jointKey.slice(separator + 1));
1875
+ if (world.hasComponent(jointEntity as EntityHandle, GlobalTransform)) continue;
1876
+ for (const consumer of consumers) changed.add(consumer);
1877
+ }
1878
+ for (const entity of current) {
1879
+ if (entry.projection.slot(worldId, entity) === undefined) changed.add(entity);
1880
+ }
1881
+ const structuralComponentIds = structuralComponentIdsByWorld[worldId];
1882
+ const scanAllMembership =
1883
+ structuralComponentIds !== undefined &&
1884
+ [...structuralComponentIds].some((id) =>
1885
+ STRUCTURAL_MEMBERSHIP_SCAN_COMPONENT_IDS.has(id),
1886
+ );
1887
+ for (const slot of entry.projection.slotsSnapshot()) {
1888
+ if (slot.worldId !== worldId || !current.has(slot.entityKey)) continue;
1889
+ const membershipChanged = sourceMembershipChanged(world, slot);
1890
+ if (scanAllMembership || membershipChanged) {
1891
+ changed.add(slot.entityKey);
1892
+ if (
1893
+ slot.snapshot.instances !== undefined &&
1894
+ !world.hasComponent(slot.entityKey as EntityHandle, Instances)
1895
+ ) {
1896
+ this.options.instanceCollections?.release(world, slot.entityKey);
1897
+ }
1898
+ if (
1899
+ slot.snapshot.skin !== undefined &&
1900
+ !world.hasComponent(slot.entityKey as EntityHandle, Skin)
1901
+ ) {
1902
+ this.options
1903
+ .getSkinPaletteAllocator?.()
1904
+ ?.releaseSliceFor(`${world.identity}:${slot.entityKey}`);
1905
+ }
1906
+ }
1907
+ }
1908
+ }
1909
+ for (const slot of entry.projection.slotsSnapshot()) {
1910
+ if (slot.worldId !== worldId || current.has(slot.entityKey)) continue;
1911
+ removedSlots.push(slot);
1912
+ }
1913
+ this.worldEntitiesScanned += current.size;
1914
+ }
1915
+ }
1916
+
1917
+ // Query iteration is an observation step, not publication. Persist every
1918
+ // requested identity before invoking any producer so a thrown or omitted
1919
+ // partial extraction is retried even after ECS change evidence is drained.
1920
+ for (let worldId = 0; worldId < changedEntitiesByWorld.length; worldId += 1) {
1921
+ const pending = entry.pendingRenderableEntitiesByWorld[worldId];
1922
+ if (pending === undefined) continue;
1923
+ for (const entityKey of changedEntitiesByWorld[worldId] ?? []) pending.add(entityKey);
1924
+ }
1925
+
1926
+ let changedDelta: RenderSceneApplyResult | undefined;
1927
+ const hasChangedEntities = changedEntitiesByWorld.some((entities) => entities.size > 0);
1928
+ if (hasChangedEntities || removedSlots.length > 0) {
1929
+ const operations: RenderSceneOperation[] = [];
1930
+ const removedDemand: TransmissionDemandOperation[] = [];
1931
+ const replacedSkinSnapshots: RenderableSnapshot[] = [];
1932
+ for (let worldId = 0; worldId < changedEntitiesByWorld.length; worldId += 1) {
1933
+ for (const entityKey of changedEntitiesByWorld[worldId] ?? []) {
1934
+ const slot = entry.projection.slot(worldId, entityKey);
1935
+ if (slot !== undefined) replacedSkinSnapshots.push(slot.snapshot);
1936
+ }
1937
+ }
1938
+ for (const slot of removedSlots) {
1939
+ operations.push({ kind: 'remove', worldId: slot.worldId, entityKey: slot.entityKey });
1940
+ if (slot.snapshot.skinJointEntities !== undefined) {
1941
+ removeSkinConsumer(entry.skinConsumersByJoint, slot.snapshot);
1942
+ }
1943
+ const world = worlds[slot.worldId];
1944
+ if (world !== undefined) this.options.instanceCollections?.release(world, slot.entityKey);
1945
+ this.options
1946
+ .getSkinPaletteAllocator?.()
1947
+ ?.releaseSliceFor(`${world?.identity ?? slot.worldId}:${slot.entityKey}`);
1948
+ for (
1949
+ let materialIndex = 0;
1950
+ materialIndex < slot.snapshot.materials.length;
1951
+ materialIndex += 1
1952
+ ) {
1953
+ removedDemand.push({
1954
+ kind: 'remove',
1955
+ key: `${slot.worldId}:${slot.entityKey}:${materialIndex}`,
1956
+ });
1957
+ }
1958
+ }
1959
+ for (let worldId = 0; worldId < changedEntitiesByWorld.length; worldId += 1) {
1960
+ for (const entityKey of changedEntitiesByWorld[worldId] ?? []) {
1961
+ const slot = entry.projection.slot(worldId, entityKey);
1962
+ if (slot === undefined) continue;
1963
+ for (
1964
+ let materialIndex = 0;
1965
+ materialIndex < slot.snapshot.materials.length;
1966
+ materialIndex += 1
1967
+ ) {
1968
+ removedDemand.push({ kind: 'remove', key: `${worldId}:${entityKey}:${materialIndex}` });
1969
+ }
1970
+ }
1971
+ }
1972
+ const partial = hasChangedEntities
1973
+ ? buildCandidateFrame({ kind: 'partial', entitiesByWorld: changedEntitiesByWorld })
1974
+ : undefined;
1975
+ const publishedEntitiesByWorld = worlds.map(() => new Set<number>());
1976
+ for (const snapshot of partial?.renderables ?? []) {
1977
+ operations.push({ kind: 'create', snapshot });
1978
+ publishedEntitiesByWorld[snapshot.worldId]?.add(snapshot.entityKey);
1979
+ }
1980
+ changedDelta = entry.projection.apply(operations);
1981
+ const publishedSkinKeys = new Set<string>();
1982
+ for (const snapshot of partial?.renderables ?? []) {
1983
+ publishedSkinKeys.add(`${snapshot.worldId}:${snapshot.entityKey}`);
1984
+ }
1985
+ for (const snapshot of replacedSkinSnapshots) {
1986
+ // Keep the old dependency while a failed/omitted partial publication
1987
+ // retains its CPU slot. The pending identity must still be woken by
1988
+ // a later joint transform change so the producer gets another chance.
1989
+ if (
1990
+ snapshot.skinJointEntities !== undefined &&
1991
+ publishedSkinKeys.has(`${snapshot.worldId}:${snapshot.entityKey}`)
1992
+ ) {
1993
+ removeSkinConsumer(entry.skinConsumersByJoint, snapshot);
1994
+ }
1995
+ }
1996
+ for (const snapshot of partial?.renderables ?? []) {
1997
+ if (snapshot.skinJointEntities !== undefined) {
1998
+ addSkinConsumer(entry.skinConsumersByJoint, snapshot);
1999
+ }
2000
+ }
2001
+ entry.transmissionDemand.apply([
2002
+ ...removedDemand,
2003
+ ...transmissionDemandOperations(partial?.renderables ?? []),
2004
+ ]);
2005
+ for (const removed of changedDelta.removedSlots) entry.dispatchBySlot.delete(removed.slot);
2006
+ for (const recreated of changedDelta.recreatedSlots)
2007
+ entry.dispatchBySlot.delete(recreated.slot);
2008
+ if (partial !== undefined) {
2009
+ if (refreshStructureFacts) {
2010
+ entry.structure = {
2011
+ ...entry.structure,
2012
+ dispatch: partial.dispatch,
2013
+ visibilityStats: partial.visibilityStats,
2014
+ visibilitySnapshots: partial.visibilitySnapshots,
2015
+ featureVisibilitySnapshots: partial.featureVisibilitySnapshots,
2016
+ hiddenEntityReports: partial.hiddenEntityReports,
2017
+ };
2018
+ }
2019
+ const partialDispatch = dispatchEntriesBySlot(partial, entry.projection);
2020
+ for (const [slot, dispatch] of partialDispatch) entry.dispatchBySlot.set(slot, dispatch);
2021
+ // A changed renderable which now fails producer validation still owns
2022
+ // its retained identity; replace its old dispatch with an empty list.
2023
+ for (const snapshot of partial.renderables) {
2024
+ const slot = entry.projection.slot(snapshot.worldId, snapshot.entityKey);
2025
+ if (slot !== undefined && !partialDispatch.has(slot.slot))
2026
+ entry.dispatchBySlot.set(slot.slot, []);
2027
+ }
2028
+ }
2029
+ for (let worldId = 0; worldId < changedEntitiesByWorld.length; worldId += 1) {
2030
+ const requested = changedEntitiesByWorld[worldId];
2031
+ const published = publishedEntitiesByWorld[worldId];
2032
+ if (requested === undefined || published === undefined) continue;
2033
+ const pending = entry.pendingRenderableEntitiesByWorld[worldId];
2034
+ for (const entityKey of requested) {
2035
+ const slot = entry.projection.slot(worldId, entityKey);
2036
+ if (published.has(entityKey)) {
2037
+ pending?.delete(entityKey);
2038
+ continue;
2039
+ }
2040
+ if (slot === undefined) {
2041
+ const current = currentRenderableEntitiesByWorld?.[worldId];
2042
+ if (current !== undefined && !current.has(entityKey)) pending?.delete(entityKey);
2043
+ continue;
2044
+ }
2045
+ // The producer did not publish this requested identity. Keep the
2046
+ // old CPU slot for recovery, but stop submitting stale dispatch and
2047
+ // retry the same identity even if its World version stays still.
2048
+ pending?.add(entityKey);
2049
+ if (slot !== undefined) entry.dispatchBySlot.set(slot.slot, []);
2050
+ }
2051
+ }
2052
+ const newlyUnavailable: RenderSceneSlot[] = [];
2053
+ for (let worldId = 0; worldId < changedEntitiesByWorld.length; worldId += 1) {
2054
+ const requested = changedEntitiesByWorld[worldId];
2055
+ const published = publishedEntitiesByWorld[worldId];
2056
+ if (requested === undefined || published === undefined) continue;
2057
+ for (const entityKey of requested) {
2058
+ const slot = entry.projection.slot(worldId, entityKey);
2059
+ if (slot === undefined || published.has(entityKey)) {
2060
+ if (slot !== undefined) entry.unavailableSlots.delete(slot.slot);
2061
+ continue;
2062
+ }
2063
+ if (!entry.unavailableSlots.has(slot.slot)) newlyUnavailable.push(slot);
2064
+ entry.unavailableSlots.add(slot.slot);
2065
+ }
2066
+ }
2067
+ for (const removed of changedDelta.removedSlots) entry.unavailableSlots.delete(removed.slot);
2068
+ for (const recreated of changedDelta.recreatedSlots)
2069
+ entry.unavailableSlots.delete(recreated.slot);
2070
+ entry.dispatchRevision += 1;
2071
+ entry.dispatchCache = undefined;
2072
+ entry.topology.apply(
2073
+ topologyDeltaForAvailable(changedDelta, entry.unavailableSlots, newlyUnavailable),
2074
+ );
2075
+ this.syncGpuOwner(entry.token, entry.projection, changedDelta);
2076
+ this.deltaFrames +=
2077
+ changedDelta.created +
2078
+ changedDelta.updated +
2079
+ changedDelta.removed +
2080
+ changedDelta.recreated >
2081
+ 0
2082
+ ? 1
2083
+ : 0;
2084
+ this.transformUpdates += changedDelta.updated;
2085
+ }
2086
+
1601
2087
  const transformSpans: WorldTransformSpan[] = [];
1602
2088
  for (let worldId = 0; worldId < entry.transformQueries.length; worldId += 1) {
1603
2089
  const query = entry.transformQueries[worldId];
@@ -1615,17 +2101,29 @@ export class PersistentRenderScene {
1615
2101
  if (requiresRebuild) {
1616
2102
  return this.rebuildComposition(worlds, owner, catalogEpoch, buildCandidateFrame, leases);
1617
2103
  }
1618
- if (transformSpans.length === 0) {
2104
+ if (transformSpans.length === 0 && changedDelta === undefined) {
1619
2105
  this.noChangeFrames += 1;
1620
2106
  this.syncGpuOwner(entry.token, entry.projection);
1621
- return this.deriveFramePlan(entry, buildCandidateFrame);
2107
+ const result = this.deriveFramePlan(entry, buildCandidateFrame);
2108
+ for (let worldId = 0; worldId < nextReadVersions.length; worldId += 1) {
2109
+ const version = nextReadVersions[worldId];
2110
+ if (version !== undefined) entry.readVersions[worldId] = version;
2111
+ }
2112
+ return result;
1622
2113
  }
1623
- const delta = entry.projection.applyTransformSpans(transformSpans);
1624
- entry.topology.apply(delta);
1625
- this.syncGpuOwner(entry.token, entry.projection, delta);
1626
- this.deltaFrames += 1;
1627
- this.transformUpdates += delta.updated;
1628
- return this.deriveFramePlan(entry, buildCandidateFrame);
2114
+ if (transformSpans.length > 0) {
2115
+ const delta = entry.projection.applyTransformSpans(transformSpans);
2116
+ entry.topology.apply(topologyDeltaForAvailable(delta, entry.unavailableSlots, []));
2117
+ this.syncGpuOwner(entry.token, entry.projection, delta);
2118
+ this.deltaFrames += 1;
2119
+ this.transformUpdates += delta.updated;
2120
+ }
2121
+ const result = this.deriveFramePlan(entry, buildCandidateFrame);
2122
+ for (let worldId = 0; worldId < nextReadVersions.length; worldId += 1) {
2123
+ const version = nextReadVersions[worldId];
2124
+ if (version !== undefined) entry.readVersions[worldId] = version;
2125
+ }
2126
+ return result;
1629
2127
  }
1630
2128
 
1631
2129
  invalidate(): void {
@@ -1651,6 +2149,16 @@ export class PersistentRenderScene {
1651
2149
  const detachedComposition = this.composition?.worlds.includes(world)
1652
2150
  ? this.composition
1653
2151
  : undefined;
2152
+ if (detachedComposition !== undefined) {
2153
+ for (const slot of detachedComposition.projection.slotsSnapshot()) {
2154
+ if (detachedComposition.worlds[slot.worldId] !== world) continue;
2155
+ this.options.instanceCollections?.release(world, slot.entityKey);
2156
+ if (slot.snapshot.skin === undefined) continue;
2157
+ this.options
2158
+ .getSkinPaletteAllocator?.()
2159
+ ?.releaseSliceFor(`${world.identity}:${slot.entityKey}`);
2160
+ }
2161
+ }
1654
2162
  if (detachedComposition !== undefined) this.composition = undefined;
1655
2163
  if (detachedComposition !== undefined) this.temporalCapture = undefined;
1656
2164
  if (detachedComposition !== undefined) {
@@ -1904,7 +2412,7 @@ export class PersistentRenderScene {
1904
2412
  worlds: readonly World[],
1905
2413
  owner: { readonly cameraOwner: number; readonly resourceOwner: number },
1906
2414
  catalogEpoch: number,
1907
- buildCandidateFrame: (renderables: 'full' | 'none') => ExtractedFrame,
2415
+ buildCandidateFrame: (request: PersistentRenderCandidateRequest) => ExtractedFrame,
1908
2416
  leases?: readonly RenderReadLease[],
1909
2417
  preserveVisibilityOnReorder = false,
1910
2418
  ): ExtractedFrame {
@@ -1974,6 +2482,10 @@ export class PersistentRenderScene {
1974
2482
  transmissionDemand.apply(transmissionDemandOperations(candidateFrame.renderables));
1975
2483
  const topology = new BatchTopology();
1976
2484
  topology.rebuild(projection.slotsSnapshot());
2485
+ const skinConsumersByJoint = new Map<string, Set<number>>();
2486
+ for (const snapshot of stableCandidateFrame.renderables) {
2487
+ if (snapshot.skinJointEntities !== undefined) addSkinConsumer(skinConsumersByJoint, snapshot);
2488
+ }
1977
2489
  const token = {};
1978
2490
  const entry: PersistentCompositionEntry = {
1979
2491
  token,
@@ -2003,8 +2515,17 @@ export class PersistentRenderScene {
2003
2515
  leases === undefined || leases.length !== worlds.length
2004
2516
  ? []
2005
2517
  : worlds.map(createGlobalTransformChangeQuery),
2518
+ renderChangeQueries:
2519
+ leases === undefined || leases.length !== worlds.length
2520
+ ? []
2521
+ : worlds.map(createRenderChangeQueries),
2522
+ dispatchBySlot: dispatchEntriesBySlot(stableCandidateFrame, projection),
2523
+ dispatchRevision: 0,
2524
+ dispatchCache: undefined,
2525
+ skinConsumersByJoint,
2526
+ pendingRenderableEntitiesByWorld: worlds.map(() => new Set<number>()),
2527
+ unavailableSlots: new Set<number>(),
2006
2528
  catalogEpoch,
2007
- rigidOnly: candidateFrame.renderables.every((renderable) => renderable.skin === undefined),
2008
2529
  probeProjection: new ProbeBlendSceneProjection(),
2009
2530
  temporalDemanded: frameDemandsTemporal(candidateFrame),
2010
2531
  gpuDrivenSceneRequired: true,
@@ -2064,10 +2585,23 @@ export class PersistentRenderScene {
2064
2585
  */
2065
2586
  private deriveFramePlan(
2066
2587
  entry: PersistentCompositionEntry,
2067
- buildCandidateFrame: (renderables: 'full' | 'none') => ExtractedFrame,
2588
+ buildCandidateFrame: (request: PersistentRenderCandidateRequest) => ExtractedFrame,
2068
2589
  ): ExtractedFrame {
2069
2590
  const resourceFrame = buildCandidateFrame('none');
2070
- const candidateFrame: ExtractedFrame = { ...resourceFrame, ...entry.structure };
2591
+ const cachedDispatch = entry.dispatchCache;
2592
+ const dispatch: DispatchEntry[] = [
2593
+ ...(cachedDispatch?.revision === entry.dispatchRevision
2594
+ ? cachedDispatch.value
2595
+ : dispatchForProjection(entry.dispatchBySlot, entry.projection, entry.unavailableSlots)),
2596
+ ];
2597
+ if (cachedDispatch?.revision !== entry.dispatchRevision) {
2598
+ entry.dispatchCache = { revision: entry.dispatchRevision, value: dispatch };
2599
+ }
2600
+ const candidateFrame: ExtractedFrame = {
2601
+ ...resourceFrame,
2602
+ ...entry.structure,
2603
+ dispatch,
2604
+ };
2071
2605
  entry.temporalDemanded = frameDemandsTemporal(candidateFrame);
2072
2606
  entry.projection.setTemporalTracking(entry.temporalDemanded);
2073
2607
  this.projectProbes(entry, candidateFrame);
@@ -2076,9 +2610,18 @@ export class PersistentRenderScene {
2076
2610
  const candidates = entry.temporalDemanded
2077
2611
  ? materialized.map((snapshot) => entry.projection.withTemporal(snapshot))
2078
2612
  : materialized;
2613
+ const drawableCandidates =
2614
+ entry.unavailableSlots.size === 0
2615
+ ? candidates
2616
+ : candidates.map((snapshot) => {
2617
+ const slot = entry.projection.slot(snapshot.worldId, snapshot.entityKey);
2618
+ return slot !== undefined && entry.unavailableSlots.has(slot.slot)
2619
+ ? { ...snapshot, authorVisible: false }
2620
+ : snapshot;
2621
+ });
2079
2622
  const frameWithProbes = attachProbeRecords(
2080
- { ...candidateFrame, renderables: candidates },
2081
- candidates,
2623
+ { ...candidateFrame, renderables: drawableCandidates },
2624
+ drawableCandidates,
2082
2625
  entry.projection.slotsSnapshot(),
2083
2626
  entry.probeProjection,
2084
2627
  );