@forgeax/engine-render 0.1.34 → 0.1.35

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 (243) hide show
  1. package/README.md +52 -3
  2. package/dist/assembly/device-feature-admission.d.ts +2 -2
  3. package/dist/assembly/host-contract.d.ts +3 -2
  4. package/dist/assembly/host-contract.d.ts.map +1 -1
  5. package/dist/assembly/recovery/renderer-recover.d.ts.map +1 -1
  6. package/dist/assembly/render-target-host.d.ts +5 -0
  7. package/dist/assembly/render-target-host.d.ts.map +1 -1
  8. package/dist/assembly/renderer-facade.d.ts.map +1 -1
  9. package/dist/assembly/webgpu-renderer.d.ts +1 -0
  10. package/dist/assembly/webgpu-renderer.d.ts.map +1 -1
  11. package/dist/authoring.mjs +2 -4
  12. package/dist/authoring.mjs.map +1 -1
  13. package/dist/chunk-3XUBWRY2.mjs +1842 -0
  14. package/dist/chunk-3XUBWRY2.mjs.map +1 -0
  15. package/dist/{chunk-UI5SFODX.mjs → chunk-G6HDNGY7.mjs} +2329 -1776
  16. package/dist/chunk-G6HDNGY7.mjs.map +1 -0
  17. package/dist/{chunk-HEB564WF.mjs → chunk-LM5IE7GF.mjs} +4 -4
  18. package/dist/{chunk-HEB564WF.mjs.map → chunk-LM5IE7GF.mjs.map} +1 -1
  19. package/dist/{chunk-MKDIT2EM.mjs → chunk-LNIUIKQ7.mjs} +143 -246
  20. package/dist/chunk-LNIUIKQ7.mjs.map +1 -0
  21. package/dist/chunk-MXTWKZO4.mjs +3268 -0
  22. package/dist/chunk-MXTWKZO4.mjs.map +1 -0
  23. package/dist/{chunk-ODZWR6JQ.mjs → chunk-MZ4C7EHF.mjs} +2042 -27
  24. package/dist/chunk-MZ4C7EHF.mjs.map +1 -0
  25. package/dist/chunk-RIPAEGI6.mjs +48 -0
  26. package/dist/chunk-RIPAEGI6.mjs.map +1 -0
  27. package/dist/{chunk-OCDLEI6I.mjs → chunk-SGPUJW3F.mjs} +117 -5
  28. package/dist/chunk-SGPUJW3F.mjs.map +1 -0
  29. package/dist/{chunk-ELJFJ2BU.mjs → chunk-X2KA6WHM.mjs} +29 -4
  30. package/dist/chunk-X2KA6WHM.mjs.map +1 -0
  31. package/dist/{chunk-BLXGONR2.mjs → chunk-XSFB7XXQ.mjs} +51 -4
  32. package/dist/chunk-XSFB7XXQ.mjs.map +1 -0
  33. package/dist/cloud/frame-context.d.ts +2 -2
  34. package/dist/cloud/frame-context.d.ts.map +1 -1
  35. package/dist/construct-renderer.mjs +810 -2073
  36. package/dist/construct-renderer.mjs.map +1 -1
  37. package/dist/device/gpu-residency.d.ts +19 -19
  38. package/dist/device/gpu-residency.d.ts.map +1 -1
  39. package/dist/errors/render.d.ts +3 -2
  40. package/dist/errors/render.d.ts.map +1 -1
  41. package/dist/extract/material-context.d.ts +7 -0
  42. package/dist/extract/material-context.d.ts.map +1 -0
  43. package/dist/features/host.d.ts +5 -0
  44. package/dist/features/host.d.ts.map +1 -1
  45. package/dist/features/plan.d.ts +2 -0
  46. package/dist/features/plan.d.ts.map +1 -1
  47. package/dist/features/types.d.ts +4 -0
  48. package/dist/features/types.d.ts.map +1 -1
  49. package/dist/glyph-text-layout-system.d.ts +1 -1
  50. package/dist/glyph-text-layout-system.d.ts.map +1 -1
  51. package/dist/gpu-driven/shadow-ownership.d.ts +3 -3
  52. package/dist/gpu-driven/shadow-ownership.d.ts.map +1 -1
  53. package/dist/ibl/frame-resources.d.ts.map +1 -1
  54. package/dist/ibl/skylight-bind-group.d.ts +4 -4
  55. package/dist/ibl/skylight-bind-group.d.ts.map +1 -1
  56. package/dist/index.d.ts +3 -0
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.mjs +530 -9
  59. package/dist/index.mjs.map +1 -1
  60. package/dist/instances.d.ts +3 -4
  61. package/dist/instances.d.ts.map +1 -1
  62. package/dist/internal.mjs +7 -8
  63. package/dist/internal.mjs.map +1 -1
  64. package/dist/pbr-pipeline.d.ts +3 -3
  65. package/dist/pbr-pipeline.d.ts.map +1 -1
  66. package/dist/pipeline/standard-output/lut-gpu.d.ts +2 -2
  67. package/dist/pipeline/standard-output/lut-gpu.d.ts.map +1 -1
  68. package/dist/pipeline/standard-output/resources.d.ts +2 -2
  69. package/dist/pipeline/standard-output/resources.d.ts.map +1 -1
  70. package/dist/publication/contract.d.ts +77 -0
  71. package/dist/publication/contract.d.ts.map +1 -0
  72. package/dist/publication/dependencies.d.ts +7 -0
  73. package/dist/publication/dependencies.d.ts.map +1 -0
  74. package/dist/publication/prepare-geometry.d.ts +9 -0
  75. package/dist/publication/prepare-geometry.d.ts.map +1 -0
  76. package/dist/publication/programs.d.ts +5 -0
  77. package/dist/publication/programs.d.ts.map +1 -0
  78. package/dist/publication/publisher.d.ts +33 -0
  79. package/dist/publication/publisher.d.ts.map +1 -0
  80. package/dist/publication/receiver.d.ts +29 -0
  81. package/dist/publication/receiver.d.ts.map +1 -0
  82. package/dist/publication/resource-scope.d.ts +18 -0
  83. package/dist/publication/resource-scope.d.ts.map +1 -0
  84. package/dist/publication/targets.d.ts +33 -0
  85. package/dist/publication/targets.d.ts.map +1 -0
  86. package/dist/publication/video.d.ts +5 -0
  87. package/dist/publication/video.d.ts.map +1 -0
  88. package/dist/record/fold-buckets.d.ts +3 -3
  89. package/dist/record/fold-buckets.d.ts.map +1 -1
  90. package/dist/record/frame-snapshot.d.ts +6 -6
  91. package/dist/record/frame-snapshot.d.ts.map +1 -1
  92. package/dist/record/frame.d.ts +5 -4
  93. package/dist/record/frame.d.ts.map +1 -1
  94. package/dist/record/helpers.d.ts +2 -2
  95. package/dist/record/helpers.d.ts.map +1 -1
  96. package/dist/record/main-pass-geometry.d.ts +2 -2
  97. package/dist/record/main-pass-geometry.d.ts.map +1 -1
  98. package/dist/record/main-pass-material.d.ts +7 -9
  99. package/dist/record/main-pass-material.d.ts.map +1 -1
  100. package/dist/record/main-pass-sprite-draws.d.ts +3 -16
  101. package/dist/record/main-pass-sprite-draws.d.ts.map +1 -1
  102. package/dist/record/main-pass.d.ts.map +1 -1
  103. package/dist/record/prepared-material-bindings.d.ts +3 -3
  104. package/dist/record/prepared-material-bindings.d.ts.map +1 -1
  105. package/dist/record/render-context.d.ts +2 -2
  106. package/dist/record/render-context.d.ts.map +1 -1
  107. package/dist/record/sprite-instance-buffer.d.ts +31 -0
  108. package/dist/record/sprite-instance-buffer.d.ts.map +1 -0
  109. package/dist/record/target-capture-graph.d.ts +19 -0
  110. package/dist/record/target-capture-graph.d.ts.map +1 -0
  111. package/dist/record/typed-frame-graph.d.ts +2 -13
  112. package/dist/record/typed-frame-graph.d.ts.map +1 -1
  113. package/dist/record/view-ubo.d.ts.map +1 -1
  114. package/dist/record/volume-params.d.ts +2 -2
  115. package/dist/record/volume-params.d.ts.map +1 -1
  116. package/dist/recovery/render-system-candidate.d.ts +5 -5
  117. package/dist/recovery/render-system-candidate.d.ts.map +1 -1
  118. package/dist/render-contract.d.ts +4 -1
  119. package/dist/render-contract.d.ts.map +1 -1
  120. package/dist/render-system-extract-tail.d.ts.map +1 -1
  121. package/dist/render-system-extract.d.ts +2 -0
  122. package/dist/render-system-extract.d.ts.map +1 -1
  123. package/dist/render-system-presentation.d.ts +2 -2
  124. package/dist/render-system-presentation.d.ts.map +1 -1
  125. package/dist/render-system-projections.d.ts +3 -2
  126. package/dist/render-system-projections.d.ts.map +1 -1
  127. package/dist/render-system-residency.d.ts +2 -2
  128. package/dist/render-system-residency.d.ts.map +1 -1
  129. package/dist/render-system.d.ts +3 -7
  130. package/dist/render-system.d.ts.map +1 -1
  131. package/dist/scene/render-scene.d.ts +8 -4
  132. package/dist/scene/render-scene.d.ts.map +1 -1
  133. package/dist/scene/render-source.d.ts +42 -0
  134. package/dist/scene/render-source.d.ts.map +1 -0
  135. package/dist/scene/source-systems.d.ts +6 -0
  136. package/dist/scene/source-systems.d.ts.map +1 -0
  137. package/dist/systems/skin-palette-allocator.d.ts.map +1 -1
  138. package/dist/systems/skin-palette-types.d.ts +10 -0
  139. package/dist/systems/skin-palette-types.d.ts.map +1 -1
  140. package/dist/systems/transparent-dispatch.d.ts +2 -2
  141. package/dist/systems/transparent-dispatch.d.ts.map +1 -1
  142. package/dist/systems/transparent-sort-config.d.ts +2 -1
  143. package/dist/systems/transparent-sort-config.d.ts.map +1 -1
  144. package/dist/temporal/index.mjs +6 -7
  145. package/package.json +21 -21
  146. package/src/__tests__/auto-exposure-transaction.integration.test.ts +4 -9
  147. package/src/__tests__/device-feature-admission.unit.test.ts +3 -2
  148. package/src/__tests__/gpu-driven-pbr.dawn.test.ts +3 -4
  149. package/src/__tests__/ktx2-basis-gpu-consumer.dawn.test.ts +4 -1
  150. package/src/__tests__/main-pass-material-medium-selection.unit.test.ts +0 -1
  151. package/src/__tests__/materials-standard-contract.unit.test.ts +0 -1
  152. package/src/__tests__/physical-clearcoat.integration.test.ts +23 -23
  153. package/src/__tests__/physical-material-budget.unit.test.ts +40 -0
  154. package/src/__tests__/render-error-code-owner.test-d.ts +1 -0
  155. package/src/__tests__/renderer-factory-material-contract.unit.test.ts +1 -1
  156. package/src/assembly/device-feature-admission.ts +2 -2
  157. package/src/assembly/host-contract.ts +5 -2
  158. package/src/assembly/recovery/renderer-recover.ts +9 -0
  159. package/src/assembly/render-target-host.ts +57 -47
  160. package/src/assembly/renderer-facade.ts +3 -1
  161. package/src/assembly/webgpu-ready.ts +1 -1
  162. package/src/assembly/webgpu-renderer.ts +89 -61
  163. package/src/cloud/frame-context.ts +2 -2
  164. package/src/device/gpu-residency.ts +26 -25
  165. package/src/errors/render.ts +3 -1
  166. package/src/extract/material-context.ts +21 -0
  167. package/src/features/host.ts +13 -5
  168. package/src/features/plan.ts +2 -0
  169. package/src/features/types.ts +4 -0
  170. package/src/glyph-text-layout-system.ts +2 -2
  171. package/src/gpu-driven/shadow-ownership.ts +3 -3
  172. package/src/ibl/frame-resources.ts +0 -1
  173. package/src/ibl/skylight-bind-group.ts +13 -25
  174. package/src/index.ts +9 -0
  175. package/src/instances.ts +9 -6
  176. package/src/pbr-pipeline.ts +11 -17
  177. package/src/pipeline/standard-output/lut-gpu.ts +3 -3
  178. package/src/pipeline/standard-output/resources.ts +2 -2
  179. package/src/publication/__tests__/derived-geometry.integration.test.ts +177 -0
  180. package/src/publication/__tests__/features.integration.test.ts +122 -0
  181. package/src/publication/__tests__/programs.unit.test.ts +25 -0
  182. package/src/publication/__tests__/publication.integration.test.ts +370 -0
  183. package/src/publication/__tests__/targets.integration.test.ts +120 -0
  184. package/src/publication/__tests__/video.browser.test.ts +50 -0
  185. package/src/publication/contract.ts +104 -0
  186. package/src/publication/dependencies.ts +54 -0
  187. package/src/publication/prepare-geometry.ts +94 -0
  188. package/src/publication/programs.ts +45 -0
  189. package/src/publication/publisher.ts +618 -0
  190. package/src/publication/receiver.ts +297 -0
  191. package/src/publication/resource-scope.ts +31 -0
  192. package/src/publication/targets.ts +182 -0
  193. package/src/publication/video.ts +33 -0
  194. package/src/record/__tests__/material-bg-cache.unit.test.ts +15 -1
  195. package/src/record/__tests__/sprite-material-abi.unit.test.ts +8 -10
  196. package/src/record/__tests__/video-texture-view.unit.test.ts +9 -9
  197. package/src/record/fold-buckets.ts +8 -5
  198. package/src/record/frame-snapshot.ts +6 -6
  199. package/src/record/frame.ts +42 -22
  200. package/src/record/helpers.ts +2 -2
  201. package/src/record/main-pass-geometry.ts +21 -2
  202. package/src/record/main-pass-material.ts +37 -33
  203. package/src/record/main-pass-sprite-draws.ts +6 -152
  204. package/src/record/main-pass.ts +8 -7
  205. package/src/record/prepared-material-bindings.ts +11 -6
  206. package/src/record/render-context.ts +2 -2
  207. package/src/record/shadow-pass.ts +0 -3
  208. package/src/record/sprite-instance-buffer.ts +155 -0
  209. package/src/record/target-capture-graph.ts +258 -0
  210. package/src/record/typed-frame-graph.ts +6 -206
  211. package/src/record/view-ubo.ts +1 -1
  212. package/src/record/volume-params.ts +6 -6
  213. package/src/recovery/render-system-candidate.ts +10 -10
  214. package/src/render-contract.ts +6 -1
  215. package/src/render-system-extract-tail.ts +33 -17
  216. package/src/render-system-extract.ts +2 -0
  217. package/src/render-system-presentation.ts +2 -2
  218. package/src/render-system-projections.ts +3 -2
  219. package/src/render-system-residency.ts +2 -2
  220. package/src/render-system.ts +82 -53
  221. package/src/scene/render-scene.ts +110 -100
  222. package/src/scene/render-source.ts +90 -0
  223. package/src/scene/source-systems.ts +33 -0
  224. package/src/systems/skin-palette-allocator.ts +1 -0
  225. package/src/systems/skin-palette-types.ts +11 -0
  226. package/src/systems/transparent-dispatch.ts +2 -2
  227. package/src/systems/transparent-sort-config.ts +3 -1
  228. package/dist/chunk-BLXGONR2.mjs.map +0 -1
  229. package/dist/chunk-ELJFJ2BU.mjs.map +0 -1
  230. package/dist/chunk-ILQ5MEZF.mjs +0 -32
  231. package/dist/chunk-ILQ5MEZF.mjs.map +0 -1
  232. package/dist/chunk-MKDIT2EM.mjs.map +0 -1
  233. package/dist/chunk-OCDLEI6I.mjs.map +0 -1
  234. package/dist/chunk-ODZWR6JQ.mjs.map +0 -1
  235. package/dist/chunk-RXZ3RGOU.mjs +0 -53
  236. package/dist/chunk-RXZ3RGOU.mjs.map +0 -1
  237. package/dist/chunk-SHFUFJ5N.mjs +0 -1781
  238. package/dist/chunk-SHFUFJ5N.mjs.map +0 -1
  239. package/dist/chunk-UI5SFODX.mjs.map +0 -1
  240. package/dist/chunk-UINHLCZA.mjs +0 -2389
  241. package/dist/chunk-UINHLCZA.mjs.map +0 -1
  242. package/dist/chunk-WEX45HKH.mjs +0 -1524
  243. package/dist/chunk-WEX45HKH.mjs.map +0 -1
@@ -1,3 +1,4 @@
1
+ import type { RenderPublicationError } from '../publication/contract';
1
2
  // @forgeax/engine-render -- render cluster error classes.
2
3
  //
3
4
  // feat-20260704-runtime-tier1-decomposition M2 / w8 (D-3): the monolithic
@@ -562,7 +563,7 @@ export class PointShadowAtlasBoundsViolationError extends Error {
562
563
  * Fired by the per-frame record stage (`videoTextureView`) when a VideoPlayer
563
564
  * entity can reach neither video upload path this frame: the general
564
565
  * `copyExternalImageToTexture` path (no host HTMLVideoElement resolved via
565
- * `VideoElementProvider`) AND the high-perf `GPUExternalTexture` path
566
+ * `VideoSourceProvider`) AND the high-perf `GPUExternalTexture` path
566
567
  * (capability absent). The engine surfaces this explicit failure rather than
567
568
  * silently rendering a stale/garbage texture (charter P3, plan-strategy D-6).
568
569
  * - `.code = 'video-upload-unsupported'`
@@ -1980,6 +1981,7 @@ export type RenderErrorCode = RenderError['code'];
1980
1981
  * `RenderErrorCode` discriminant on `.code`.
1981
1982
  */
1982
1983
  export type RenderError =
1984
+ | RenderPublicationError
1983
1985
  | LifecycleConstructionError
1984
1986
  | AutoExposureError
1985
1987
  | RendererExpectedOperationError
@@ -0,0 +1,21 @@
1
+ import type { MaterialCookProgramContext } from '@forgeax/engine-pack/material-cook';
2
+ import type { RhiCaps } from '@forgeax/engine-rhi';
3
+
4
+ /** The receiving device selects the immutable material program domain. */
5
+ export function renderMaterialContext(caps: Pick<RhiCaps, 'backendKind' | 'storageBuffer'>): {
6
+ readonly materialContext?: MaterialCookProgramContext;
7
+ } {
8
+ if (caps.backendKind === 'null') return {};
9
+ return {
10
+ materialContext: {
11
+ backend: caps.backendKind === 'wgpu-webgl2' ? 'webgl2' : caps.backendKind,
12
+ capability: caps.storageBuffer ? 'storage-buffer' : 'uniform-fallback',
13
+ pipeline: 'forward',
14
+ geometry: 'mesh',
15
+ pass: 'forward',
16
+ profile: 'forgeax-material-wgsl-v1',
17
+ toolchain: 'naga-oil',
18
+ instrumentation: 'none',
19
+ },
20
+ };
21
+ }
@@ -133,6 +133,8 @@ export interface RenderFeatureStageEvent {
133
133
  }
134
134
 
135
135
  export interface RenderFeatureFrameInput {
136
+ readonly publishedFeatures?: readonly { readonly identity: string; readonly data: unknown }[];
137
+ readonly onFeatureSourceSubmitted?: (identity: string, feedback: unknown) => void;
136
138
  readonly worlds: readonly import('@forgeax/engine-ecs').World[];
137
139
  readonly owner: number;
138
140
  readonly frameNumber: number;
@@ -1454,7 +1456,10 @@ export function runRenderFeatureFrame(
1454
1456
  const extracted = invokeStage(
1455
1457
  slot,
1456
1458
  'extract',
1457
- () => slot.feature.extract(extractContext),
1459
+ () => {
1460
+ const published = input.publishedFeatures?.find((row) => row.identity === identity);
1461
+ return published === undefined ? slot.feature.extract(extractContext) : ok(published.data);
1462
+ },
1458
1463
  stageEvents,
1459
1464
  errors,
1460
1465
  );
@@ -1655,11 +1660,14 @@ export function runRenderFeatureFrame(
1655
1660
  continue;
1656
1661
  }
1657
1662
  planExecutionProjections.set(plannedFrame, projected.value);
1658
- if (feature.onFrameSubmitted !== undefined) {
1663
+ if (feature.onFrameSubmitted !== undefined || feature.onSourceFrameSubmitted !== undefined) {
1659
1664
  submissionSensitivePlans.add(plannedFrame);
1660
- submissionAcknowledgments.set(plannedFrame, () =>
1661
- feature.onFrameSubmitted?.(extracted.value, projected.value),
1662
- );
1665
+ submissionAcknowledgments.set(plannedFrame, () => {
1666
+ feature.onFrameSubmitted?.(extracted.value, projected.value);
1667
+ if (input.publishedFeatures?.some((row) => row.identity === identity))
1668
+ input.onFeatureSourceSubmitted?.(identity, declared.value.sourceFeedback);
1669
+ else feature.onSourceFrameSubmitted?.(extracted.value, declared.value.sourceFeedback);
1670
+ });
1663
1671
  }
1664
1672
  plans.push(plannedFrame);
1665
1673
  if (feature.onFrameAborted !== undefined) {
@@ -237,6 +237,8 @@ export type RenderFeaturePassDeclaration =
237
237
  * access is derived from these roles rather than duplicated as reads/writes.
238
238
  */
239
239
  export interface RenderFeaturePlan {
240
+ /** Source acknowledgment facts; never part of GPU resource or graph identity. */
241
+ readonly sourceFeedback?: unknown;
240
242
  readonly resources: readonly RenderFeatureResourceDeclaration[];
241
243
  readonly passes: readonly RenderFeaturePassDeclaration[];
242
244
  }
@@ -171,6 +171,8 @@ export interface RenderFeature<FrameData> {
171
171
  readonly source: string;
172
172
  }[];
173
173
  extract(context: RenderFeatureExtractContext): Result<FrameData, RenderError>;
174
+ /** GUID roots needed by the declarative plan; the publisher carries their asset closure. */
175
+ assetDependencies?(data: FrameData): readonly string[];
174
176
  plan(data: FrameData, context: RenderFeaturePlanContext): Result<RenderFeaturePlan, RenderError>;
175
177
  /**
176
178
  * Consume frame-owned producer state only after the compiled graph reached
@@ -178,6 +180,8 @@ export interface RenderFeature<FrameData> {
178
180
  * extracted frame and never during declarative planning.
179
181
  */
180
182
  onFrameSubmitted?(data: FrameData, submission?: RenderFeatureSubmission): void;
183
+ /** Consume source-owned intents after submission; feedback must be structured-cloneable. */
184
+ onSourceFrameSubmitted?(data: FrameData, feedback: unknown): void;
181
185
  /**
182
186
  * Discard frame-owned producer state when graph admission or submission is
183
187
  * rejected. This is the symmetric recovery path for onFrameSubmitted.
@@ -158,7 +158,7 @@ interface GlyphTextData {
158
158
  */
159
159
  export function glyphTextLayoutSystem(
160
160
  world: World,
161
- gpuStore: GpuResidencyCache,
161
+ gpuStore: Pick<GpuResidencyCache, 'updateMesh'>,
162
162
  ): Result<void, TextError> {
163
163
  resetFontConcurrency();
164
164
 
@@ -200,7 +200,7 @@ function collectGlyphEntities(world: World): EntityHandle[] {
200
200
  */
201
201
  function processEntity(
202
202
  world: World,
203
- gpuStore: GpuResidencyCache,
203
+ gpuStore: Pick<GpuResidencyCache, 'updateMesh'>,
204
204
  entity: EntityHandle,
205
205
  ): TextError | null {
206
206
  const gtRes = world.get(entity, GlyphText);
@@ -1,5 +1,5 @@
1
- import type { World } from '@forgeax/engine-ecs';
2
1
  import { gpuDrivenShadowDrawKey } from '../extract/gpu-driven';
2
+ import type { RenderResourceScope } from '../publication/resource-scope';
3
3
  import { worldEntityKey } from '../record/frame-snapshot';
4
4
  import type { ShadowCasterMembership } from '../render-system-extract';
5
5
 
@@ -7,12 +7,12 @@ const WORLD_ENTITY_KEY_STRIDE = 4294967296;
7
7
 
8
8
  /**
9
9
  * Extract emits world/entity keys in the transient `worlds[]` index domain,
10
- * while PersistentRenderScene owns stable renderer-local World keys for the
10
+ * while PersistentRenderScene owns stable renderer-local RenderResourceScope keys for the
11
11
  * retained GPU scene. Rebase ShadowCaster ownership at this orchestration
12
12
  * boundary before both GPU projection and CPU residual recording consume it.
13
13
  */
14
14
  export function rebaseShadowCasterOwnership(
15
- worlds: readonly World[],
15
+ worlds: readonly RenderResourceScope[],
16
16
  stableWorldKeys: readonly number[],
17
17
  entityKeys: ReadonlySet<number>,
18
18
  drawKeys: ReadonlySet<string>,
@@ -32,7 +32,6 @@ export function resolveFrameSkylightResources(
32
32
  prefilterView: resident?.prefilterView ?? fallback.prefilterView,
33
33
  prefilterSampler: fallback.sampler,
34
34
  brdfLutView: resident?.brdfLutView ?? fallback.brdfLutView,
35
- brdfLutSampler: fallback.sampler,
36
35
  intensityBuffer: fallback.intensityBuffer,
37
36
  });
38
37
  }
@@ -1,20 +1,20 @@
1
1
  // skylight-bind-group.ts -- Skylight resources merged into @group(1) PBR
2
- // material BGL (binding 7..13) + fallback identity resource bundle. The
2
+ // material BGL (binding 7..12) + fallback identity resource bundle. The
3
3
  // complete Standard material group appends transmission entries.
4
4
  //
5
5
  // Plan-strategy D-5 (round-4 REVISED): the round-2 stand-alone @group(4)
6
6
  // Skylight BindGroupLayout collided with WebGPU's default maxBindGroups=4
7
7
  // limit, blocking pbr-pl pipeline-layout creation in chrome-beta. Round-4
8
- // rewrites the contract: Skylight 7 entries are **appended** to the
9
- // existing PBR material BindGroupLayout at bindings 7..13; pipeline layout
8
+ // rewrites the contract: Skylight 6 entries are **appended** to the
9
+ // existing PBR material BindGroupLayout at bindings 7..12; pipeline layout
10
10
  // stays at 4 slots `[view, material, mesh, instances]`; unlit pipeline is
11
11
  // untouched (it uses its own material BGL).
12
12
  //
13
13
  // Surface (M2 round-4 / t40 amend):
14
14
  // - mergeSkylightIntoMaterialBgl(materialBglEntries): given the existing
15
15
  // 7 PBR material BindGroupLayout entries (binding 0..6), returns the
16
- // merged 14-entry array with Skylight resources at binding 7..13
17
- // [irrTex, irrSampler, prefTex, prefSampler, brdfTex, brdfSampler,
16
+ // merged 13-entry array with Skylight resources at binding 7..12
17
+ // [irrTex, irrSampler, prefTex, prefSampler, brdfTex,
18
18
  // uniform { intensity: f32 }]. The merge is a pure function -- caller
19
19
  // (createRenderer) is responsible for passing the result to
20
20
  // `device.createBindGroupLayout`.
@@ -124,13 +124,13 @@ export interface SkylightQueue {
124
124
  */
125
125
  export interface SkylightBindGroupResources {
126
126
  readonly irradianceView: TextureView;
127
+ /** Shared by diffuse irradiance and the BRDF lookup table. */
127
128
  readonly irradianceSampler: Sampler;
128
129
  readonly prefilterView: TextureView;
129
130
  /** Present only when the per-draw prefilter slot was replaced by a local probe. */
130
131
  readonly skylightPrefilterView?: TextureView;
131
132
  readonly prefilterSampler: Sampler;
132
133
  readonly brdfLutView: TextureView;
133
- readonly brdfLutSampler: Sampler;
134
134
  readonly intensityBuffer: Buffer;
135
135
  }
136
136
 
@@ -143,7 +143,7 @@ export interface SkylightBindGroupResources {
143
143
  *
144
144
  * No stand-alone `bindGroup` field: the round-2 stand-alone Skylight BG
145
145
  * is gone (D-5 round-4); the fallback resources flow into the PBR
146
- * material BG at binding 7..13.
146
+ * material BG at binding 7..12.
147
147
  */
148
148
  export interface SkylightFallback {
149
149
  readonly irradianceTexture: Texture;
@@ -159,7 +159,7 @@ export interface SkylightFallback {
159
159
  // ─── Pure merger: BindGroupLayout entries (D-5 round-4) ─────────────────────
160
160
 
161
161
  /**
162
- * Append the 7 Skylight BindGroupLayout entries after the existing PBR
162
+ * Append the 6 Skylight BindGroupLayout entries after the existing PBR
163
163
  * user-region entries. Pure function -- the
164
164
  * caller passes the result to `device.createBindGroupLayout`. Charter P4
165
165
  * consistent abstraction: one BGL holds material + Skylight together,
@@ -208,16 +208,10 @@ export function mergeSkylightIntoMaterialBgl(
208
208
  visibility: GPU_SHADER_STAGE_FRAGMENT,
209
209
  texture: { sampleType: 'float', viewDimension: '2d' },
210
210
  } as BglEntry);
211
- // binding 12: brdfLutSampler
211
+ // binding 12: uniform { intensity: f32 }
212
212
  merged.push({
213
213
  binding: skylightBindingStart + 5,
214
214
  visibility: GPU_SHADER_STAGE_FRAGMENT,
215
- sampler: { type: 'filtering' },
216
- } as BglEntry);
217
- // binding 13: uniform { intensity: f32 }
218
- merged.push({
219
- binding: skylightBindingStart + 6,
220
- visibility: GPU_SHADER_STAGE_FRAGMENT,
221
215
  buffer: { type: 'uniform' },
222
216
  } as BglEntry);
223
217
  return merged;
@@ -226,7 +220,7 @@ export function mergeSkylightIntoMaterialBgl(
226
220
  // ─── Pure merger: BindGroupEntry values (assembly site) ─────────────────────
227
221
 
228
222
  /**
229
- * Append the 7 Skylight BindGroupEntry resources (binding 7..13) onto the
223
+ * Append the 6 Skylight BindGroupEntry resources (binding 7..12) onto the
230
224
  * existing PBR material BindGroupEntry values (binding 0..6). The caller
231
225
  * passes the result to `device.createBindGroup` as part of the merged
232
226
  * material BG.
@@ -301,10 +295,6 @@ export function assembleMaterialWithSkylightEntries(
301
295
  },
302
296
  {
303
297
  binding: iblStart + 5,
304
- resource: { kind: 'sampler', value: skylight.brdfLutSampler },
305
- },
306
- {
307
- binding: iblStart + 6,
308
298
  resource: { kind: 'buffer', value: { buffer: skylight.intensityBuffer } },
309
299
  },
310
300
  ];
@@ -413,11 +403,9 @@ export function createSkylightFallback(
413
403
  device: SkylightDevice,
414
404
  queue: SkylightQueue,
415
405
  ): SkylightFallback {
416
- // One sampler shared across the three texture slots. The PBR material
417
- // BindGroup still declares 3 separate sampler bindings (D-5 round-4
418
- // ordering at binding 8/10/12), so we pass the same handle three times
419
- // in assembleMaterialWithSkylightEntries; WebGPU spec permits sampler
420
- // reuse across BindGroupEntry slots.
406
+ // Diffuse IBL and the BRDF LUT share one binding; prefilter remains a
407
+ // separate binding so per-draw reflection probes can replace it. Both
408
+ // bindings use this linear-clamp sampler for the global environment.
421
409
  const samplerResult = device.createSampler({
422
410
  label: 'skylight-fallback-sampler',
423
411
  magFilter: 'linear',
package/src/index.ts CHANGED
@@ -560,6 +560,15 @@ export {
560
560
  type PointsStyleInput,
561
561
  } from './points-lines/admission.js';
562
562
  export type { PointsLinesInspection } from './points-lines/inspection.js';
563
+ export {
564
+ type PublishedRenderFrameInput,
565
+ type RenderPublication,
566
+ RenderPublicationError,
567
+ type RenderPublicationIdentity,
568
+ renderPublicationTransfers,
569
+ } from './publication/contract';
570
+ export { createRenderPublisher, type RenderPublicationCandidate } from './publication/publisher';
571
+ export { RenderPublicationTargetOwner, type RenderTargetAuthoring } from './publication/targets';
563
572
  /**
564
573
  * Public bounded GPU pass facts. Use the single `gpuPassTiming` opt-in,
565
574
  * `draw()` receipt, and `observe(receipt, { include: ['timings'] })` route.
package/src/instances.ts CHANGED
@@ -1,4 +1,3 @@
1
- import type { World } from '@forgeax/engine-ecs';
2
1
  import { InstanceTransformsStrideMismatchError } from '@forgeax/engine-ecs/projection';
3
2
  import type { RhiErrorCode } from '@forgeax/engine-rhi';
4
3
 
@@ -93,7 +92,7 @@ export function instanceUploadRangesForResident(
93
92
  }
94
93
 
95
94
  interface Projection extends InstanceCollectionSnapshot {
96
- readonly world: World;
95
+ readonly world: object;
97
96
  readonly entity: number;
98
97
  }
99
98
  interface Residency {
@@ -112,11 +111,15 @@ interface Residency {
112
111
  export class InstanceProjectionStore {
113
112
  private nextId = 1;
114
113
  private nextGeneration = 1;
115
- private identities = new WeakMap<World, Map<number, InstanceCollectionId>>();
114
+ private identities = new WeakMap<object, Map<number, InstanceCollectionId>>();
116
115
  private readonly records = new Map<InstanceCollectionId, Projection>();
117
116
  private readonly residency = new Map<InstanceCollectionId, Residency>();
118
117
 
119
- project(world: World, entity: number, transforms: ArrayLike<number>): InstanceCollectionSnapshot {
118
+ project(
119
+ world: object,
120
+ entity: number,
121
+ transforms: ArrayLike<number>,
122
+ ): InstanceCollectionSnapshot {
120
123
  const collectionId =
121
124
  this.identities.get(world)?.get(entity) ?? (this.nextId++ as InstanceCollectionId);
122
125
  const previous = this.records.get(collectionId);
@@ -147,7 +150,7 @@ export class InstanceProjectionStore {
147
150
 
148
151
  /** Called only after the owning RenderScene accepts its prepared changes. */
149
152
  accept(
150
- world: World,
153
+ world: object,
151
154
  entity: number,
152
155
  snapshot: {
153
156
  readonly collectionId?: InstanceCollectionId;
@@ -175,7 +178,7 @@ export class InstanceProjectionStore {
175
178
  });
176
179
  }
177
180
 
178
- release(world: World, entity: number): void {
181
+ release(world: object, entity: number): void {
179
182
  const entities = this.identities.get(world);
180
183
  const id = entities?.get(entity);
181
184
  if (id === undefined) return;
@@ -6,7 +6,7 @@
6
6
  // material BGL grows from the derived user region by appending the 7
7
7
  // Skylight resources via `mergeSkylightIntoMaterialBgl`. The
8
8
  // unlit pipeline keeps its 7-entry material BGL (no Skylight binding
9
- // 7..13 contamination) so unlit demos don't carry IBL state.
9
+ // 7..12 contamination) so unlit demos don't carry IBL state.
10
10
  // - charter P4: same pipeline layout shape drives Skylight present +
11
11
  // absent paths -- AI users do not branch on Skylight existence.
12
12
  // - feat-20260520-skylight-ibl-cubemap M4 / t59 (round-4): the
@@ -209,8 +209,8 @@ export function buildPbrMaterialUserRegionEntries(
209
209
  // (group(0) bindings 3..7); this kind is the seam for
210
210
  // each per-material shadow override surface a future feat
211
211
  // wires onto group(1).
212
- // - 'ibl' the 7 IBL / Skylight entries (irradiance / prefilter
213
- // cube + brdfLut 2d + 3 samplers + intensity uniform).
212
+ // - 'ibl' the 6 IBL / Skylight entries (irradiance / prefilter
213
+ // cube + brdfLut 2d + 2 samplers + intensity uniform).
214
214
  // Used by `buildPbrPipelineLayouts` after the user-region
215
215
  // user paramSchema entries are emitted.
216
216
  // - 'lightmap' the 4 emissive + occlusion entries (sampler + texture
@@ -224,7 +224,7 @@ export function buildPbrMaterialUserRegionEntries(
224
224
  // placeholders because their shaders do not read these slots.
225
225
  export type InjectionKind = keyof typeof INJECTION_KIND_LENGTHS;
226
226
 
227
- const IBL_INJECTION_LENGTH = 7;
227
+ const IBL_INJECTION_LENGTH = 6;
228
228
  const LIGHTMAP_INJECTION_LENGTH = 4;
229
229
  const SHADOW_INJECTION_LENGTH = 2;
230
230
  const TRANSMISSION_INJECTION_LENGTH = 2;
@@ -283,16 +283,10 @@ export function appendInjection(
283
283
  visibility: GPU_SHADER_STAGE_FRAGMENT,
284
284
  texture: { sampleType: 'float', viewDimension: '2d' },
285
285
  },
286
- // binding start+5: brdfLutSampler
286
+ // binding start+5: uniform { intensity: f32 }
287
287
  {
288
288
  binding: start + 5,
289
289
  visibility: GPU_SHADER_STAGE_FRAGMENT,
290
- sampler: { type: 'filtering' },
291
- },
292
- // binding start+6: uniform { intensity: f32 }
293
- {
294
- binding: start + 6,
295
- visibility: GPU_SHADER_STAGE_FRAGMENT,
296
290
  buffer: { type: 'uniform' },
297
291
  },
298
292
  ];
@@ -1119,13 +1113,13 @@ export function pbrSkinMeshDynamicOffsets(
1119
1113
 
1120
1114
  /**
1121
1115
  * Build a stand-alone 7-entry unlit material BGL. Round-4 D-5 keeps unlit
1122
- * material BG isolated from Skylight binding 7..13 -- unlit demos do not
1116
+ * material BG isolated from Skylight binding 7..12 -- unlit demos do not
1123
1117
  * pay for IBL state. The unlit pipeline still binds material at slot 1,
1124
1118
  * just with a 7-entry layout.
1125
1119
  *
1126
1120
  * Note: at the moment the runtime still routes both unlit + standard
1127
1121
  * through a single 14-entry pipeline layout; the unlit material BG
1128
- * carries fallback identity resources at binding 7..13. This factory is
1122
+ * carries fallback identity resources at binding 7..12. This factory is
1129
1123
  * exported as a future-proof seam for the moment when unlit demos own
1130
1124
  * their own pipeline layout (t57 (e) test pins the contract today so the
1131
1125
  * eventual split has a green target).
@@ -1241,7 +1235,7 @@ export const SPRITE_PASS_PER_INSTANCE_REGION_VARIANT_SET = '';
1241
1235
  *
1242
1236
  * Three groups by derivation source:
1243
1237
  * 1. Shader-derived (paramSchema reflection + injection chain):
1244
- * - `'pbr-material-merged'` — derived user region + Skylight 7 + transmission 2
1238
+ * - `'pbr-material-merged'` — derived user region + Skylight 6 + transmission 2
1245
1239
  * - `'unlit-material'` — 7 entries: base PBR material only (no inject)
1246
1240
  * - `'hdrp-7-slot'` — 7 entries (binding 0 + 3..8): HDRP cluster + SSAO group(2) BGL
1247
1241
  * 2. Caps-driven literal shapes (no shader):
@@ -1638,11 +1632,11 @@ export function buildBindGroupLayoutDescriptor(
1638
1632
  }
1639
1633
  case 'pbr-material-merged': {
1640
1634
  // Material BGL: per-shader user-region (derive(paramSchema).bglEntries)
1641
- // + IBL injection (7) + transmission injection (2). The user-region
1635
+ // + IBL injection (6) + transmission injection (2). The user-region
1642
1636
  // size is the only variable; injection start = userRegion.length so a
1643
1637
  // custom schema shifts IBL/transmission by its sampler/texture pairs.
1644
- // The built-in standard-PBR schema is 17 + 7 + 2 = 26 entries;
1645
- // clearcoat texture pairs are derived from the effective root schema.
1638
+ // The built-in standard-PBR user region has 15 entries;
1639
+ // scene rows follow injection, and physical texture pairs follow the effective root schema.
1646
1640
  //
1647
1641
  // Schema source priority (D-1): explicit materialParamSchema option >
1648
1642
  // registry lookup of spec.shader.id > built-in standard-PBR fallback.
@@ -1,6 +1,5 @@
1
1
  import type { AssetRegistry } from '@forgeax/engine-assets-runtime';
2
2
  import { resolveAssetHandle } from '@forgeax/engine-assets-runtime';
3
- import type { World } from '@forgeax/engine-ecs';
4
3
  import type { RenderGraphBuilder, RenderGraphError } from '@forgeax/engine-render-graph';
5
4
  import type {
6
5
  BindGroup,
@@ -13,6 +12,7 @@ import type {
13
12
  } from '@forgeax/engine-rhi';
14
13
  import { err, type Handle, ok, type Result, type TextureAsset } from '@forgeax/engine-types';
15
14
  import type { GpuResidencyCache, TextureGpuEntry } from '../../device/gpu-residency';
15
+ import type { RenderResourceScope } from '../../publication/resource-scope';
16
16
  import type { RenderPipelineFrame, RenderPipelineTarget } from '../../render-pipeline';
17
17
  import {
18
18
  admitStandardColorLut,
@@ -41,7 +41,7 @@ export interface StandardLutGraphProjection {
41
41
  }
42
42
 
43
43
  export interface StandardLutGpuPreparationInput {
44
- readonly world: World;
44
+ readonly world: RenderResourceScope;
45
45
  readonly handle: Handle<'TextureAsset', 'shared'>;
46
46
  readonly strength: number;
47
47
  readonly assets: AssetRegistry;
@@ -125,7 +125,7 @@ export function prepareStandardLutGpu(
125
125
  if (!asset.ok) {
126
126
  return failure(
127
127
  'standard-lut-residency-failed',
128
- 'the authored TextureAsset handle resolves in the active World',
128
+ 'the authored TextureAsset handle resolves in the active RenderResourceScope',
129
129
  'register or rebuild the TextureAsset, then retry the same camera generation',
130
130
  causeDetail(asset.error),
131
131
  );
@@ -1,6 +1,6 @@
1
- import type { World } from '@forgeax/engine-ecs';
2
1
  import { err, ok, type Result, RhiError } from '@forgeax/engine-rhi';
3
2
  import { toShared } from '@forgeax/engine-types';
3
+ import type { RenderResourceScope } from '../../publication/resource-scope';
4
4
  import type { RenderFrameState } from '../../record/frame-snapshot';
5
5
  import type { RenderSystemInternals } from '../../record/render-context';
6
6
  import type { CameraSnapshot } from '../../render-contract';
@@ -17,7 +17,7 @@ export function prepareStandardOutputResources(
17
17
  state: RenderFrameState,
18
18
  internals: RenderSystemInternals,
19
19
  camera: CameraSnapshot | undefined,
20
- world: World,
20
+ world: RenderResourceScope,
21
21
  ): Result<void, RhiError> {
22
22
  const output = camera?.output;
23
23
  if (output?.exposure.kind === 'auto') {
@@ -0,0 +1,177 @@
1
+ import { AssetRegistry, HANDLE_CUBE } from '@forgeax/engine-assets-runtime';
2
+ import { World } from '@forgeax/engine-ecs';
3
+ import { AssetGuid } from '@forgeax/engine-pack/guid';
4
+ import { rhi } from '@forgeax/engine-rhi-null';
5
+ import { Transform } from '@forgeax/engine-scene';
6
+ import { ShaderRegistry } from '@forgeax/engine-shader';
7
+ import { Skin } from '@forgeax/engine-skinning';
8
+ import { expect, it } from 'vitest';
9
+ import { createSkinPaletteOwner } from '../../assembly/skin-palette-owner';
10
+ import { Instances, MeshFilter, MeshRenderer } from '../../components';
11
+ import { GlyphText } from '../../components/glyph-text';
12
+ import { InstanceProjectionStore } from '../../instances';
13
+ import { PersistentRenderScene } from '../../scene/render-scene';
14
+ import { renderPublicationTransfers } from '../contract';
15
+ import { preparePublicationGeometry } from '../prepare-geometry';
16
+ import { createRenderPublisher } from '../publisher';
17
+ import { RenderPublicationReceiver } from '../receiver';
18
+
19
+ function required<T>(value: T | undefined): T {
20
+ if (value === undefined) throw new Error('Missing expected render projection');
21
+ return value;
22
+ }
23
+
24
+ function fixture() {
25
+ const world = new World();
26
+ const assets = new AssetRegistry(new ShaderRegistry({ manifestUrl: undefined }));
27
+ const identity = { source: 'derived-geometry', epoch: 1 };
28
+ const publisher = createRenderPublisher(world, assets, identity);
29
+ const receiver = new RenderPublicationReceiver(identity);
30
+ const scene = new PersistentRenderScene();
31
+ const instances = new InstanceProjectionStore();
32
+ const publish = (palette?: ReturnType<typeof createSkinPaletteOwner>) => {
33
+ world.update(0).unwrap();
34
+ const candidate = publisher.prepare(0).unwrap();
35
+ const packet = structuredClone(candidate.packet);
36
+ candidate.accept();
37
+ const received = receiver.accept(packet).unwrap();
38
+ const prepared = preparePublicationGeometry(received, scene, palette, assets, instances);
39
+ const frame = scene.consumePublication(prepared);
40
+ publisher.recycle(packet.revision, renderPublicationTransfers(packet)).unwrap();
41
+ return { packet, frame };
42
+ };
43
+ const spawn = () =>
44
+ world
45
+ .spawn(
46
+ { component: Transform, data: {} },
47
+ { component: MeshFilter, data: { assetHandle: HANDLE_CUBE } },
48
+ { component: MeshRenderer, data: {} },
49
+ )
50
+ .unwrap();
51
+ return { world, assets, publisher, receiver, scene, instances, publish, spawn };
52
+ }
53
+ const matrix = () => new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]);
54
+
55
+ it('derives glyph meshes in the source World and republishes edits under the same mesh handle', () => {
56
+ const f = fixture();
57
+ const font = f.world.allocSharedRef('FontAsset', {
58
+ kind: 'font',
59
+ atlas: AssetGuid.random(),
60
+ sampler: AssetGuid.random(),
61
+ glyphs: {
62
+ 65: {
63
+ advance: 10,
64
+ bearingX: 0,
65
+ bearingY: 8,
66
+ size: { w: 8, h: 8 },
67
+ region: { x: 0, y: 0, w: 8, h: 8 },
68
+ },
69
+ },
70
+ common: {
71
+ lineHeight: 12,
72
+ base: 8,
73
+ distanceRange: 4,
74
+ pxRange: 4,
75
+ atlasWidth: 8,
76
+ atlasHeight: 8,
77
+ },
78
+ });
79
+ const label = f.world
80
+ .spawn(
81
+ { component: Transform, data: {} },
82
+ { component: GlyphText, data: { fontHandle: font, text: 'A', fontSize: 1 } },
83
+ )
84
+ .unwrap();
85
+ const initial = f.publish();
86
+ expect(initial.frame.renderables.map((row) => row.entityKey)).toContain(label);
87
+ const handle = f.world.get(label, MeshFilter).unwrap().assetHandle;
88
+ const mesh = initial.packet.assets.find((row) => row.handle === handle)?.value;
89
+ expect(mesh?.kind).toBe('mesh');
90
+ f.world.set(label, GlyphText, { text: 'AAA' }).unwrap();
91
+ const edited = f.publish();
92
+ expect(f.world.get(label, MeshFilter).unwrap().assetHandle).toBe(handle);
93
+ expect(edited.packet.invalidatedAssets).toContain(handle);
94
+ const next = edited.packet.assets.find((row) => row.handle === handle)?.value;
95
+ expect(
96
+ next?.kind === 'mesh' && mesh?.kind === 'mesh' && next.vertices.length > mesh.vertices.length,
97
+ ).toBe(true);
98
+ f.publisher.dispose();
99
+ const later = f.world
100
+ .spawn({ component: GlyphText, data: { fontHandle: font, text: 'A' } })
101
+ .unwrap();
102
+ f.world.update(0).unwrap();
103
+ expect(f.world.hasComponent(later, MeshFilter)).toBe(false);
104
+ });
105
+
106
+ it('retains instance history on pose edits and retires it across removal and topology changes', () => {
107
+ const f = fixture();
108
+ const entity = f.spawn();
109
+ f.world.addComponent(entity, { component: Instances, data: { transforms: matrix() } }).unwrap();
110
+ const initial = required(f.publish().frame.renderables[0]?.instances);
111
+ const moved = matrix();
112
+ moved[12] = 2;
113
+ f.world.set(entity, Instances, { transforms: moved }).unwrap();
114
+ const edited = required(f.publish().frame.renderables[0]?.instances);
115
+ expect(edited.collectionId).toBe(initial.collectionId);
116
+ expect(edited.generations).toEqual(initial.generations);
117
+ expect(edited.revision).toBe(required(initial.revision) + 1);
118
+ f.world
119
+ .set(entity, Instances, { transforms: new Float32Array([...matrix(), ...matrix()]) })
120
+ .unwrap();
121
+ const resized = required(f.publish().frame.renderables[0]?.instances);
122
+ expect(resized.generations?.[0]).not.toBe(initial.generations?.[0]);
123
+ f.world.removeComponent(entity, Instances).unwrap();
124
+ f.publish();
125
+ f.world.addComponent(entity, { component: Instances, data: { transforms: matrix() } }).unwrap();
126
+ const restored = required(f.publish().frame.renderables[0]?.instances);
127
+ expect(restored.collectionId).not.toBe(initial.collectionId);
128
+ f.publisher.dispose();
129
+ });
130
+
131
+ it.each([
132
+ true,
133
+ false,
134
+ ])('keeps untouched skin palettes and reclaims removed slices (storage=%s)', async (storage) => {
135
+ const device = (await (await rhi.requestAdapter()).unwrap().requestDevice()).unwrap();
136
+ const palette = createSkinPaletteOwner(device, storage);
137
+ const f = fixture();
138
+ const skeleton = f.world.allocSharedRef('SkeletonAsset', {
139
+ kind: 'skeleton',
140
+ jointCount: 1,
141
+ inverseBindMatrices: matrix(),
142
+ });
143
+ const joint = f.world.spawn({ component: Transform, data: {} }).unwrap();
144
+ const first = f.spawn(),
145
+ second = f.spawn();
146
+ for (const entity of [first, second])
147
+ f.world
148
+ .addComponent(entity, {
149
+ component: Skin,
150
+ data: { skeleton, joints: new Uint32Array([joint]) },
151
+ })
152
+ .unwrap();
153
+ const initial = f.publish(palette).frame;
154
+ const one = required(initial.renderables.find((row) => row.entityKey === first)?.skin);
155
+ const two = required(initial.renderables.find((row) => row.entityKey === second)?.skin);
156
+ expect(one).toBeDefined();
157
+ expect(two).toBeDefined();
158
+ f.world.removeComponent(first, Skin).unwrap();
159
+ const removed = f.publish(palette).frame;
160
+ expect(removed.renderables.find((row) => row.entityKey === first)?.skin).toBeUndefined();
161
+ expect(removed.renderables.find((row) => row.entityKey === second)?.skin?.fence).toBe(two.fence);
162
+ f.world.set(joint, Transform, { pos: [1, 0, 0] }).unwrap();
163
+ const moved = required(
164
+ f.publish(palette).frame.renderables.find((row) => row.entityKey === second)?.skin,
165
+ );
166
+ expect(moved.fence).toBe(two.fence);
167
+ expect(moved.uploadBytes).toBe(64);
168
+ f.world
169
+ .addComponent(first, { component: Skin, data: { skeleton, joints: new Uint32Array([joint]) } })
170
+ .unwrap();
171
+ const restored = required(
172
+ f.publish(palette).frame.renderables.find((row) => row.entityKey === first)?.skin,
173
+ );
174
+ expect(restored.fence).not.toBe(one.fence);
175
+ f.publisher.dispose();
176
+ palette.dispose();
177
+ });