@forgeax/engine-render 0.1.26 → 0.1.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +215 -0
  2. package/dist/assembly/dynamic-geometry-host.d.ts +31 -0
  3. package/dist/assembly/dynamic-geometry-host.d.ts.map +1 -0
  4. package/dist/assembly/host-contract.d.ts +11 -0
  5. package/dist/assembly/host-contract.d.ts.map +1 -1
  6. package/dist/assembly/renderer-facade.d.ts.map +1 -1
  7. package/dist/assembly/webgpu-renderer.d.ts.map +1 -1
  8. package/dist/authoring.mjs +2 -2
  9. package/dist/{chunk-FJ6P52EE.mjs → chunk-ILQ5MEZF.mjs} +3 -17
  10. package/dist/chunk-ILQ5MEZF.mjs.map +1 -0
  11. package/dist/{chunk-HKXTW355.mjs → chunk-IQMKJLNU.mjs} +3 -3
  12. package/dist/{chunk-HKXTW355.mjs.map → chunk-IQMKJLNU.mjs.map} +1 -1
  13. package/dist/{chunk-KM2NOX2I.mjs → chunk-J52NMQKR.mjs} +462 -6
  14. package/dist/chunk-J52NMQKR.mjs.map +1 -0
  15. package/dist/{chunk-ZZ4YQ474.mjs → chunk-J6KACNOH.mjs} +2 -2
  16. package/dist/{chunk-ZZ4YQ474.mjs.map → chunk-J6KACNOH.mjs.map} +1 -1
  17. package/dist/{chunk-ADHHVYLW.mjs → chunk-JAASCMXA.mjs} +3 -3
  18. package/dist/{chunk-ADHHVYLW.mjs.map → chunk-JAASCMXA.mjs.map} +1 -1
  19. package/dist/{chunk-TMIASV2N.mjs → chunk-MBU4ZVE7.mjs} +20 -12
  20. package/dist/chunk-MBU4ZVE7.mjs.map +1 -0
  21. package/dist/{chunk-E34VL5VI.mjs → chunk-MYJ5RPJO.mjs} +3 -3
  22. package/dist/{chunk-E34VL5VI.mjs.map → chunk-MYJ5RPJO.mjs.map} +1 -1
  23. package/dist/{chunk-JDWARUOI.mjs → chunk-RBDBZ5R7.mjs} +17 -3
  24. package/dist/chunk-RBDBZ5R7.mjs.map +1 -0
  25. package/dist/{chunk-YUQXSEHG.mjs → chunk-VSL23LZE.mjs} +26 -12
  26. package/dist/chunk-VSL23LZE.mjs.map +1 -0
  27. package/dist/construct-renderer.mjs +862 -106
  28. package/dist/construct-renderer.mjs.map +1 -1
  29. package/dist/device/gpu-residency.d.ts +10 -5
  30. package/dist/device/gpu-residency.d.ts.map +1 -1
  31. package/dist/device/mesh-residency-lifetime.d.ts +21 -0
  32. package/dist/device/mesh-residency-lifetime.d.ts.map +1 -0
  33. package/dist/dynamic-geometry.d.ts +125 -0
  34. package/dist/dynamic-geometry.d.ts.map +1 -0
  35. package/dist/index.d.ts +3 -0
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.mjs +7 -6
  38. package/dist/index.mjs.map +1 -1
  39. package/dist/internal.mjs +7 -7
  40. package/dist/record/frame.d.ts +1 -1
  41. package/dist/record/frame.d.ts.map +1 -1
  42. package/dist/record/main-pass-geometry.d.ts.map +1 -1
  43. package/dist/record/render-context.d.ts +2 -0
  44. package/dist/record/render-context.d.ts.map +1 -1
  45. package/dist/record/typed-frame-graph.d.ts.map +1 -1
  46. package/dist/render-contract.d.ts +12 -0
  47. package/dist/render-contract.d.ts.map +1 -1
  48. package/dist/render-system-extract-tail.d.ts.map +1 -1
  49. package/dist/render-system-extract.d.ts +6 -0
  50. package/dist/render-system-extract.d.ts.map +1 -1
  51. package/dist/render-system.d.ts +7 -0
  52. package/dist/render-system.d.ts.map +1 -1
  53. package/dist/temporal/index.mjs +4 -4
  54. package/package.json +20 -20
  55. package/src/__tests__/dynamic-geometry-host.unit.test.ts +1123 -0
  56. package/src/__tests__/dynamic-geometry.unit.test.ts +233 -0
  57. package/src/__tests__/factory-contract.integration.test.ts +6 -14
  58. package/src/__tests__/mesh-submission-lifetime.unit.test.ts +53 -0
  59. package/src/__tests__/renderer-factory-material-contract.unit.test.ts +1 -1
  60. package/src/assembly/dynamic-geometry-host.ts +839 -0
  61. package/src/assembly/host-contract.ts +26 -0
  62. package/src/assembly/renderer-facade.ts +17 -0
  63. package/src/assembly/webgpu-renderer.ts +49 -2
  64. package/src/components/sprite-animation.ts +1 -1
  65. package/src/device/gpu-residency.ts +62 -62
  66. package/src/device/mesh-residency-lifetime.ts +73 -0
  67. package/src/dynamic-geometry.ts +736 -0
  68. package/src/index.ts +16 -0
  69. package/src/record/__tests__/standard-pbr-ubo-layout.unit.test.ts +68 -1
  70. package/src/record/frame.ts +2 -0
  71. package/src/record/main-pass-geometry.ts +13 -4
  72. package/src/record/main-pass-sprite-draws.ts +2 -0
  73. package/src/record/render-context.ts +2 -0
  74. package/src/record/typed-frame-graph.ts +2 -9
  75. package/src/render-contract.ts +24 -0
  76. package/src/render-system-extract-tail.ts +3 -2
  77. package/src/render-system-extract.ts +30 -1
  78. package/src/render-system.ts +42 -0
  79. package/dist/chunk-FJ6P52EE.mjs.map +0 -1
  80. package/dist/chunk-JDWARUOI.mjs.map +0 -1
  81. package/dist/chunk-KM2NOX2I.mjs.map +0 -1
  82. package/dist/chunk-TMIASV2N.mjs.map +0 -1
  83. package/dist/chunk-YUQXSEHG.mjs.map +0 -1
package/src/index.ts CHANGED
@@ -107,6 +107,21 @@ export {
107
107
  VisibilityStateValue,
108
108
  visibilityStateFromU32,
109
109
  } from './components/visibility';
110
+ export type {
111
+ DynamicGeometryCandidate,
112
+ DynamicGeometryCandidateState,
113
+ DynamicGeometryErrorCode,
114
+ DynamicGeometryErrorDetail,
115
+ DynamicGeometryInspection,
116
+ DynamicGeometryLifecycle,
117
+ DynamicGeometryOrdering,
118
+ DynamicGeometryPrepareInput,
119
+ DynamicGeometryReceipt,
120
+ } from './dynamic-geometry';
121
+ export {
122
+ createDynamicGeometryLifecycle,
123
+ DynamicGeometryError,
124
+ } from './dynamic-geometry';
110
125
  export type {
111
126
  EnvironmentInspection,
112
127
  EnvironmentInspectionFailure,
@@ -394,6 +409,7 @@ export {
394
409
  type SsrCompositionResult,
395
410
  type SsrReflectionColor,
396
411
  } from './ssr/composition';
412
+ export { getActiveCamera, setActiveCamera } from './systems/active-camera';
397
413
  export type {
398
414
  RenderTarget,
399
415
  RenderTargetAdmissionLimits,
@@ -3,7 +3,8 @@ import { vec3 } from '@forgeax/engine-math';
3
3
  import { DEFAULT_STANDARD_PBR_PARAM_SCHEMA } from '@forgeax/engine-shader';
4
4
  import { derive } from '@forgeax/engine-types';
5
5
  import { describe, expect, it } from 'vitest';
6
- import type { MaterialSnapshot } from '../../render-system-extract';
6
+ import { Materials } from '../../materials';
7
+ import { type MaterialSnapshot, materialParamSchemaForMaterial } from '../../render-system-extract';
7
8
  import { applyMaterialTextureUvScales, buildPbrMaterialUboPayload } from '../main-pass-material';
8
9
 
9
10
  describe('Standard PBR UBO layout', () => {
@@ -69,6 +70,72 @@ describe('Standard PBR UBO layout', () => {
69
70
  );
70
71
  });
71
72
 
73
+ it('writes custom Standard Surface parameters at their published offsets', () => {
74
+ const custom = Materials.standard({
75
+ surfaceModule: 'game_3d::rusted_iron_surface',
76
+ colorSpace: 'linear',
77
+ parameters: [
78
+ { name: 'ironColor', type: 'color' },
79
+ { name: 'rustDark', type: 'color' },
80
+ { name: 'rustBright', type: 'color' },
81
+ { name: 'noiseScale', type: 'f32' },
82
+ ],
83
+ values: {
84
+ ironColor: [0.4, 0.45, 0.47, 1],
85
+ rustDark: [0.42, 0.085, 0.018, 1],
86
+ rustBright: [0.95, 0.34, 0.055, 1],
87
+ noiseScale: 1.85,
88
+ },
89
+ });
90
+ const schema = materialParamSchemaForMaterial(
91
+ custom.parameters ?? [],
92
+ 'forgeax::default-standard-pbr',
93
+ custom.passes ?? [],
94
+ );
95
+ const material = {
96
+ baseColor: vec3.create(1, 1, 1),
97
+ metallic: 0,
98
+ roughness: 0.5,
99
+ materialShaderId: 'sha256:rusted-iron-forward',
100
+ materialParamSchema: schema,
101
+ paramSnapshot: {
102
+ ironColor: [0.4, 0.45, 0.47, 1],
103
+ rustDark: [0.42, 0.085, 0.018, 1],
104
+ rustBright: [0.95, 0.34, 0.055, 1],
105
+ noiseScale: 1.85,
106
+ },
107
+ } satisfies MaterialSnapshot;
108
+ const f32 = new Float32Array(buildPbrMaterialUboPayload(material).buffer);
109
+ const derived = derive(schema);
110
+ const numericOffsets = new Map(
111
+ derived.numericMembers.map((member) => [member.name, member.offset / 4]),
112
+ );
113
+
114
+ expect(numericOffsets.get('ironColor')).toBeDefined();
115
+ expect(numericOffsets.get('rustDark')).toBeDefined();
116
+ expect(numericOffsets.get('rustBright')).toBeDefined();
117
+ expect(numericOffsets.get('noiseScale')).toBeDefined();
118
+ const requireOffset = (name: string): number => {
119
+ const offset = numericOffsets.get(name);
120
+ if (offset === undefined) throw new Error(`missing numeric offset for ${name}`);
121
+ return offset;
122
+ };
123
+ const ironColorOffset = requireOffset('ironColor');
124
+ const rustDarkOffset = requireOffset('rustDark');
125
+ const rustBrightOffset = requireOffset('rustBright');
126
+ const noiseScaleOffset = requireOffset('noiseScale');
127
+ expect(f32.slice(ironColorOffset, ironColorOffset + 4)).toEqual(
128
+ new Float32Array([0.4, 0.45, 0.47, 1]),
129
+ );
130
+ expect(f32.slice(rustDarkOffset, rustDarkOffset + 4)).toEqual(
131
+ new Float32Array([0.42, 0.085, 0.018, 1]),
132
+ );
133
+ expect(f32.slice(rustBrightOffset, rustBrightOffset + 4)).toEqual(
134
+ new Float32Array([0.95, 0.34, 0.055, 1]),
135
+ );
136
+ expect(f32[noiseScaleOffset]).toBeCloseTo(1.85, 6);
137
+ });
138
+
72
139
  it('uses all seven user-region identity coordinate records in the no-snapshot fallback', () => {
73
140
  const material = {
74
141
  baseColor: vec3.create(0.7, 0.7, 0.7),
@@ -1341,6 +1341,7 @@ export function recordFrame(
1341
1341
  transmissionDemand?: TransmissionDemand,
1342
1342
  volumetricFog?: ExtractedVolumetricFog,
1343
1343
  timingCapture?: GpuTimingCapture,
1344
+ onRenderableDraw?: (entry: ValidatedRenderable) => void,
1344
1345
  ): boolean {
1345
1346
  frameState.reflectionFallbackObservationSource = undefined;
1346
1347
  frameState.reflectionFallbackCompletion = undefined;
@@ -2157,6 +2158,7 @@ export function recordFrame(
2157
2158
  : { reflectionProbes: cubeCapture.reflectionProbes }),
2158
2159
  pointsLines: pointsLinesOwner,
2159
2160
  materialBgAssemblyCache: frameState.materialBgAssemblyCache,
2161
+ ...(onRenderableDraw === undefined ? {} : { onRenderableDraw }),
2160
2162
  directionalShadowCacheReuse: directionalShadowCache.reuse,
2161
2163
  ...(profilePhase !== undefined ? { profilePhase } : {}),
2162
2164
  };
@@ -297,6 +297,7 @@ function submitSubmeshDraws(
297
297
  indexCount: number,
298
298
  vertexCount: number,
299
299
  indexOffset: number,
300
+ onDraw?: () => void,
300
301
  ): void {
301
302
  if (state.pipeline !== pipeline) {
302
303
  pass.setPipeline(pipeline);
@@ -320,6 +321,7 @@ function submitSubmeshDraws(
320
321
  } else {
321
322
  pass.draw(vertexCount, instanceDraw.instanceCount, 0, 0);
322
323
  }
324
+ onDraw?.();
323
325
  }
324
326
  }
325
327
 
@@ -691,6 +693,7 @@ export function recordGeometryDraws(
691
693
  resolveGeometryInstancesBindGroup(c, identityInstanceBuffer, undefined);
692
694
  pass.setBindGroup(3, pointsLinesInstancesBg);
693
695
  recordOcclusionCandidate(() => recordPointsLinesDraw(pass, pointsLinesSubmission.plan));
696
+ c.onRenderableDraw?.(entry);
694
697
  pass.setBindGroup(0, viewBindGroup as BindGroup, [viewBindGroupDynamicOffset, 0]);
695
698
  continue;
696
699
  }
@@ -1007,6 +1010,10 @@ export function recordGeometryDraws(
1007
1010
  : (materialPasses.get(entry.renderableIndex)?.get(submeshMaterial.materialHandle ?? 0) ??
1008
1011
  []);
1009
1012
  for (const selectedPass of draws) {
1013
+ // Temporal projects the selected material's alpha/motion contract,
1014
+ // not its authored Forward entry points or color outputs.
1015
+ const vertexEntry = passKind === 'temporal' ? undefined : selectedPass?.vertexEntry;
1016
+ const fragmentEntry = passKind === 'temporal' ? undefined : selectedPass?.fragmentEntry;
1010
1017
  const selectedShaderId =
1011
1018
  selectedPass === undefined
1012
1019
  ? submeshMaterial.materialShaderId
@@ -1154,8 +1161,8 @@ export function recordGeometryDraws(
1154
1161
  unlitVariantSet,
1155
1162
  colorFormatOverride,
1156
1163
  entry.mesh.layoutProjection,
1157
- selectedPass?.vertexEntry,
1158
- selectedPass?.fragmentEntry,
1164
+ vertexEntry,
1165
+ fragmentEntry,
1159
1166
  );
1160
1167
  materialPipelineEntry = unlitRsp;
1161
1168
  smPipelineHandle =
@@ -1219,8 +1226,8 @@ export function recordGeometryDraws(
1219
1226
  variantSet,
1220
1227
  colorFormatOverride,
1221
1228
  entry.mesh.layoutProjection,
1222
- selectedPass?.vertexEntry,
1223
- selectedPass?.fragmentEntry,
1229
+ vertexEntry,
1230
+ fragmentEntry,
1224
1231
  );
1225
1232
  // feat-20260615-pipeline-spec-ssot M6-T1: cache miss resolves to
1226
1233
  // null uniformly across URP / HDRP / skin shaders. Charter P3
@@ -1320,6 +1327,7 @@ export function recordGeometryDraws(
1320
1327
  sm.indexCount,
1321
1328
  sm.vertexCount,
1322
1329
  sm.indexOffset,
1330
+ () => c.onRenderableDraw?.(entry),
1323
1331
  ),
1324
1332
  );
1325
1333
  } else {
@@ -1334,6 +1342,7 @@ export function recordGeometryDraws(
1334
1342
  sm.indexCount,
1335
1343
  sm.vertexCount,
1336
1344
  sm.indexOffset,
1345
+ () => c.onRenderableDraw?.(entry),
1337
1346
  ),
1338
1347
  ),
1339
1348
  );
@@ -737,6 +737,7 @@ function recordSpriteEntityDraws(
737
737
  );
738
738
  spritePass.setBindGroup(3, spriteInstBg);
739
739
  spritePass.drawIndexed(spriteEntry.mesh.indexCount, spriteInstanceCount, 0, 0, 0);
740
+ c.onRenderableDraw?.(spriteEntry);
740
741
  continue;
741
742
  }
742
743
  spriteBufUsage = GPU_BUFFER_USAGE_UNIFORM | GPU_BUFFER_USAGE_COPY_DST;
@@ -899,6 +900,7 @@ function recordSpriteEntityDraws(
899
900
  spritePass.setBindGroup(1, spritePassBg, [materialSlot * MATERIAL_PER_ENTITY_STRIDE]);
900
901
  spritePass.setBindGroup(3, spriteInstancesBg);
901
902
  spritePass.drawIndexed(spriteEntry.mesh.indexCount, spriteInstanceCount, 0, 0, 0);
903
+ c.onRenderableDraw?.(spriteEntry);
902
904
  }
903
905
  }
904
906
 
@@ -517,6 +517,8 @@ export interface _StandardForwardSceneView {
517
517
  readonly materialSlotCount: number;
518
518
  readonly pointsLines: PointsLinesRecordOwner | undefined;
519
519
  readonly materialBgAssemblyCache: Map<string, MaterialBgAssemblyCacheEntry>;
520
+ /** Record-stage proof callback; invoked only after a real draw command is encoded. */
521
+ readonly onRenderableDraw?: (entry: ValidatedRenderable) => void;
520
522
  readonly reflectionProbes?: ReflectionProbeRecordState;
521
523
  materialUboPayloadCache?: {
522
524
  readonly materialSlots: readonly MaterialSnapshot[];
@@ -40,7 +40,6 @@ import {
40
40
  createFullscreenBindGroup,
41
41
  isTemporalFullscreenBinding,
42
42
  postProcessShaderEntrySignature,
43
- postProcessShaderModuleLabel,
44
43
  } from '../fullscreen-post-process-pass';
45
44
  import type { PreparedGpuDrivenFrame } from '../gpu-driven/production-raster';
46
45
  import {
@@ -1588,12 +1587,6 @@ export function executeCompiledFrameGraph(
1588
1587
  timingCapture?: GpuTimingCapture,
1589
1588
  ): boolean {
1590
1589
  const graph = frameState.compiledFrameGraph;
1591
- const invalidatePostProcessModule = (id: string): void => {
1592
- const entry = internals.lookupPostProcess?.(id);
1593
- if (entry !== undefined) {
1594
- internals.invalidateShaderModule?.(postProcessShaderModuleLabel(id, entry.source));
1595
- }
1596
- };
1597
1590
  const rejectTemporalFrame = (): void => {
1598
1591
  if (frameState.temporalFrameInput === undefined) return;
1599
1592
  frameState.temporalFrameTransaction.commit({ accepted: false });
@@ -1945,7 +1938,6 @@ export function executeCompiledFrameGraph(
1945
1938
  frameState.activeTemporalGpuState = stagedGpuState;
1946
1939
  if (previousGpuState !== undefined && previousGpuState !== stagedGpuState) {
1947
1940
  internals.clearPostProcessPipelineCache?.('forgeax.taa-resolve');
1948
- invalidatePostProcessModule('forgeax.taa-resolve');
1949
1941
  retireTemporalGpuStateAfterFence(
1950
1942
  previousGpuState,
1951
1943
  internals.device.queue,
@@ -1975,7 +1967,8 @@ export function executeCompiledFrameGraph(
1975
1967
  }
1976
1968
  if (stagedTemporalCommit.kind === 'off') {
1977
1969
  internals.clearPostProcessPipelineCache?.('forgeax.taa-resolve');
1978
- invalidatePostProcessModule('forgeax.taa-resolve');
1970
+ // Retire frame-sized resources, not the device-owned prewarmed
1971
+ // shader. The next TAA frame must not start an async cold compile.
1979
1972
  const activeGpuState = frameState.activeTemporalGpuState;
1980
1973
  if (activeGpuState !== undefined) {
1981
1974
  retireTemporalGpuStateAfterFence(
@@ -58,6 +58,14 @@ import type { TemporalInspection } from './temporal/inspection';
58
58
  export type { TransmissionInspection } from './inspection-types';
59
59
  export type { PointsLinesInspection } from './points-lines/inspection';
60
60
 
61
+ import type {
62
+ DynamicGeometryCandidate,
63
+ DynamicGeometryError,
64
+ DynamicGeometryInspection,
65
+ DynamicGeometryOrdering,
66
+ DynamicGeometryPrepareInput,
67
+ DynamicGeometryReceipt,
68
+ } from './dynamic-geometry';
61
69
  import type {
62
70
  IblBindingInspection,
63
71
  MeshMaterialBindingObservation,
@@ -297,6 +305,18 @@ export interface SpotLightProjectorFrameContext {
297
305
  export interface Renderer {
298
306
  /** Renderer-owned authoring and update path for large instance collections. */
299
307
  attach(world: World): RenderResult<RenderWorldLease, RenderError>;
308
+ /** Prepare a standard MeshAsset for the active device generation. */
309
+ prepareDynamicGeometry(
310
+ input: DynamicGeometryPrepareInput,
311
+ ): Result<DynamicGeometryCandidate, DynamicGeometryError>;
312
+ /** Admit a prepared candidate; publication is tied to the next FrameReceipt. */
313
+ acceptDynamicGeometry(
314
+ candidate: DynamicGeometryCandidate,
315
+ ordering: DynamicGeometryOrdering,
316
+ ): Result<DynamicGeometryCandidate, DynamicGeometryError>;
317
+ dynamicGeometryReceipt(candidate: DynamicGeometryCandidate): DynamicGeometryReceipt | undefined;
318
+ cancelDynamicGeometry(candidate: DynamicGeometryCandidate): Result<void, DynamicGeometryError>;
319
+ retireDynamicGeometry(candidate: DynamicGeometryCandidate): Result<void, DynamicGeometryError>;
300
320
  draw(input: RenderFrameInput): RenderResult<FrameReceipt, RenderError>;
301
321
  createRenderTarget(descriptor: RenderTargetDescriptor): RenderResult<RenderTarget, RenderError>;
302
322
  resizeRenderTarget(
@@ -406,6 +426,8 @@ export interface RenderFrameInput {
406
426
  readonly leases: readonly RenderWorldLease[];
407
427
  readonly camera: FrameCamera;
408
428
  readonly environment: FrameEnvironment;
429
+ /** Fixed-step publication consumed by dynamic geometry candidates. */
430
+ readonly fixedStep?: number;
409
431
  /** Optional profiler correlation token owned by App and consumed by Render. */
410
432
  readonly profileFrame?: ProfileFrameToken;
411
433
  }
@@ -513,6 +535,8 @@ export interface RenderInspection {
513
535
  readonly visibilityStats: { readonly explicitlyHidden: number };
514
536
  /** Renderer-owned large-instance residency and upload facts. */
515
537
  readonly instanceCollections: readonly InstanceCollectionInspection[];
538
+ /** Standard MeshAsset dynamic-candidate lifecycle facts. */
539
+ readonly dynamicGeometry?: DynamicGeometryInspection;
516
540
  readonly renderScene: RenderSceneInspection;
517
541
  readonly reflectionProbes: ReflectionProbeInspection;
518
542
  /** Renderer-owned SSR dependency seam; consumer facts are projected from live owners. */
@@ -148,7 +148,7 @@ import {
148
148
  isEngineInjectedTextureField,
149
149
  materialColorParameterSchema,
150
150
  materialNormalScale,
151
- materialParametersToParamSchema,
151
+ materialParamSchemaForMaterial,
152
152
  materialProgramKeysForMaterial,
153
153
  materialTextureFields,
154
154
  materialTextureRef,
@@ -2073,9 +2073,10 @@ export function extractFrame(world: World, context: PreparedExtractContext): Ext
2073
2073
  }
2074
2074
  }
2075
2075
 
2076
- const materialParamSchema = materialParametersToParamSchema(
2076
+ const materialParamSchema = materialParamSchemaForMaterial(
2077
2077
  resolved.parameters ?? [],
2078
2078
  authoredFirstPassShader,
2079
+ allPasses,
2079
2080
  );
2080
2081
  // feat-20260611-fox-skinning-vertex-attribute-chain M4 / w17 (D-5):
2081
2082
  // bidirectional Skin <-> pbr-skin material fail-fast at extract.
@@ -94,6 +94,7 @@ import { AssetGuid, type AssetGuid as AssetGuidBytes } from '@forgeax/engine-pac
94
94
  import { RhiError } from '@forgeax/engine-rhi';
95
95
  import { GlobalTransform, Transform } from '@forgeax/engine-scene';
96
96
  import {
97
+ DEFAULT_STANDARD_SURFACE_MODULE,
97
98
  type MaterialShaderArtifact,
98
99
  STANDARD_PIPELINE_PARAM_SCHEMA,
99
100
  } from '@forgeax/engine-shader';
@@ -1780,6 +1781,33 @@ export function materialParametersToParamSchema(
1780
1781
  return projectMaterialParametersToParamSchema(parameters);
1781
1782
  }
1782
1783
 
1784
+ /**
1785
+ * Select the parameter ABI for one resolved material. The canonical Standard
1786
+ * schema is valid only for the engine-owned default Surface; a Standard root
1787
+ * with a project Surface keeps the custom fields that its cooked WGSL reads.
1788
+ */
1789
+ export function materialParamSchemaForMaterial(
1790
+ parameters: readonly MaterialParameter[],
1791
+ authoredShaderId: string | undefined,
1792
+ passes: readonly MaterialPass[],
1793
+ ): readonly ParamSchemaEntry[] {
1794
+ const firstPass =
1795
+ passes.find(
1796
+ (pass) =>
1797
+ !/shadow|depth/i.test(
1798
+ String(
1799
+ (pass.renderState?.tags as Record<string, unknown> | undefined)?.LightMode ?? pass.name,
1800
+ ),
1801
+ ),
1802
+ ) ?? passes[0];
1803
+ const surfaceModule = firstPass?.program.moduleSlots?.surface;
1804
+ const schemaShaderId =
1805
+ surfaceModule === undefined || surfaceModule === DEFAULT_STANDARD_SURFACE_MODULE
1806
+ ? authoredShaderId
1807
+ : undefined;
1808
+ return materialParametersToParamSchema(parameters, schemaShaderId);
1809
+ }
1810
+
1783
1811
  /**
1784
1812
  * Resolve color semantics from the material asset first and the shader schema
1785
1813
  * for fields the asset does not redeclare. Generated and historical pack
@@ -1983,9 +2011,10 @@ export function resolveMaterialSnapshot(
1983
2011
  paramSnap[k] = v as number[];
1984
2012
  }
1985
2013
  }
1986
- const materialParamSchema = materialParametersToParamSchema(
2014
+ const materialParamSchema = materialParamSchemaForMaterial(
1987
2015
  resolved.parameters ?? [],
1988
2016
  authoredFirstPassShader,
2017
+ allPasses,
1989
2018
  );
1990
2019
  // feat-20260614 M8 (D-19): texture / sampler values are embedded GUIDs
1991
2020
  // (dash-form strings) after loadByGuid. Resolve each to a user-tier column
@@ -667,6 +667,13 @@ export interface RenderSystem {
667
667
  renderReadLeases?: readonly RenderReadLease[],
668
668
  timingCapture?: GpuTimingCapture,
669
669
  ): boolean;
670
+ /**
671
+ * Return the asset binding consumed by the last successful record pass.
672
+ * Dynamic geometry uses this renderer-owned frame fact as its publication
673
+ * barrier; ECS component presence alone is not a draw receipt.
674
+ */
675
+ isDynamicGeometryConsumed(world: World, entity: number, meshHandle: number | undefined): boolean;
676
+ invalidateGeometryHistory(): void;
670
677
  /** Prepare a detached graph candidate without entering the frame record path. */
671
678
  prepareRecoveryGraphCandidate(
672
679
  runtime: RecoveryGraphCandidateRuntime,
@@ -1081,6 +1088,7 @@ export function createRenderSystem(internals: RenderSystemInternals): RenderSyst
1081
1088
  let releaseProfilerCatalog =
1082
1089
  phaseCatalogRegistration?.ok === true ? phaseCatalogRegistration.value : undefined;
1083
1090
  let preparedWorlds: readonly World[] = [];
1091
+ let lastSubmittedDynamicGeometryBindings = new WeakMap<World, ReadonlyMap<number, number>>();
1084
1092
  let latestCamera: CameraSnapshot | undefined;
1085
1093
  const pointsLinesOwner = new StandardPointsLinesOwner(internals);
1086
1094
  const instanceCollections = new InstanceProjectionStore();
@@ -2321,6 +2329,14 @@ export function createRenderSystem(internals: RenderSystemInternals): RenderSyst
2321
2329
  releaseProfilerCatalog?.();
2322
2330
  releaseProfilerCatalog = undefined;
2323
2331
  },
2332
+ invalidateGeometryHistory(): void {
2333
+ frameState.temporalFrameTransaction.reset('signature-change');
2334
+ frameState.temporalFrame = undefined;
2335
+ frameState.temporalFrameInput = undefined;
2336
+ frameState.lastSuccessfulTemporalView = undefined;
2337
+ frameState.successfulTemporalFrameIndex = 0;
2338
+ frameState.pendingTemporalCommit = { kind: 'none' };
2339
+ },
2324
2340
  get renderScene(): RenderSceneInspection {
2325
2341
  return {
2326
2342
  ...persistentRenderScene.inspect(),
@@ -2525,6 +2541,10 @@ export function createRenderSystem(internals: RenderSystemInternals): RenderSyst
2525
2541
  const profileSession = internals.profiler?.activeSession();
2526
2542
  let ownsProfileFrame = false;
2527
2543
  let submitted = false;
2544
+ // A failed draw must never leave a previous frame eligible to publish a
2545
+ // newly accepted dynamic-geometry candidate.
2546
+ lastSubmittedDynamicGeometryBindings = new WeakMap();
2547
+ const frameConsumedDynamicGeometryBindings = new WeakMap<World, Map<number, number>>();
2528
2548
  if (profileSession !== undefined && opts.profileFrame === undefined) {
2529
2549
  try {
2530
2550
  ownsProfileFrame = profileSession.beginFrame(++directFrameId).ok;
@@ -3208,8 +3228,22 @@ export function createRenderSystem(internals: RenderSystemInternals): RenderSyst
3208
3228
  persistentRenderScene.transmissionTopologyDemand(),
3209
3229
  volumetricFog,
3210
3230
  timingCapture,
3231
+ (entry) => {
3232
+ const renderable = entry.source;
3233
+ const world = compositionWorlds[renderable.worldId];
3234
+ if (world === undefined) return;
3235
+ let bindings = frameConsumedDynamicGeometryBindings.get(world);
3236
+ if (bindings === undefined) {
3237
+ bindings = new Map();
3238
+ frameConsumedDynamicGeometryBindings.set(world, bindings);
3239
+ }
3240
+ bindings.set(renderable.entityKey, renderable.assetHandle);
3241
+ },
3211
3242
  ),
3212
3243
  );
3244
+ if (submitted) {
3245
+ lastSubmittedDynamicGeometryBindings = frameConsumedDynamicGeometryBindings;
3246
+ }
3213
3247
  if (submitted) {
3214
3248
  try {
3215
3249
  internals.recoveryColdWorkGuard?.finish();
@@ -3345,6 +3379,14 @@ export function createRenderSystem(internals: RenderSystemInternals): RenderSyst
3345
3379
  }
3346
3380
  return submitted;
3347
3381
  },
3382
+ isDynamicGeometryConsumed(
3383
+ world: World,
3384
+ entity: number,
3385
+ meshHandle: number | undefined,
3386
+ ): boolean {
3387
+ if (!Number.isInteger(entity) || meshHandle === undefined) return false;
3388
+ return lastSubmittedDynamicGeometryBindings.get(world)?.get(entity) === meshHandle;
3389
+ },
3348
3390
  pipelineDispatchCounts: dispatchCounts,
3349
3391
  observeCurrentFrame(options: FrameObservationOptions) {
3350
3392
  const currentFrameId = frameState.frameNumber - 1;
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/components/glyph-text.ts","../src/components/sprite-playback-mode.ts","../src/systems/active-camera.ts"],"names":[],"mappings":";;;AAkDO,IAAM,SAAA,GAAY,gBAAgB,WAAA,EAAa;AAAA;AAAA;AAAA,EAGpD,UAAA,EAAY;AAAA,IACV,IAAA,EAAM,mBAAA;AAAA,IACN,OAAA,EAAS,CAAA;AAAA,IACT,mBAAA,EAAqB;AAAA,GACvB;AAAA,EACA,IAAA,EAAM,EAAE,IAAA,EAAM,QAAA,EAAU,SAAS,EAAA,EAAG;AAAA,EACpC,QAAA,EAAU,EAAE,IAAA,EAAM,KAAA,EAAO,SAAS,EAAA,EAAG;AAAA;AAAA;AAAA,EAGrC,KAAA,EAAO,EAAE,IAAA,EAAM,eAAA,EAAiB,OAAA,EAAS,IAAI,YAAA,CAAa,CAAC,CAAA,EAAG,CAAA,EAAG,CAAA,EAAG,CAAC,CAAC,CAAA;AACxE,CAAC;;;ACXM,IAAM,yBAAA,GAA4B,CAAA;AAGlC,IAAM,0BAAA,GAA6B,CAAA;AAGnC,IAAM,cAAA,GAAiB,OAAO,MAAA,CAAO;AAAA,EAC1C,IAAA,EAAM,yBAAA;AAAA,EACN,KAAA,EAAO;AACT,CAAU;AAeH,SAAS,0BAA0B,KAAA,EAAmC;AAC3E,EAAA,OAAO,KAAA,KAAU,6BAA6B,OAAA,GAAU,MAAA;AAC1D;;;ACnBO,IAAM,iBAAA,GAAoB,cAAA;AAkB1B,SAAS,gBAAgB,KAAA,EAAwC;AACtE,EAAA,IAAI,CAAC,KAAA,CAAM,WAAA,CAAY,iBAAiB,GAAG,OAAO,MAAA;AAClD,EAAA,OAAO,KAAA,CAAM,YAA0B,iBAAiB,CAAA;AAC1D;AAUO,SAAS,eAAA,CAAgB,OAAc,MAAA,EAAsB;AAClE,EAAA,KAAA,CAAM,cAAA,CAA6B,iBAAA,EAAmB,EAAE,MAAA,EAAQ,CAAA;AAClE;AAiBO,SAAS,uBAAA,CACd,gBACA,YAAA,EACQ;AACR,EAAA,IAAI,YAAA,KAAiB,QAAW,OAAO,EAAA;AACvC,EAAA,OAAO,cAAA,CAAe,QAAQ,YAAY,CAAA;AAC5C","file":"chunk-FJ6P52EE.mjs","sourcesContent":["// @forgeax/engine-runtime - GlyphText component (feat-20260531-world-space-msdf-text-rendering M4 / w14).\n//\n// `GlyphText` is the authoring source component for world-space MSDF text\n// (requirements AC-06 / §domain model). It carries ONLY authoring data; the\n// glyph quad baking + MeshFilter / MeshRenderer attachment is the job of the\n// `glyphTextLayoutSystem` (plan-strategy D-2: GlyphText is pure authoring\n// data, baking is a system responsibility). There is NO `TextLayoutAsset`\n// intermediate (OOS-5) -- layout output lives directly in a baked MeshAsset.\n//\n// Naming: single-semantic component drops the `Component` suffix\n// (AGENTS.md §Component naming rule #1 -- Transform / Camera / GlyphText).\n//\n// Schema vocab:\n// - `fontHandle: 'shared<FontAsset>'` -> `Handle<'FontAsset', 'shared'>`\n// (u32-stored; AssetRegistry owns the FontAsset lifecycle). AI users\n// obtain the handle via `assets.loadByGuid<FontAsset>(guid)`.\n// - `text: 'string'` -> native JS string (UniqueRefStore-backed, same\n// dispatch as `Name.value`).\n// - `fontSize: 'f32'` -> layout scale applied to the FontAsset metrics.\n// - `color: 'array<f32, 4>'` -> linear-space rgba tint (feat-20260709 M3:\n// collapsed from four `colorR/G/B/A` scalar columns into one inline\n// stride-4 SoA column, mirroring the DirectionalLight direction/color\n// idiom). Explicit layer-2 default [1,1,1,1] (opaque white).\n//\n// charter mapping: P1 (single import surface from `@forgeax/engine-render`,\n// co-located with `glyphTextLayoutSystem` that consumes it) + P3\n// (machine-readable schema literal) + P5 (consistent abstraction: same\n// `'shared<T>'` idiom as MeshFilter.assetHandle / MeshRenderer.materials).\n\nimport { defineComponent } from '@forgeax/engine-ecs';\n\n/**\n * Glyph text authoring component (world-space MSDF text).\n *\n * Spawn an entity with a `GlyphText` and the `glyphTextLayoutSystem`\n * (auto-wired by `createRenderer` / `createApp`) lays out the glyph quads,\n * bakes a `MeshAsset`, and attaches `MeshFilter` + `MeshRenderer` on the\n * next frame (Commands-deferred). Mutating `text` / `fontSize` / `color`\n * re-bakes the mesh in place (plan-strategy D-1 updateMesh; registry size\n * unchanged, AC-08).\n *\n * @example Spawn a world-space label:\n * import { GlyphText } from '@forgeax/engine-render/authoring';\n * const font = (await assets.loadByGuid(fontGuid)).unwrap();\n * world.spawn({\n * component: GlyphText,\n * data: { fontHandle: font, text: 'Hello', fontSize: 32,\n * color: [1, 1, 1, 1] },\n * });\n */\nexport const GlyphText = defineComponent('GlyphText', {\n // The layout/render owner re-resolves the font asset on the target world;\n // authoring text and style remain portable simulation state.\n fontHandle: {\n type: 'shared<FontAsset>',\n default: 0 as never,\n simulationTransient: true,\n },\n text: { type: 'string', default: '' },\n fontSize: { type: 'f32', default: 16 },\n // color carries an explicit layer-2 default [1,1,1,1] (opaque white); the\n // array layer-3 fallback is all-zero, so the default MUST be explicit (D-5).\n color: { type: 'array<f32, 4>', default: new Float32Array([1, 1, 1, 1]) },\n});\n","// @forgeax/engine-runtime - SpritePlaybackMode (u32 column encoding + mapper).\n//\n// SSOT for the SpriteAnimation.playbackMode ECS column (M2 T-12) and the\n// runtime tick-system branch selector (M4 T-23). The shape mirrors the M1\n// `Tonemap` block in `./camera.ts:72-90` (TONEMAP_NONE = 0 /\n// TONEMAP_REINHARD_EXTENDED = 1 + `type Tonemap = 'none' |\n// 'reinhard-extended'` + `tonemapFromF32`) for charter P4 consistent\n// abstraction — AI users keep one mental model across all u32-encoded\n// closed-union schema columns.\n//\n// Why `'u32'` + constant + mapper instead of a string-literal column?\n// ECS schema whitelist `SchemaFieldType` (packages/ecs/src/component.ts\n// section schema-field-type) does not accept string-literal unions\n// (research F-2 + F-5). Storing a u32 + translating to a closed\n// `'loop' | 'clamp'` literal union at the tick-system seam preserves\n// AI-user-facing narrowing (`switch (mode) { case 'loop': ... }`)\n// without forcing the ECS column to learn a new field-type vocabulary.\n//\n// Naming convention (plan-strategy section 8.command naming):\n// SPRITE_PLAYBACK_MODE_LOOP / SPRITE_PLAYBACK_MODE_CLAMP mirror M1\n// TONEMAP_* / TRANSPARENT_SORT_MODE_*. `spritePlaybackModeFromU32`\n// mirrors `tonemapFromF32` / `cameraProjectionFromF32`. AI users\n// discover the trio via single-import barrel re-export from\n// `@forgeax/engine-runtime` (wired in by M2 T-13).\n//\n// Anchors: plan-strategy section 2 D-5 + section 3.1 PR block SPM +\n// section 4 risk R-SCHEMA-1 reaction; research F-2 + F-5;\n// requirements section AC-02 + section 2.3 playbackMode row;\n// plan-tasks.json T-07 acceptanceCheck (rg\n// \"SPRITE_PLAYBACK_MODE_LOOP|SPRITE_PLAYBACK_MODE_CLAMP|\n// spritePlaybackModeFromU32\" >= 3 hits in this file).\n\n/**\n * Playback mode discriminator literal union (requirements section AC-02 +\n * section 2.3). Two members for the MVP:\n *\n * `'loop'` — `currentFrame = (currentFrame + 1) % frameCount`; the\n * sprite-animation-tick system wraps the index when\n * `accumDt >= frameDuration` (default for hello-sprite-\n * atlas demo \"100 sprites synchronised walk cycle\").\n * `'clamp'` — `currentFrame = min(currentFrame + 1, frameCount - 1)`;\n * holds the last frame for death / terminator-animation\n * style sequences (requirements section 2.5 q8 lock).\n *\n * Future modes (`'pingpong'`, reverse playback, arbitrary frame index\n * jumps) are deferred per requirements OOS-03. The closed union shape\n * leaves room for additive growth without breaking the u32 enum encoding\n * (plan-strategy section 2 D-5).\n */\nexport type SpritePlaybackMode = 'loop' | 'clamp';\n\n/** Numeric encoding of the loop playback mode (schema value for `playbackMode`). */\nexport const SPRITE_PLAYBACK_MODE_LOOP = 0 as const;\n\n/** Numeric encoding of the clamp playback mode (schema value for `playbackMode`). */\nexport const SPRITE_PLAYBACK_MODE_CLAMP = 1 as const;\n\n/** Grouped authoring values; the numeric ECS encoding stays owner-local. */\nexport const SpritePlayback = Object.freeze({\n loop: SPRITE_PLAYBACK_MODE_LOOP,\n clamp: SPRITE_PLAYBACK_MODE_CLAMP,\n} as const);\n\n/**\n * Map a `SpriteAnimation.playbackMode` numeric column value to the closed\n * `SpritePlaybackMode` string-literal union. The defensive fallback mirrors\n * `cameraProjectionFromF32` / `tonemapFromF32` precedent — a value other\n * than `SPRITE_PLAYBACK_MODE_CLAMP` (1) maps to `'loop'`, so stale or\n * uninitialised entities surface a predictable playback shape through the\n * tick-system query (rather than throwing or returning `undefined`).\n * Validation of `playbackMode` happens at schema-write time, not here.\n *\n * The tick-system in M4 T-23 (`spriteAnimationTickSystem`) consumes the\n * return value via `switch (mode) { case 'loop': ...; case 'clamp': ... }`\n * to pick the per-branch frame-advance arithmetic.\n */\nexport function spritePlaybackModeFromU32(value: number): SpritePlaybackMode {\n return value === SPRITE_PLAYBACK_MODE_CLAMP ? 'clamp' : 'loop';\n}\n","// @forgeax/engine-runtime - ActiveCamera KV resource + selection helper.\n//\n// feat-20260630-viewport-2x2-run-x-display-redesign M2 w12 / plan-strategy D-2.\n//\n// PROBLEM (research Finding 4): the render extract stage selects the camera by\n// archetype-query FIRST-HIT (`cameras[0]`) and fires `render-system-multi-camera`\n// when more than one [Camera, Transform, Entity] entity exists. A single engine\n// world that carries BOTH an editor orbit camera AND a game camera therefore\n// cannot pick which one renders. This module adds the minimum neutral mechanism:\n// an `ActiveCamera { entity }` resource naming the entity to render through.\n//\n// ENGINE-NEUTRAL (requirements OOS-4 / AC-16): the engine knows only entity IDs.\n// It does NOT know which entity is \"editor\" or \"game\" -- the caller (editor side)\n// decides what `ActiveCamera.entity` points at. No editor concept enters this\n// file or the engine layer.\n//\n// BACKWARD COMPATIBLE (plan-strategy D-2): when the resource is ABSENT, or the\n// named entity is not among the queried cameras, selection falls back to the\n// existing first-hit behavior unchanged. Single-camera scenes are unaffected.\n//\n// Surface:\n// - interface ActiveCamera { entity: number }\n// - constant ACTIVE_CAMERA_KEY = 'ActiveCamera'\n// - helper getActiveCamera(world): ActiveCamera | undefined\n// - helper setActiveCamera(world, entity): void\n// - pure selectActiveCameraIndex(cameraEntities, activeEntity): number\n//\n// @new-surface KV resource: ECS has no defineResource factory; the TS POD\n// interface + string KV key form is the minimum-new-surface route, mirroring\n// TransparentSortConfig. The world resource store\n// (insertResource / getResource / hasResource) is reused unchanged.\n// @derives world.hasResource / world.insertResource / world.getResource KV API.\n// @fallback getActiveCamera KV missing returns undefined; no warn; no throw\n// (absent ActiveCamera is a legal state -> first-hit fallback in extract).\n//\n// charter mapping: F1 (single-import barrel + single entity pointer, smallest\n// concept face vs a multi-field priority/enabled scheme) + P4 (consistent\n// abstraction -- same world.{has,get,insert}Resource KV API as every other\n// engine resource consumer).\n\nimport type { World } from '@forgeax/engine-ecs';\n\n// ────────────────────────────────────────────────────────────────────────────\n// POD interface + KV key\n// ────────────────────────────────────────────────────────────────────────────\n\n/**\n * Active-camera pointer (plain-data POD). Lives as a world-level resource keyed\n * by `ACTIVE_CAMERA_KEY`. `entity` is the packed entity id (engine `EntityHandle`\n * is a branded number) of the camera the renderer should use.\n *\n * The engine treats `entity` as an opaque id; whether it names an editor camera\n * or a game camera is a caller-side decision (OOS-4 — engine stays neutral).\n */\nexport interface ActiveCamera {\n readonly entity: number;\n}\n\n/** World resource key for the `ActiveCamera` KV entry. */\nexport const ACTIVE_CAMERA_KEY = 'ActiveCamera' as const;\n\n// ────────────────────────────────────────────────────────────────────────────\n// Helpers\n// ────────────────────────────────────────────────────────────────────────────\n\n/**\n * Read the world's `ActiveCamera` resource.\n *\n * @fallback KV missing returns `undefined` — absent ActiveCamera is a legal\n * state (the renderer falls back to archetype first-hit). NO warn, NO throw.\n * The `hasResource` guard precedes the read so this helper never trips\n * `ResourceNotFoundError` from `world.getResource`.\n *\n * @example\n * const a = getActiveCamera(world); // undefined when not set\n * if (a) { ... a.entity ... }\n */\nexport function getActiveCamera(world: World): ActiveCamera | undefined {\n if (!world.hasResource(ACTIVE_CAMERA_KEY)) return undefined;\n return world.getResource<ActiveCamera>(ACTIVE_CAMERA_KEY);\n}\n\n/**\n * Write the world's `ActiveCamera` resource (idempotent last-write-wins via\n * `world.insertResource`). The renderer will use the camera whose entity id\n * equals `entity`, or fall back to first-hit if that id is not a queried camera.\n *\n * @example\n * setActiveCamera(world, gameCameraEntity); // render through the game camera\n */\nexport function setActiveCamera(world: World, entity: number): void {\n world.insertResource<ActiveCamera>(ACTIVE_CAMERA_KEY, { entity });\n}\n\n/**\n * Pure selection: given the entity ids of every camera surfaced by the extract\n * archetype query (in query order) and the optional active-camera entity id,\n * return the index of the active camera in `cameraEntities`, or `-1` to signal\n * \"no selection — use first-hit fallback\".\n *\n * `-1` is returned when `activeEntity` is `undefined` (resource absent) OR when\n * the id is not present among `cameraEntities` (stale / non-camera entity).\n * Both cases preserve the existing first-hit behavior (plan-strategy D-2).\n *\n * @example\n * selectActiveCameraIndex([10, 20, 30], 20) === 1 // pick the 2nd camera\n * selectActiveCameraIndex([10, 20, 30], undefined) === -1 // first-hit\n * selectActiveCameraIndex([10, 20, 30], 999) === -1 // first-hit\n */\nexport function selectActiveCameraIndex(\n cameraEntities: readonly number[],\n activeEntity: number | undefined,\n): number {\n if (activeEntity === undefined) return -1;\n return cameraEntities.indexOf(activeEntity);\n}\n"]}
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/components/sprite-animation.ts","../src/components/sprite-instances.ts","../src/components/sprite-region-override.ts","../src/components/tile-layer.ts","../src/components/tilemap.ts"],"names":["defineComponent"],"mappings":";;;AAgKO,IAAM,eAAA,GAAkB,gBAAgB,iBAAA,EAAmB;AAAA,EAChE,UAAA,EAAY,EAAE,IAAA,EAAM,KAAA,EAAM;AAAA,EAC1B,aAAA,EAAe,EAAE,IAAA,EAAM,KAAA,EAAM;AAAA,EAC7B,YAAA,EAAc,EAAE,IAAA,EAAM,KAAA,EAAO,WAAW,IAAA,EAAK;AAAA,EAC7C,OAAA,EAAS,EAAE,IAAA,EAAM,KAAA,EAAO,WAAW,IAAA,EAAK;AAAA,EACxC,OAAA,EAAS,EAAE,IAAA,EAAM,YAAA,EAAa;AAAA,EAC9B,YAAA,EAAc,EAAE,IAAA,EAAM,KAAA;AACxB,CAAC;ACdM,IAAM,eAAA,GAAkBA,gBAAgB,iBAAA,EAAmB;AAAA,EAChE,UAAA,EAAY,EAAE,IAAA,EAAM,YAAA,EAAa;AAAA,EACjC,OAAA,EAAS,EAAE,IAAA,EAAM,YAAA;AACnB,CAAC;ACtDM,IAAM,oBAAA,GAAuBA,gBAAgB,sBAAA,EAAwB;AAAA,EAC1E,MAAA,EAAQ,EAAE,IAAA,EAAM,eAAA;AAClB,CAAC;AC7CM,IAAM,WAAA,GAAc,OAAO,MAAA,CAAO;AAAA,EACvC,KAAA,EAAO,CAAA;AAAA,EACP,OAAA,EAAS;AACX,CAAC;AAuBM,SAAS,gBAAgB,GAAA,EAAoC;AAClE,EAAA,OAAO,GAAA,KAAQ,IAAI,UAAA,GAAa,OAAA;AAClC;AAwDO,IAAM,SAAA,GAAYA,gBAAgB,WAAA,EAAa;AAAA,EACpD,KAAA,EAAO,EAAE,IAAA,EAAM,YAAA,EAAa;AAAA,EAC5B,UAAA,EAAY,EAAE,IAAA,EAAM,KAAA,EAAO,SAAS,CAAA,EAAE;AAAA,EACtC,KAAA,EAAO,EAAE,IAAA,EAAM,IAAA,EAAM,SAAS,CAAA,EAAE;AAAA,EAChC,SAAA,EAAW,EAAE,IAAA,EAAM,IAAA,EAAM,SAAS,CAAA;AACpC,CAAC;AC7GM,IAAM,OAAA,GAAUA,gBAAgB,SAAA,EAAW;AAAA,EAChD,IAAA,EAAM,EAAE,IAAA,EAAM,KAAA,EAAO,SAAS,CAAA,EAAE;AAAA,EAChC,IAAA,EAAM,EAAE,IAAA,EAAM,KAAA,EAAO,SAAS,CAAA,EAAE;AAAA;AAAA;AAAA,EAGhC,QAAA,EAAU,EAAE,IAAA,EAAM,eAAA,EAAiB,OAAA,EAAS,IAAI,YAAA,CAAa,CAAC,CAAA,EAAG,CAAC,CAAC,CAAA,EAAE;AAAA,EACrE,SAAA,EAAW,EAAE,IAAA,EAAM,KAAA,EAAO,SAAS,EAAA,EAAG;AAAA,EACtC,OAAA,EAAS,EAAE,IAAA,EAAM,QAAA;AACnB,CAAC","file":"chunk-JDWARUOI.mjs","sourcesContent":["// @forgeax/engine-runtime - SpriteAnimation (per-entity frame-tick clock).\n//\n// 6-field schema (1:1 with requirements section 2.3 table; D-5 + D-6\n// vocab keywords locked):\n//\n// frameCount : 'u32' total frames in the cycle (>= 1).\n// frameDuration : 'f32' per-frame seconds (> 0).\n// currentFrame : 'u32' live frame index, in [0, frameCount).\n// accumDt : 'f32' dt accumulator (research F-3 carries\n// fractional residue across ticks).\n// regions : 'array<f32>' flat per-frame UV rectangles\n// [uMin, vMin, uW, vH]; the M4 tick\n// system enforces\n// `regions.length === frameCount * 4`\n// on first observation (AC-09 fail-fast,\n// D-1 path).\n// playbackMode : 'u32' numeric encoding of `SpritePlaybackMode`\n// (0 = SPRITE_PLAYBACK_MODE_LOOP,\n// 1 = SPRITE_PLAYBACK_MODE_CLAMP).\n//\n// Why u32 for playbackMode? research F-2 + F-5: the ECS schema whitelist\n// rejects string-literal unions. The M1 SSOT\n// `packages/runtime/src/components/sprite-playback-mode.ts` carries the\n// numeric constants + the `spritePlaybackModeFromU32` mapper that turns\n// the column value back into a string-literal union at the M4 tick-system\n// seam — same shape as the M1 Tonemap encoding (`tonemap: 'f32'` +\n// TONEMAP_NONE / TONEMAP_REINHARD_EXTENDED + tonemapFromF32). One mental\n// model across all closed-union schema columns; charter P4 consistent\n// abstraction.\n//\n// Why array<f32> (variable) and not array<f32, N> (fixed) for regions?\n// The length `frameCount * 4` is data-dependent: `frameCount` is itself a\n// per-entity column read at runtime, not a schema-time literal. Same\n// shape as M1 `Instances.transforms: 'array<f32>'` whose length depends\n// on the live instance count; D-6 codifies the precedent.\n//\n// dt accumulator clock model (requirements section 2.5 q6 + plan-strategy\n// section 2 D-5):\n// accumDt += Time.delta\n// while (accumDt >= frameDuration) { advance currentFrame; accumDt -= frameDuration }\n// The carry-over `accumDt` survives across ticks so frame timing stays\n// stable under jittery dt; M4 T-23 implements the loop. Per-frame UV\n// is materialised by writing the slice\n// `regions[currentFrame*4 .. currentFrame*4 + 4]` into the entity's\n// `SpriteRegionOverride.region` column (T-11 + M4 T-23).\n//\n// Sprite-only consumption (requirements section 5 constraint #2): the\n// M4 tick system + the M3 extract branch read this component only when\n// the entity routes through the sprite bucket. Opaque buckets ignore\n// the component even when present. Naming keeps the `Sprite` prefix per\n// OOS-01 (no premature 3D generalisation); 3D UV animation lands in a\n// separate component in a future feat.\n//\n// 4-step recipe (charter F1 progressive disclosure — minimum walk-cycle\n// host code; full demo lands in M6 hello-sprite-atlas):\n// 1. Build atlas via `forgeax-engine-remote-asset atlas --input <glob> --name <prefix> --output <dir>`\n// (M5 build-time hook); load the emitted `<name>.atlas.png` plus\n// `<name>.atlas.meta.json` sidecar.\n// 2. Register the sprite material referencing the atlas\n// `TextureAsset` with the initial frame's `region` rectangle (the\n// runtime tick system overrides this region per entity per frame).\n// 3. `world.spawn` an entity with `MeshFilter(HANDLE_QUAD)` +\n// `MeshRenderer(spriteMaterial)` + `Instances(...)` plus this\n// `SpriteAnimation` (frameCount / frameDuration / regions /\n// playbackMode) and `SpriteRegionOverride { region }` (the tick\n// system writes per-frame UV).\n// 4. Add `spriteAnimationTickSystem` (M4 T-23) to the schedule between\n// input/time and `RenderSystem.extract`.\n//\n// @derives `defineComponent` factory (packages/ecs/src/component.ts) —\n// `'u32'` / `'f32'` are scalar tier-1 keywords; `'array<f32>'` is a\n// tier-2 schema-vocab keyword (variable-capacity; M1 feat-20260515\n// buffer-array vocab). Layer-3 default fallback fills missing\n// `currentFrame` / `accumDt` / `playbackMode` with 0\n// (component-default-fallback typeDefault table).\n// @consumes M4 T-23 (`spriteAnimationTickSystem` reads all 6 fields and\n// writes per-frame slice into SpriteRegionOverride).\n//\n// charter mapping: F1 (single-import barrel discovery + 4-step recipe\n// at the JSDoc head); P3 (4 explicit error fields surface via the M4\n// tick-system fail-fast — `regions.length` mismatch / `frameDuration`\n// non-positive routes through SpriteAnimationInvalidError, M1 T-05);\n// P4 (consistent abstraction — same numeric closed-union encoding as\n// Tonemap / TransparentSortConfig.mode); P5 (producer / consumer\n// separation — atlas regions are produced build-time by\n// vite-plugin-image and consumed runtime as a flat Float32Array; AI\n// users never call a packer at runtime).\n//\n// Anchors: plan-strategy section 2 D-5 + D-6 + section 3.1 SAC + section\n// 4 risks R-SCHEMA-1 + R-SCHEMA-2 + R-TIME-1 reaction; plan-tasks.json\n// T-12; research F-2 + F-3 + F-5; requirements section AC-02 + section\n// 2.3 + section 2.5 + section 7 boundary table.\n\nimport { defineComponent } from '@forgeax/engine-ecs';\n\n/**\n * Per-entity sprite frame-tick clock.\n *\n * Schema (6 fields, 1:1 with requirements section 2.3):\n * - `frameCount: u32` — total frames (>= 1).\n * - `frameDuration: f32` — seconds per frame (> 0).\n * - `currentFrame: u32` — live frame index (0..frameCount). Layer-3\n * default `0` when omitted at spawn.\n * - `accumDt: f32` — dt residue carried across ticks. Layer-3 default\n * `0` when omitted; lives on the component (not on a system-side\n * resource) so the system stays stateless and AI-user setFrame paths\n * can reset both `currentFrame` and `accumDt` atomically.\n * - `regions: array<f32>` — flat per-frame UV rectangles\n * `[uMin, vMin, uW, vH]`; runtime invariant\n * `regions.length === frameCount * 4` (enforced by the M4 tick system).\n * - `playbackMode: u32` — numeric encoding of `SpritePlaybackMode`;\n * `SPRITE_PLAYBACK_MODE_LOOP = 0` (default) /\n * `SPRITE_PLAYBACK_MODE_CLAMP = 1`.\n *\n * Pair with `SpriteRegionOverride` (T-11) — the tick system writes the\n * per-frame UV slice into the override column, which `render-system-\n * extract` then reads in the sprite bucket branch.\n *\n * @example Spawn a 4-frame walk cycle that loops:\n * import { MeshFilter, MeshRenderer } from '@forgeax/engine-render';\n * import {\n * SpriteAnimation, SpriteRegionOverride, SPRITE_PLAYBACK_MODE_LOOP,\n * } from '@forgeax/engine-render/authoring';\n * import { Transform } from '@forgeax/engine-scene';\n * import { HANDLE_QUAD } from '@forgeax/engine-assets-runtime';\n *\n * // 4 frames packed in an atlas, each 64x64 inside a 256x64 row:\n * const regions = new Float32Array([\n * 0.00, 0, 0.25, 1, // walk-0\n * 0.25, 0, 0.25, 1, // walk-1\n * 0.50, 0, 0.25, 1, // walk-2\n * 0.75, 0, 0.25, 1, // walk-3\n * ]);\n * world.spawn(\n * { component: Transform, data: { pos: [0, 0, 0],\n * quat: [0, 0, 0, 1], scale: [1, 1, 1] } },\n * { component: MeshFilter, data: { assetHandle: HANDLE_QUAD } },\n * { component: MeshRenderer, data: { materials: [spriteMaterial] } },\n * { component: SpriteAnimation,\n * data: {\n * frameCount: 4,\n * frameDuration: 0.1,\n * regions,\n * playbackMode: SPRITE_PLAYBACK_MODE_LOOP,\n * } },\n * { component: SpriteRegionOverride,\n * data: { region: new Float32Array([0, 0, 0.25, 1]) } },\n * );\n *\n * @example Manual setFrame — jump to frame 2 atomically:\n * world.set(entity, SpriteAnimation,\n * { currentFrame: 2, accumDt: 0 }).unwrap();\n *\n * Error path (M4 T-23 routes through `SpriteAnimationInvalidError`,\n * `EcsErrorCode === 'sprite-animation-invalid'`; charter P3):\n * - `regions.length !== frameCount * 4` -> detail.field = 'regions-length',\n * detail carries `regionsLength` + `frameCount`.\n * - `frameDuration <= 0` -> detail.field = 'frame-duration', detail carries\n * `frameDuration`.\n */\nexport const SpriteAnimation = defineComponent('SpriteAnimation', {\n frameCount: { type: 'u32' },\n frameDuration: { type: 'f32' },\n currentFrame: { type: 'u32', transient: true },\n accumDt: { type: 'f32', transient: true },\n regions: { type: 'array<f32>' },\n playbackMode: { type: 'u32' },\n});\n","// @forgeax/engine-runtime - SpriteInstances component (per-entity 2D\n// instanced-draw transforms + per-instance UV region).\n//\n// Schema: 2 array<f32> fields. `transforms` carries packed column-major mat4\n// instance transforms (16 f32 per instance, stride = 16). `regions` carries\n// packed per-instance UV vec4 (4 f32 per instance, stride = 4; layout\n// [uMin, vMin, uW, vH] mirroring SpriteAnimation.regions). The pair share\n// the same instance count; the SSOT invariant is\n// transforms.length / 16 === regions.length / 4\n// enforced through a TWO-LAYER contract:\n//\n// 1. AI user set-site: spawn / `world.set` / `world.push` callers pass two\n// Float32Arrays whose lengths satisfy the stride pair. The set site is\n// the AI user's responsibility — the engine cannot prove column-major\n// mat4 packing or per-instance UV intent without caller declaration.\n// 2. RenderSystem extract entry: `render-system-extract.ts` performs a\n// defensive `transforms.length / 16 === regions.length / 4` check on\n// every `world.get(e, SpriteInstances)` snapshot at frame extract time;\n// violations route a structured `SpriteInstancesCountMismatchError`\n// (`code: 'sprite-instances-count-mismatch'`,\n// `detail: { transformsLength, regionsLength, expectedStride: { transforms: 16, regions: 4 } }`)\n// through the World Layer-3 ErrorHandler and the renderable is skipped.\n// Two further extract-entry checks fire:\n// - `'sprite-instances-requires-sprite-shader'` — MaterialAsset's\n// first pass `shader` must be `'forgeax::sprite'`.\n// - `'sprite-instances-mutually-exclusive-with-instances'` — same\n// entity must not carry both Instances + SpriteInstances.\n// (Error class declarations live in `@forgeax/engine-ecs` errors.ts; the\n// extract-entry fire path is owned by feat M3 w12 / w13.)\n//\n// Peer relation to Instances (charter P4 consistent abstraction):\n// - Instances : 3D scene primitive — per-instance mat4 only (stride 16).\n// - SpriteInstances : 2D scene primitive — per-instance mat4 + per-instance\n// UV region (interleaved 80B per instance: 64B mat4 + 16B region).\n// AI users pick by data shape (does the per-instance carry UV?). Both ride\n// the array-vocab path and route through the same Layer-3 error envelope.\n//\n// Group transform chained semantics (charter proposition 5 mental migration):\n// When the same entity carries both `Transform` (entity_world) and\n// `SpriteInstances` (per-instance local transforms), the vertex shader\n// composes per instance:\n// world_position[i] = entity_world * instances_local[i] * vertex_position\n// `SpriteInstances.transforms[i*16..i*16+15]` is interpreted as a local-\n// space transform under the entity, exactly like `Instances.transforms`.\n// Set the entity's `Transform` to identity to make `instances_local[i]`\n// directly world-space.\n//\n// Uniform / storage buffer cap (research D-R-4 + AC-04): 80B per instance\n// * 128-cap fallback = 10240 B < 16384 B uniform max; the storage-buffer\n// path is uncapped and used by capable backends (RenderSystem record-stage\n// cap-gate).\n//\n// 4-segment minimum contract (read this header before reaching for the\n// source body):\n//\n// ===== (a) single-component import + spawn example =====\n//\n// import {\n// createRenderer,\n// MeshFilter, MeshRenderer, Transform,\n// SpriteInstances, type SpriteInstancesData,\n// } from '@forgeax/engine-render';\n//\n// // 1 entity rendering N instanced sprites (16N transforms f32 + 4N regions f32):\n// const transforms = new Float32Array(N * 16);\n// const regions = new Float32Array(N * 4);\n// // ... fill column-major mat4 columns + [uMin, vMin, uW, vH] per instance ...\n// world.spawn(\n// { component: MeshFilter, data: { assetHandle: HANDLE_QUAD } },\n// { component: MeshRenderer, data: { /* sprite-shaded MaterialAsset */ } },\n// { component: SpriteInstances, data: { transforms, regions } },\n// );\n//\n// ===== (b) packed mat4 + region layout =====\n//\n// const transforms = new Float32Array(N * 16); // column-major mat4 per instance\n// const regions = new Float32Array(N * 4); // [uMin, vMin, uW, vH] per instance\n// // instance i occupies floats:\n// // transforms[i*16 .. i*16+15] — column-major mat4 (translation in m03/m13/m23)\n// // regions [i* 4 .. i* 4+ 3] — uMin, vMin, uW, vH (atlas-normalized UV rect)\n//\n// `transforms.length` MUST be a non-zero multiple of 16; `regions.length`\n// MUST be a non-zero multiple of 4; the per-instance count derived from\n// both MUST agree. AI users gate at the set / push site; the RenderSystem\n// extract entry holds the second defensive (see error path 1 below).\n//\n// ===== (c) error code consumption (typed property access, no message regex) =====\n//\n// // Stride / mutual-exclusion / sprite-shader violations surface through\n// // the engine-ecs Layer-3 ErrorHandler when extract reads a malformed\n// // snapshot:\n// // on('error', (err) => {\n// // switch (err.code) {\n// // case 'sprite-instances-count-mismatch':\n// // // err.detail.transformsLength / regionsLength / expectedStride\n// // break;\n// // case 'sprite-instances-requires-sprite-shader':\n// // // err.detail.entityId / observedMaterialShaderId\n// // break;\n// // case 'sprite-instances-mutually-exclusive-with-instances':\n// // // err.detail.entityId\n// // break;\n// // }\n// // });\n//\n// charter mapping (charter v2 numbering, with round-1 v1 aliases retained\n// in parentheses so reviewers tracking the v1 spec can cross-reference;\n// SSOT for the v2 -> v1 alias map is the AI User Charter — see\n// .claude/skills/forgeax-closed-loop/agents/ai-user-charter.md):\n// F1 (alias: proposition 1) — single import surface\n// `import { SpriteInstances, type SpriteInstancesData } from\n// '@forgeax/engine-render'`;\n// P2 (alias: proposition 3) — machine-readable schema > prose:\n// `{ transforms: 'array<f32>', regions: 'array<f32>' }` is the SSOT,\n// stride pair documented here + enforced at the RenderSystem entry;\n// P3 (alias: proposition 4) — explicit failure: 3 structured EcsError\n// codes route per failure shape, not silent half-row;\n// P4 (alias: proposition 5) — consistent abstraction: SpriteInstances\n// mirrors Instances — both ride the array-vocab path; pick by data\n// shape, not by API.\n//\n// Anchors: requirements AC-01 (component schema), AC-03 (3 error codes),\n// AC-09 (IDE autocomplete + type inference); plan-strategy D-1 (interleaved\n// single binding slot), D-6 (3 codes declared in M1 ecs, fired in M3 render),\n// D-7 (type export location), D-8 (barrel re-export at runtime, not ecs).\n\nimport { defineComponent } from '@forgeax/engine-ecs';\n\n/**\n * Per-entity 2D instanced-draw primitive (ECS component).\n *\n * Carries two variable-length `array<f32>` fields:\n * - `transforms` — column-major mat4 per instance (16 f32, stride 16);\n * - `regions` — UV vec4 per instance ([uMin, vMin, uW, vH], 4 f32,\n * stride 4).\n *\n * The pair share the same instance count; the invariant\n * `transforms.length / 16 === regions.length / 4` is enforced at the\n * RenderSystem extract entry (NOT at ECS write paths). The runtime\n * RenderSystem consumer materialises a fresh `Float32Array` snapshot on\n * every `world.get(e, SpriteInstances)` access and uploads an interleaved\n * 80B-per-instance buffer to the GPU in the record stage.\n *\n * @example Spawn an entity rendering 10000 instanced sprites:\n * const transforms = new Float32Array(10000 * 16);\n * const regions = new Float32Array(10000 * 4);\n * // ... fill mat4 columns + [uMin, vMin, uW, vH] ...\n * world.spawn(\n * { component: MeshFilter, data: { assetHandle: HANDLE_QUAD } },\n * { component: MeshRenderer, data: { ... } },\n * { component: SpriteInstances, data: { transforms, regions } },\n * );\n */\nexport const SpriteInstances = defineComponent('SpriteInstances', {\n transforms: { type: 'array<f32>' },\n regions: { type: 'array<f32>' },\n});\n\n/**\n * Type-level hint for `data` at the `SpriteInstances` spawn site.\n *\n * The runtime ECS column shape for `array<f32>` accepts a `Float32Array`\n * payload at spawn / set time (the bytes are copied into the BufferPool\n * slot). Both fields are typed as `Float32Array` so AI-user IDE\n * autocomplete picks up the typed-array shape and the AC-09 inference\n * surfaces without `as` casts inside `world.get(e, SpriteInstances)` and\n * QueryRow access paths.\n *\n * @example\n * import type { SpriteInstancesData } from '@forgeax/engine-render/authoring';\n * const data: SpriteInstancesData = {\n * transforms: new Float32Array(N * 16),\n * regions: new Float32Array(N * 4),\n * };\n */\nexport type SpriteInstancesData = {\n readonly transforms: Float32Array;\n readonly regions: Float32Array;\n};\n","// @forgeax/engine-runtime - SpriteRegionOverride (per-entity UV region\n// override, sprite-only).\n//\n// Schema: 1 fixed-length array column `region: 'array<f32, 4>'` carrying a\n// packed UV sub-rectangle `[uMin, vMin, uW, vH]`. The fixed `4` length is\n// enforced at compile time by the ECS schema-vocab keyword\n// `'array<T, N>'` (D-6: prefer compile-time enforcement over runtime\n// fail-fast for length-bound contracts).\n//\n// Why an entity-side override component instead of a render parameter on\n// MaterialRenderer? Plan-strategy section 1 / requirements section 5\n// constraint #1 (Pipeline Isolation R1, M1 ssot): `AssetRegistry` stays\n// append-only / read-only and `MaterialRenderer` schema is frozen.\n// Per-entity render-parameter overrides ride the same rail as Layer /\n// SortKey — one ECS column per entity, read at extract time, never\n// flowing back to the asset.\n//\n// Sprite-only consumption (requirements section 5 constraint #2 + section\n// 7 boundary line 1): `render-system-extract` reads this component ONLY\n// inside the sprite bucket branch (`pipelineTag === 'sprite'` plus the\n// asset-side sprite discriminant). Opaque (`unlit` / `standard`) buckets\n// ignore the column even when the entity happens to carry it; the M3\n// T-15 grep gate\n// (`packages/runtime/src/__tests__/sprite-only-isolation-grep-gate.test.ts`)\n// asserts this file contains zero substring matches against the asset\n// discriminant identifier as a structural lock (charter P5 producer /\n// consumer separation: the component owner never names the asset\n// discriminant directly).\n//\n// Naming: bare `SpriteRegionOverride` (no `Component` suffix per\n// AGENTS.md section Component naming \"Single-semantic components drop\n// the Component suffix\"). The `Sprite` prefix is intentional — 3D UV\n// region animation lands in a separate component (e.g. future\n// `MaterialRegionOverride`) per OOS-01 to avoid premature 3D\n// generalisation. Plan-strategy section 8 naming convention pins the\n// prefix to make the AC-11 grep gate trivially decidable.\n//\n// @derives `defineComponent` factory (packages/ecs/src/component.ts) —\n// `'array<f32, 4>'` is on the `SchemaVocabKeyword` whitelist (line ~94\n// `array<${ManagedArrayElementType}, ${number}>`).\n// @consumes M3 T-16 (`render-system-extract.ts` sprite bucket branch\n// reads override into `paramSnapshot.region`, post-M3 feat-20260625\n// ablation -- the pre-ablation per-entity hop through the snapshot\n// POD layer collapsed into the generic paramSchema-driven path).\n// @produces M4 T-23 (`spriteAnimationTickSystem` writes per-frame UV\n// slice into `region`).\n//\n// charter mapping: F1 (single-import barrel discovery — joins Layer /\n// SortKey / Transform / SpriteAnimation under a single\n// `from '@forgeax/engine-render'`); P3 (compile-time length=4 lock\n// surfaces mismatched payloads at the TS edge before the column write\n// path observes them); P4 (consistent abstraction — same per-entity\n// override shape AI users already learned for Layer / SortKey).\n//\n// Anchors: plan-strategy section 2 D-6 + section 3.1 SRO + section 4\n// risk R-SCHEMA-2 reaction; plan-tasks.json T-11; research F-5;\n// requirements section AC-01 + section 2.4 + section 5 constraint #2.\n\nimport { defineComponent } from '@forgeax/engine-ecs';\n\n/**\n * Per-entity sprite UV region override (sprite-only).\n *\n * Carries a fixed-length `Float32Array(4)` of `[uMin, vMin, uW, vH]` that\n * `render-system-extract` reads in the sprite bucket branch and writes\n * into `paramSnapshot.region`, replacing the asset-side region for\n * this entity only (feat-20260625 M3 ablation: the prior per-entity POD\n * indirection collapsed into the generic paramSchema-driven snapshot).\n * Opaque buckets ignore this component; the AC-11 grep gate keeps the\n * producer / consumer separation structural (this file never names the\n * asset-side sprite discriminant identifier).\n *\n * Pair with `SpriteAnimation` to drive the override per frame\n * (sprite-animation-tick system, M4 T-23). Standalone use (manual region\n * override without animation) is also supported — set the column once and\n * the next extract picks up the new value.\n *\n * Schema-vocab keyword `'array<f32, 4>'` (D-6) — fixed-length 4 is the\n * compile-time enforcement axis. `Float32Array` payloads with length !== 4\n * round-trip fewer / more bytes per the underlying BufferPool slot, but\n * the slot is sized at schema registration time; AI users keep the\n * payload exactly four-floats wide.\n *\n * @example Spawn a sprite entity that displays the right half of an atlas:\n * import { MeshFilter, MeshRenderer } from '@forgeax/engine-render';\n * import { SpriteRegionOverride } from '@forgeax/engine-render/authoring';\n * import { Transform } from '@forgeax/engine-scene';\n * import { HANDLE_QUAD } from '@forgeax/engine-assets-runtime';\n *\n * world.spawn(\n * { component: Transform, data: { pos: [0, 0, 0],\n * quat: [0, 0, 0, 1], scale: [1, 1, 1] } },\n * { component: MeshFilter, data: { assetHandle: HANDLE_QUAD } },\n * { component: MeshRenderer, data: { materials: [spriteMaterial] } },\n * { component: SpriteRegionOverride,\n * data: { region: new Float32Array([0.5, 0, 0.5, 1]) } },\n * );\n *\n * @example Update the override at runtime (e.g. flip-frame test):\n * world.set(entity, SpriteRegionOverride,\n * { region: new Float32Array([0, 0, 1, 1]) }).unwrap();\n */\nexport const SpriteRegionOverride = defineComponent('SpriteRegionOverride', {\n region: { type: 'array<f32, 4>' },\n});\n","// @forgeax/engine-runtime - TileLayer (one render layer of a Tilemap).\n//\n// Schema (4 fields):\n// tiles array<u32> per-cell tile id (packed Tiled .tmj wire form);\n// length == parent.Tilemap.cols * rows.\n// layerOrder i32 render-order key (higher = drawn on top).\n// dirty u8 0 means clean, non-zero means dirty - the next\n// tilemap-chunk-extract-system pass rebuilds derived\n// per-cell entities for this layer.\n// sortScope u8 (TS: 0 = 'layer' (default) - terrain semantics:\n// closed every derived entity in this\n// 'layer' | layer shares one Layer.value\n// 'per-cell' (layerOrder << 20), so the\n// union) whole layer Y-sorts as a single\n// bucket. AI users grep\n// `sortScope: 'layer'` to confirm.\n// 1 = 'per-cell' - object semantics: each derived\n// entity carries its own foot-Y\n// key; preserves per-cell entity\n// derivation for Y-interleave\n// with sprite entities sharing\n// the same Layer.value.\n//\n// Storage rationale (D-V-3 R-NEW-1 fallback / round-2): ECS schema does not\n// yet support `values: ['layer', 'per-cell']` literal-union constraints\n// on a `'string'` column, so the on-disk shape is `u8` (0/1) but every\n// AI-facing TS surface (`TileLayerData.sortScope`) is the closed\n// `'layer' | 'per-cell'` string union. The bridge functions\n// `encodeSortScope` / `decodeSortScope` are the SSOT for the two-way\n// mapping (charter F1 — single grep target).\n//\n// Each TileLayer entity attaches to a Tilemap entity via ChildOf (the parent\n// field carries the Tilemap entity). M0 supports multiple TileLayer entities\n// per Tilemap (each rendered as an independent z-ordered layer).\n//\n// charter mapping: F1 (single-import barrel + single grep target for\n// `sortScope`), P1 (progressive disclosure — `'layer'` / `'per-cell'`\n// reads directly without a magic-number table), P3 (dirty flag is\n// explicit - silent re-extraction is forbidden), P4 (handle-free row\n// schema mirrors MeshFilter / MeshRenderer conventions).\n\nimport { defineComponent, type EcsError, type EntityHandle, type World } from '@forgeax/engine-ecs';\nimport type { Result } from '@forgeax/engine-types';\n\n/**\n * Closed string-literal union for the TileLayer.sortScope field. AI users\n * grep `sortScope` and land on this single SSOT type alias; switch\n * statements over this type are exhaustive without a default arm.\n *\n * - 'layer' - terrain semantics; whole layer shares one Layer.value\n * bucket and Y-sorts together (chunkIndex is folded into 0).\n * - 'per-cell' - object semantics; each derived entity gets its own\n * chunkIndex tiebreak so it can Y-interleave with\n * sprite entities carrying the same Layer.value (e.g. a\n * player sprite riding the same SPRITE_LAYER_VALUE).\n */\nexport type SortScope = 'layer' | 'per-cell';\n\n/** Compact authoring values for the stored u8 sort-scope field. */\nexport const TilemapSort = Object.freeze({\n layer: 0 as const,\n perCell: 1 as const,\n});\n\n/**\n * Bridge: `SortScope` -> on-disk u8. SSOT for the encoding (`'layer'` -> 0,\n * `'per-cell'` -> 1). The compile-time exhaustive switch guards against\n * silent drift if the union ever grows; adding a new literal here without\n * adding its arm fails typecheck (charter P3 — closed unions never default).\n */\nexport function encodeSortScope(scope: SortScope): 0 | 1 {\n switch (scope) {\n case 'layer':\n return TilemapSort.layer;\n case 'per-cell':\n return TilemapSort.perCell;\n }\n}\n\n/**\n * Bridge: on-disk u8 -> `SortScope`. Defaults to `'layer'` for any value\n * outside `{0, 1}` (defensive — the column is typed `u8` so out-of-range\n * values can only appear if a future schema migration loosens the bound;\n * the default preserves terrain semantics which is the safer of the two).\n */\nexport function decodeSortScope(raw: number | undefined): SortScope {\n return raw === 1 ? 'per-cell' : 'layer';\n}\n\n/**\n * Public TileLayer field shape (`world.spawn({ component: TileLayer, data:\n * { ... } })` payload). `sortScope` is the closed string union surfaced to\n * AI users; the runtime row encodes it as `u8` via `encodeSortScope` at\n * spawn time so on-disk storage stays compact.\n *\n * Treat as the `TileLayer` data SSOT — `defineComponent` schema below\n * mirrors it field-for-field with the bridge `sortScope: u8` in place of\n * the union literal.\n */\nexport interface TileLayerData {\n readonly tiles: Uint32Array;\n readonly layerOrder?: number;\n readonly dirty?: number;\n readonly sortScope?: SortScope;\n}\n\n/**\n * TileLayer component (M0 baseline rebuild + round-2 sortScope rename).\n *\n * AI users typically spawn a TileLayer + ChildOf pair pointing at an\n * existing Tilemap entity:\n *\n * @example\n * const tilesArray = new Uint32Array(cols * rows);\n * tilesArray[5 * cols + 7] = 1; // tile id 1 at cell (7, 5)\n * world.spawn(\n * { component: TileLayer, data: { tiles: tilesArray, layerOrder: 0 } },\n * { component: ChildOf, data: { parent: tilemapEntity } },\n * );\n *\n * Object-layer variant (Y-interleave with sprite entities):\n *\n * > NOTE: schema storage is `u8` (ECS schema does not support\n * > string-literal-union column constraints; see R-NEW-2). The\n * > AI-user surface is the closed `SortScope` string union, but\n * > `world.spawn` requires the numeric encoding — pass through\n * > `encodeSortScope('per-cell')` rather than the literal string.\n *\n * @example\n * world.spawn(\n * { component: TileLayer, data: {\n * tiles: objectTiles,\n * layerOrder: 1000,\n * sortScope: encodeSortScope('per-cell'),\n * } },\n * { component: ChildOf, data: { parent: tilemapEntity } },\n * );\n *\n * Defaults: `layerOrder = 0`, `dirty = 0`, `sortScope = 'layer'` (stored\n * as `u8 = 0`, i.e. `encodeSortScope('layer')`). `tiles` has no default -\n * AI users supply the per-cell array at spawn time. To trigger a re-build\n * after mutating tiles in place, call `markTileLayerDirty(world, layer)`.\n */\nexport const TileLayer = defineComponent('TileLayer', {\n tiles: { type: 'array<u32>' },\n layerOrder: { type: 'i32', default: 0 },\n dirty: { type: 'u8', default: 0 },\n sortScope: { type: 'u8', default: 0 },\n});\n\n/**\n * Mark a TileLayer entity dirty so the next `tilemapChunkExtractSystem`\n * pass purges and re-spawns its derived per-cell entities. Use this after\n * mutating `TileLayer.tiles` (e.g. an in-place patch via a column view).\n *\n * Returns the underlying ECS write result - on `err` branch the caller\n * can read `.code` to recover (`'stale-entity'` if the layer was\n * despawned, `'component-not-present'` if the entity is missing the\n * TileLayer column; both kebab-case literals are members of the closed\n * `EcsErrorCode` union).\n */\nexport function markTileLayerDirty(\n world: World,\n layerEntity: EntityHandle,\n): Result<void, EcsError> {\n return world.set(layerEntity, TileLayer, { dirty: 1 });\n}\n","// @forgeax/engine-runtime - Tilemap (grid + tileset reference).\n//\n// Schema (5 fields):\n// cols u32 total grid columns\n// rows u32 total grid rows\n// tileSize array<f32, 2> per-cell world [width, height] (default [1, 1];\n// feat-20260709 M3: collapsed from the tileSizeX\n// / tileSizeY scalar pair into one inline column)\n// chunkSize u32 per-chunk axis-length in cells (default 16)\n// tileset string durable TilesetAsset GUID\n//\n// Naming: single-semantic Tilemap (AGENTS.md §Component naming drops\n// `Component` suffix). One Tilemap entity per grid; TileLayer entities\n// attach via ChildOf relationship and supply the per-cell tile id array\n// for a single render-layer.\n//\n// charter mapping: F1 (single-import barrel from `@forgeax/engine-runtime`),\n// P1 (progressive disclosure - defaults cover the common case), P4 (durable\n// asset identity stays separate from World-local handles).\n\nimport { defineComponent } from '@forgeax/engine-ecs';\n\n/**\n * Tilemap component (M0 baseline rebuild).\n *\n * A Tilemap entity carries grid metadata + a single durable `tileset` GUID. The\n * actual per-cell tile id array lives on attached `TileLayer` entities\n * (one per render layer; ChildOf points back to the Tilemap entity).\n *\n * Defaults:\n * - `tileSize = [1, 1]` (unit-cell world coordinates).\n * - `chunkSize = 16` (tilemap-chunk-extract-system chunks 16x16 cells).\n *\n * @example Spawn a 32x32 unit-cell Tilemap referencing a TilesetAsset:\n * const tilemap = world.spawn(\n * { component: Tilemap, data: { cols: 32, rows: 32, tileset } },\n * { component: Transform, data: {} },\n * ).unwrap();\n */\nexport const Tilemap = defineComponent('Tilemap', {\n cols: { type: 'u32', default: 0 },\n rows: { type: 'u32', default: 0 },\n // tileSize carries an explicit layer-2 default [1,1] (unit cell); the array\n // layer-3 fallback is all-zero, so the default MUST be explicit (D-5).\n tileSize: { type: 'array<f32, 2>', default: new Float32Array([1, 1]) },\n chunkSize: { type: 'u32', default: 16 },\n tileset: { type: 'string' },\n});\n"]}