@forgeax/engine-runtime 0.1.27 → 0.1.29

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 (114) hide show
  1. package/README.md +98 -0
  2. package/dist/collect-scene-asset.d.ts.map +1 -1
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.mjs +480 -15
  6. package/dist/index.mjs.map +1 -1
  7. package/dist/scene-utils/mount-override-fold.d.ts +3 -1
  8. package/dist/scene-utils/mount-override-fold.d.ts.map +1 -1
  9. package/package.json +37 -30
  10. package/src/__tests__/animation-graph-collect-roundtrip.test.ts +4 -2
  11. package/src/__tests__/asset-registry-aabb.cases.unit.test.ts +5 -4
  12. package/src/__tests__/asset-registry-guid-reverse.test.ts +22 -27
  13. package/src/__tests__/asset-registry-mounts.test.ts +174 -734
  14. package/src/__tests__/asset-registry-scene.cases.unit.test.ts +9 -18
  15. package/src/__tests__/asset-registry.recursive.spec.ts +23 -29
  16. package/src/__tests__/camera-ortho.cases.unit.test.ts +12 -3
  17. package/src/__tests__/collect-scene-asset.test.ts +96 -113
  18. package/src/__tests__/collect-scene-mount-collapse.test.ts +64 -66
  19. package/src/__tests__/collect-transient-children-absent.test.ts +11 -17
  20. package/src/__tests__/collect-transient-roundtrip.test.ts +12 -13
  21. package/src/__tests__/collect-transient-skip.test.ts +20 -27
  22. package/src/__tests__/components.test-d.ts +17 -1
  23. package/src/__tests__/components.unit.test.ts +75 -27
  24. package/src/__tests__/composite-skybox-cross-world.browser.test.ts +4 -2
  25. package/src/__tests__/dawn/instances-per-instance-pbr.dawn.test.ts +8 -4
  26. package/src/__tests__/dawn/instances-shadow.dawn.test.ts +4 -1
  27. package/src/__tests__/dawn/instances-uniform-fallback.dawn.test.ts +66 -92
  28. package/src/__tests__/dawn/material-cooked-fixture.dawn.test.ts +200 -4
  29. package/src/__tests__/errors.unit.test.ts +13 -0
  30. package/src/__tests__/feature-depth-material.dawn.test.ts +23 -0
  31. package/src/__tests__/feature-depth-material.fixture.ts +473 -0
  32. package/src/__tests__/fullscreen-post-process-pass.dawn.test.ts +14 -14
  33. package/src/__tests__/geometry.unit.test.ts +42 -43
  34. package/src/__tests__/gpu-resource-release.cases.unit.test.ts +16 -9
  35. package/src/__tests__/handle-migration.test.ts +0 -1
  36. package/src/__tests__/instances-renderer-lifecycle.integration.test.ts +20 -14
  37. package/src/__tests__/instances.test-d.ts +13 -52
  38. package/src/__tests__/instantiate-mount-deferred-wiring.test.ts +42 -80
  39. package/src/__tests__/lazy-catalog.cases.unit.test.ts +17 -3
  40. package/src/__tests__/loader-registry.cases.unit.test.ts +4 -4
  41. package/src/__tests__/material-publication.browser.test.ts +29 -0
  42. package/src/__tests__/material-publication.commands.ts +23 -0
  43. package/src/__tests__/material-publication.dawn.test.ts +24 -0
  44. package/src/__tests__/material-publication.fixture.ts +349 -0
  45. package/src/__tests__/material-publication.server.ts +109 -0
  46. package/src/__tests__/parse-scene-payload-refs.cases.unit.test.ts +73 -96
  47. package/src/__tests__/pbr-pipeline.unit.test.ts +25 -11
  48. package/src/__tests__/point-light-shadow.browser.test.ts +8 -2
  49. package/src/__tests__/point-light-shadow.unit.test.ts +37 -8
  50. package/src/__tests__/record-all-topology.cases.unit.test.ts +52 -0
  51. package/src/__tests__/record-draw-branch.cases.unit.test.ts +1 -0
  52. package/src/__tests__/record-worldid-isolation.test.ts +47 -28
  53. package/src/__tests__/render-error-exhaustive.test-d.ts +24 -0
  54. package/src/__tests__/render-system-extract-dispatch-ordering.test.ts +15 -4
  55. package/src/__tests__/render-system-extract.test.ts +61 -0
  56. package/src/__tests__/render-system-extract.unit.test.ts +21 -7
  57. package/src/__tests__/render-system-fold-bucket.unit.test.ts +33 -1
  58. package/src/__tests__/render-system-fold-material.unit.test.ts +32 -6
  59. package/src/__tests__/render-system-fold-modegate.unit.test.ts +26 -4
  60. package/src/__tests__/render-system-fold.dawn.test.ts +4 -1
  61. package/src/__tests__/render-system-mega.test.ts +50 -62
  62. package/src/__tests__/renderer-device-loss-provider-feasibility.dawn.test.ts +0 -1
  63. package/src/__tests__/renderer-device-loss-recovery.dawn.test.ts +0 -3
  64. package/src/__tests__/renderer-draw-world.cases.unit.test.ts +16 -513
  65. package/src/__tests__/resolve-scene-guids.cases.unit.test.ts +48 -43
  66. package/src/__tests__/rhi-null-command-flow.unit.test.ts +9 -7
  67. package/src/__tests__/roots-to-scene-asset.test.ts +27 -28
  68. package/src/__tests__/roundtrip-primitive.test.ts +24 -23
  69. package/src/__tests__/scene-equirect-roundtrip.test.ts +5 -16
  70. package/src/__tests__/scene-instantiate-cycle-runtime.test.ts +13 -29
  71. package/src/__tests__/scene-mount-fixed-point.test.ts +104 -227
  72. package/src/__tests__/scene-mount-roundtrip.test.ts +95 -961
  73. package/src/__tests__/scene-mount-wrapper-roundtrip.test.ts +36 -182
  74. package/src/__tests__/scene-sugar-instantiate.test.ts +20 -15
  75. package/src/__tests__/serialize-mounts-roundtrip.test.ts +108 -492
  76. package/src/__tests__/serialize-scene-asset.test.ts +36 -50
  77. package/src/__tests__/shadow-caster-empty-schema.test.ts +3 -1
  78. package/src/__tests__/shadow-fields-observable.dawn.test.ts +34 -19
  79. package/src/__tests__/shadow-mesh-ssbo-order.test.ts +5 -12
  80. package/src/__tests__/skin-extract-getarrayview-count.unit.test.ts +6 -4
  81. package/src/__tests__/skin-m2-extract-happy.cases.unit.test.ts +50 -2
  82. package/src/__tests__/skin-m2-extract-joint-error.cases.unit.test.ts +50 -2
  83. package/src/__tests__/skin-m2-extract-null-assets.cases.unit.test.ts +50 -2
  84. package/src/__tests__/skin-m2-extract-skeleton-error.cases.unit.test.ts +50 -2
  85. package/src/__tests__/skin-palette-extract.cases.unit.test.ts +2 -0
  86. package/src/__tests__/skinned-shadow-mixed.dawn.test.ts +93 -48
  87. package/src/__tests__/skylight-pipeline-layout.cases.unit.test.ts +6 -2
  88. package/src/__tests__/sparse-tag-scene-roundtrip.test.ts +1 -1
  89. package/src/__tests__/sprite-lit-bgl-byte-identical.test.ts +11 -3
  90. package/src/__tests__/template-game-default-api-contract.test.ts +21 -21
  91. package/src/__tests__/tonemap-hdr-target.cases.unit.test.ts +8 -5
  92. package/src/__tests__/tonemap-pipeline-split.cases.unit.test.ts +3 -0
  93. package/src/__tests__/transient-fields-collect.test.ts +4 -4
  94. package/src/__tests__/transient-runtime-behavior.test.ts +9 -9
  95. package/src/__tests__/vfx-depth-material.dawn.test.ts +42 -0
  96. package/src/__tests__/vfx-mesh-lighting.browser.test.ts +16 -0
  97. package/src/__tests__/vfx-mesh-lighting.commands.ts +14 -0
  98. package/src/__tests__/vfx-mesh-lighting.dawn.test.ts +16 -0
  99. package/src/__tests__/vfx-mesh-lighting.fixture.ts +883 -0
  100. package/src/__tests__/wave1-dynamic-geometry.browser.test.ts +326 -0
  101. package/src/__tests__/wave1-rendering-materials.browser.test.ts +617 -0
  102. package/src/__tests__/wave1-rendering-p0.browser.test.ts +751 -0
  103. package/src/__tests__/wave1-rendering-recovery.browser.test.ts +666 -0
  104. package/src/__tests__/wave1-shadow-diagnostic.browser.test.ts +582 -0
  105. package/src/__tests__/world-scene-despawn.test.ts +8 -8
  106. package/src/__tests__/world-scene-instantiate.test.ts +23 -29
  107. package/src/__tests__/world-scene-nested.test.ts +90 -163
  108. package/src/__tests__/world-scene-override.test.ts +17 -16
  109. package/src/__tests__/zero-compression-zero-load.integration.test.ts +1 -0
  110. package/src/collect-scene-asset.ts +637 -19
  111. package/src/components/__tests__/sprite-animation.test-d.ts +2 -2
  112. package/src/index.ts +1 -0
  113. package/src/scene-utils/mount-override-fold.ts +5 -14
  114. package/src/__tests__/node-shims.d.ts +0 -31
@@ -0,0 +1,473 @@
1
+ import { createBoxGeometry } from '@forgeax/engine-geometry';
2
+ import { AssetGuid } from '@forgeax/engine-pack';
3
+ import type { Renderer, RenderFeature } from '@forgeax/engine-render';
4
+ import { ok } from '@forgeax/engine-types';
5
+ import { ParticleEffectPlayer, type ParticleRendererSourceV3 } from '@forgeax/engine-vfx';
6
+ import { cookParticleCodeEffect } from '@forgeax/engine-vfx-compiler';
7
+ import { createVfxRuntimeHost } from '@forgeax/engine-vfx-render';
8
+ import { buildEngineShaderManifest } from '@forgeax/engine-vite-plugin-shader';
9
+ import { expect } from 'vitest';
10
+ import { constructRuntimeRendererHost } from '../renderer-host';
11
+ import { frameRequest, preparedWorld } from './render-feature-prepared-graphics.fixture';
12
+
13
+ const manifest = await buildEngineShaderManifest();
14
+ const schema = [
15
+ { name: 'tint', type: 'vec4' },
16
+ { name: 'selector', type: 'u32' },
17
+ { name: 'direction', type: 'i32' },
18
+ { name: 'baseColorTexture', type: 'texture2d' },
19
+ ];
20
+
21
+ export const particleDepthLayouts = [
22
+ { name: 'billboard-material-input-instance', location: 9, offset: 31, size: 35, mesh: false },
23
+ {
24
+ name: 'topology-segment-material-input-instance',
25
+ location: 4,
26
+ offset: 12,
27
+ size: 16,
28
+ mesh: false,
29
+ },
30
+ { name: 'mesh-geometry-material-input-instance', location: 10, offset: 18, size: 22, mesh: true },
31
+ ] as const;
32
+ export async function verifyFeatureDepthMaterial(
33
+ layout: (typeof particleDepthLayouts)[number] & {
34
+ readonly particleKind?: ParticleRendererSourceV3['kind'];
35
+ readonly view: boolean;
36
+ readonly missing: 'none' | 'material' | 'depth';
37
+ readonly inputLane?: number;
38
+ readonly allInputs?: boolean;
39
+ readonly writeDepth?: boolean;
40
+ },
41
+ ): Promise<void> {
42
+ const { view, missing } = layout;
43
+ const inputLane = layout.inputLane ?? 0;
44
+ const identifier = `test::depth-material-${layout.name}-${view}-${missing}`;
45
+ const instance = new Float32Array(layout.size);
46
+ instance[layout.offset] = 0.5;
47
+ const source = `
48
+ ${view ? 'struct View { matrix: mat4x4<f32> }; @group(0) @binding(0) var<uniform> view: View;' : ''}
49
+ @group(0) @binding(${view ? 1 : 0}) var scene_depth: texture_depth_2d;
50
+ struct Material { tint: vec4<f32>, selector: u32, direction: i32 };
51
+ @group(1) @binding(0) var<uniform> material: Material;
52
+ @group(1) @binding(1) var material_sampler: sampler;
53
+ @group(1) @binding(2) var material_texture: texture_2d<f32>;
54
+ struct Input { @location(${layout.location + (layout.allInputs ? 3 : inputLane)}) particle: vec4<f32>,
55
+ ${layout.allInputs ? `@location(${layout.location}) first: vec4<f32>, @location(${layout.location + 1}) second: vec4<f32>, @location(${layout.location + 2}) third: vec4<f32>,` : ''}
56
+ ${layout.particleKind === 'mesh' ? '@location(0) geometry: vec3<f32>,' : ''}
57
+ };
58
+ struct Output { @builtin(position) position: vec4<f32>, @location(0) factor: f32 };
59
+ @vertex fn vs_main(input: Input, @builtin(vertex_index) index: u32) -> Output {
60
+ let p = array<vec2<f32>, 3>(vec2(-1., -1.), vec2(3., -1.), vec2(-1., 3.));
61
+ let factor = ${layout.allInputs ? 'select(-1.0, input.particle.x, input.first.x == 3.0 && input.second.x == 1.0 && input.third.x == 2.0)' : 'input.particle.x'};
62
+ return Output(vec4(${layout.particleKind === 'mesh' ? 'input.geometry.xy * 2.0' : 'p[index % 3u]'}, 0.5, 1.), factor);
63
+ }
64
+ @fragment fn fs_main(input: Output) -> @location(0) vec4<f32> {
65
+ let depth = textureLoad(scene_depth, vec2<i32>(input.position.xy), 0);
66
+ let sampled = textureSampleLevel(material_texture, material_sampler, vec2(0.5), 0.);
67
+ if (material.selector != 3u || material.direction != -7) { return vec4(0., 1., 0., 1.); }
68
+ return vec4(material.tint.rgb * sampled.rgb * depth * input.factor, 1.);
69
+ }`;
70
+ const shaderManifestUrl = `data:application/json,${encodeURIComponent(
71
+ JSON.stringify({
72
+ ...manifest,
73
+ materialShaders: [
74
+ ...manifest.materialShaders,
75
+ ...(layout.writeDepth
76
+ ? [
77
+ {
78
+ identifier: `${identifier}-writer`,
79
+ sourcePath: `${identifier}-writer`,
80
+ composedWgsl: `@vertex fn vs_main(@builtin(vertex_index) index: u32) -> @builtin(position) vec4<f32> {
81
+ let p = array<vec2<f32>, 3>(vec2(-1., -1.), vec2(3., -1.), vec2(-1., 3.));
82
+ return vec4(p[index], 0.25, 1.);
83
+ }`,
84
+ paramSchema: '[]',
85
+ variants: [],
86
+ },
87
+ ]
88
+ : []),
89
+ {
90
+ identifier,
91
+ sourcePath: identifier,
92
+ composedWgsl: source,
93
+ paramSchema: JSON.stringify(schema),
94
+ variants: [],
95
+ },
96
+ ],
97
+ }),
98
+ )}`;
99
+ const materialGuid = AssetGuid.random();
100
+ const meshGuid = AssetGuid.random();
101
+ const particleInputs = layout.allInputs
102
+ ? ['heat', 'warm', 'hot', 'glow'].map((name, lane) => ({
103
+ name,
104
+ type: 'f32' as const,
105
+ visibility: 'fragment' as const,
106
+ lane,
107
+ }))
108
+ : [{ name: 'heat', type: 'f32' as const, visibility: 'fragment' as const, lane: inputLane }];
109
+ const vfx =
110
+ layout.particleKind === undefined
111
+ ? undefined
112
+ : createVfxRuntimeHost({
113
+ camera: {
114
+ read: () => ({
115
+ position: new Float32Array([0, 0, 3]),
116
+ right: new Float32Array([1, 0, 0]),
117
+ up: new Float32Array([0, 1, 0]),
118
+ viewProjection: new Float32Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]),
119
+ }),
120
+ },
121
+ });
122
+ const feature: RenderFeature<undefined> = {
123
+ identity: identifier,
124
+ extract: () => ok(undefined),
125
+ plan: (_, context) => {
126
+ const color = context.targets.find((target) => target.kind === 'color');
127
+ const depth = context.targets.find((target) => target.kind === 'depth');
128
+ if (color === undefined || depth === undefined) throw new Error('Missing scene targets');
129
+ return ok({
130
+ resources: [
131
+ ...(layout.writeDepth
132
+ ? [
133
+ {
134
+ kind: 'graphics-program' as const,
135
+ name: 'depth-writer',
136
+ program: {
137
+ shader: `${identifier}-writer`,
138
+ vertexLayout: 'none',
139
+ colorFormats: [],
140
+ depthFormat: depth.format,
141
+ sampleCount: depth.sampleCount,
142
+ renderState: {
143
+ depthWriteEnabled: true,
144
+ depthCompare: 'always' as const,
145
+ cullMode: 'none' as const,
146
+ },
147
+ },
148
+ },
149
+ {
150
+ kind: 'graphics-bindings' as const,
151
+ name: 'depth-bindings',
152
+ program: 'depth-writer',
153
+ values: { group: 0 },
154
+ },
155
+ ]
156
+ : []),
157
+ {
158
+ kind: 'graphics-program',
159
+ name: 'program',
160
+ program: {
161
+ shader: identifier,
162
+ vertexLayout: layout.name,
163
+ particleInputLanes: 1,
164
+ colorFormats: [color.format],
165
+ sampleCount: color.sampleCount,
166
+ },
167
+ },
168
+ {
169
+ kind: 'graphics-bindings',
170
+ name: 'scene',
171
+ program: 'program',
172
+ values: { group: 0, sceneDepthBinding: view ? 1 : 0 },
173
+ ...(missing === 'depth' ? {} : { logicalTargets: { sceneDepth: depth.name } }),
174
+ },
175
+ {
176
+ kind: 'graphics-bindings',
177
+ name: 'material',
178
+ program: 'program',
179
+ values: { group: 1, material: { world: 0, guid: AssetGuid.format(materialGuid) } },
180
+ },
181
+ { kind: 'vertex-data', name: 'instance', layout: layout.name, data: instance },
182
+ ...(layout.mesh
183
+ ? [
184
+ {
185
+ kind: 'vertex-data' as const,
186
+ name: 'geometry',
187
+ layout: layout.name,
188
+ data: new Float32Array(36),
189
+ },
190
+ ]
191
+ : []),
192
+ ],
193
+ passes: [
194
+ ...(layout.writeDepth
195
+ ? [
196
+ {
197
+ kind: 'raster' as const,
198
+ name: 'write-depth',
199
+ colorAttachments: [],
200
+ depthStencilAttachment: {
201
+ target: depth.name,
202
+ depthLoadOp: 'load' as const,
203
+ depthStoreOp: 'store' as const,
204
+ },
205
+ draws: [
206
+ {
207
+ program: 'depth-writer',
208
+ bindings: ['depth-bindings'],
209
+ vertexData: [],
210
+ vertexLayout: 'none' as const,
211
+ draw: { kind: 'draw' as const, vertexCount: 3, instanceCount: 1 },
212
+ },
213
+ ],
214
+ },
215
+ ]
216
+ : []),
217
+ {
218
+ kind: 'raster',
219
+ name: 'sample',
220
+ colorAttachments: [{ target: color.name, loadOp: 'load', storeOp: 'store' }],
221
+ sampledTargets: missing === 'depth' ? [] : [depth.name],
222
+ draws: [
223
+ {
224
+ program: 'program',
225
+ bindings: ['scene', 'material'],
226
+ vertexData: layout.mesh
227
+ ? [
228
+ { slot: 0, resource: 'geometry' },
229
+ { slot: 1, resource: 'instance' },
230
+ ]
231
+ : [{ slot: 0, resource: 'instance' }],
232
+ draw: { kind: 'draw', vertexCount: 3, instanceCount: 1 },
233
+ },
234
+ ],
235
+ },
236
+ ],
237
+ });
238
+ },
239
+ };
240
+ const particleFeature =
241
+ vfx === undefined
242
+ ? undefined
243
+ : {
244
+ ...vfx.feature,
245
+ plan: (
246
+ data: Parameters<typeof vfx.feature.plan>[0],
247
+ context: Parameters<typeof vfx.feature.plan>[1],
248
+ ) =>
249
+ vfx.feature.plan(
250
+ data,
251
+ missing === 'depth'
252
+ ? {
253
+ ...context,
254
+ targets: context.targets.filter((target) => target.kind !== 'depth'),
255
+ }
256
+ : context,
257
+ ),
258
+ };
259
+ let device: GPUDevice | undefined;
260
+ let target: GPUTexture | undefined;
261
+ const gpuErrors: string[] = [];
262
+ const canvas = {
263
+ width: 64,
264
+ height: 64,
265
+ getContext: () => ({
266
+ configure(descriptor: { device: GPUDevice; format: GPUTextureFormat }) {
267
+ device = descriptor.device;
268
+ device.addEventListener('uncapturederror', (event) => gpuErrors.push(event.error.message));
269
+ target = device.createTexture({
270
+ size: [64, 64],
271
+ format: descriptor.format,
272
+ viewFormats: ['rgba8unorm-srgb'],
273
+ usage: 0x10 | 0x01,
274
+ });
275
+ },
276
+ unconfigure() {},
277
+ getCurrentTexture: () => target,
278
+ }),
279
+ addEventListener() {},
280
+ removeEventListener() {},
281
+ };
282
+ let renderer: Renderer | undefined;
283
+ let attachedWorld: ReturnType<typeof preparedWorld> | undefined;
284
+ try {
285
+ const constructed = await constructRuntimeRendererHost(
286
+ canvas,
287
+ { features: [particleFeature ?? feature] },
288
+ { shaderManifestUrl },
289
+ );
290
+ if (!constructed.ok) throw constructed.error;
291
+ const host = constructed.value;
292
+ renderer = host.renderer;
293
+ const textureGuid = AssetGuid.random();
294
+ if (vfx !== undefined) host.assets.catalog(meshGuid, createBoxGeometry(1, 1, 1).unwrap());
295
+ host.assets.catalog(textureGuid, {
296
+ kind: 'texture',
297
+ shape: { viewDimension: '2d', extent: { width: 1, height: 1 } },
298
+ format: 'rgba8unorm',
299
+ data: new Uint8Array([255, 0, 0, 255]),
300
+ colorSpace: 'linear',
301
+ mips: { kind: 'none' },
302
+ });
303
+ if (missing !== 'material')
304
+ host.assets.catalog(materialGuid, {
305
+ kind: 'material',
306
+ passes: [
307
+ {
308
+ name: layout.particleKind === undefined ? 'Forward' : `particle-${layout.particleKind}`,
309
+ program: { module: identifier },
310
+ renderState: { tags: { LightMode: 'Forward' }, queue: 3000, depthWriteEnabled: false },
311
+ },
312
+ ],
313
+ ...(vfx === undefined
314
+ ? {}
315
+ : {
316
+ parameters: [
317
+ { name: 'tint', type: 'vec4' },
318
+ { name: 'selector', type: 'u32' },
319
+ { name: 'direction', type: 'i32' },
320
+ { name: 'baseColorTexture', type: 'texture' },
321
+ ],
322
+ particleInputs,
323
+ }),
324
+ values: {
325
+ tint: [0.5, 1, 1, 1],
326
+ selector: 3,
327
+ direction: -7,
328
+ baseColorTexture: { texture: AssetGuid.format(textureGuid) as never },
329
+ },
330
+ });
331
+ const errors: unknown[] = [];
332
+ renderer.subscribe((event) => {
333
+ if (event.kind === 'error') errors.push(event.error);
334
+ });
335
+ const world = preparedWorld();
336
+ const attached = renderer.attach(world);
337
+ if (!attached.ok) throw attached.error;
338
+ const lease = attached.value;
339
+ if (vfx !== undefined) {
340
+ const particleRenderer = {
341
+ kind: layout.particleKind,
342
+ material: AssetGuid.format(materialGuid),
343
+ materialInputs: particleInputs.map((input) => input.name),
344
+ ...(layout.particleKind === 'mesh' ? { mesh: AssetGuid.format(meshGuid) } : {}),
345
+ ...(layout.particleKind === 'ribbon' ? { stripKey: 'alive-index', capacity: 4 } : {}),
346
+ ...(layout.particleKind === 'trail' ? { historyLength: 3, capacity: 4 } : {}),
347
+ ...(layout.particleKind === 'beam' ? { endpointField: 'velocity', capacity: 4 } : {}),
348
+ } as ParticleRendererSourceV3;
349
+ const cooked = await cookParticleCodeEffect(
350
+ {
351
+ schemaVersion: 3,
352
+ emitters: [
353
+ {
354
+ id: 'combined',
355
+ capacity: 4,
356
+ backend: { required: 'gpu' },
357
+ space: 'world',
358
+ bounds: { kind: 'sphere', center: [0, 0, 0], radius: 4 },
359
+ schedule: { rate: 0, bursts: [{ time: 0, count: 2 }] },
360
+ program: { module: 'combined.wgsl' },
361
+ renderers: [particleRenderer],
362
+ },
363
+ ],
364
+ },
365
+ {
366
+ 'combined.wgsl': {
367
+ entry: `
368
+ #import forgeax_vfx::prelude::{VfxParticle, VfxSpawnContext, VfxUpdateContext}
369
+ struct VfxParameters { heat: f32, }
370
+ struct VfxCustom { heat: f32, ${layout.allInputs ? 'warm: f32, hot: f32, glow: f32,' : ''} }
371
+ fn vfx_spawn(ctx: VfxSpawnContext, particle: ptr<function, VfxParticle>, custom: ptr<function, VfxCustom>) {
372
+ (*particle).position = vec3<f32>(f32(ctx.particleId) * 0.1, 0.0, 0.0);
373
+ (*particle).velocity = vec3<f32>(0.0, 1.0, 0.0);
374
+ (*particle).mesh_orientation = vec4<f32>(0.0, 0.0, 0.0, 1.0);
375
+ (*particle).mesh_scale = vec3<f32>(1.0);
376
+ (*particle).lifetime = 100.0;
377
+ }
378
+ fn vfx_update(ctx: VfxUpdateContext, particle: ptr<function, VfxParticle>, custom: ptr<function, VfxCustom>) {
379
+ (*particle).position.x += ctx.delta;
380
+ ${layout.allInputs ? '(*custom).heat = 3.0; (*custom).warm = 1.0; (*custom).hot = 2.0; (*custom).glow = forgeax_vfx_parameters.heat;' : '(*custom).heat = forgeax_vfx_parameters.heat;'}
381
+ }`,
382
+ },
383
+ },
384
+ { [AssetGuid.format(materialGuid)]: particleInputs },
385
+ );
386
+ if (!cooked.ok) throw cooked.error;
387
+ const attachment = await vfx.attachWorld({ world, assets: host.assets });
388
+ if (!attachment.ok) throw attachment.error;
389
+ attachedWorld = world;
390
+ const player = world
391
+ .spawn({
392
+ component: ParticleEffectPlayer,
393
+ data: {
394
+ effect: world.allocSharedRef('ParticleEffectAsset', cooked.value.asset),
395
+ playing: true,
396
+ seed: 1,
397
+ timeScale: 1,
398
+ },
399
+ })
400
+ .unwrap();
401
+ world.update(1 / 60).unwrap();
402
+ const control = vfx.acquireControl(world).unwrap();
403
+ const patched = control.patchPlayerParameters({ player, values: { heat: 0.5 } });
404
+ if (!patched.ok) throw patched.error;
405
+ }
406
+ for (let frame = 0; frame < 5; frame += 1) {
407
+ expect(world.update(1 / 60).ok).toBe(true);
408
+ const submitted = renderer.draw(frameRequest(lease));
409
+ if (!submitted.ok) throw submitted.error;
410
+ const observed = await renderer.observe(submitted.value, { include: ['draws'] });
411
+ if (!observed.ok) throw observed.error;
412
+ }
413
+ expect(gpuErrors).toEqual([]);
414
+ if (missing !== 'none') {
415
+ expect(errors).toEqual(
416
+ expect.arrayContaining([
417
+ expect.objectContaining({
418
+ detail: expect.objectContaining({
419
+ cause: expect.objectContaining({
420
+ code:
421
+ vfx !== undefined && missing === 'material'
422
+ ? 'render-feature-stage-failed'
423
+ : 'render-feature-preparation-failed',
424
+ }),
425
+ }),
426
+ }),
427
+ ]),
428
+ );
429
+ return;
430
+ }
431
+ expect(errors).toEqual([]);
432
+ if (device === undefined || target === undefined) throw new Error('Missing GPU surface');
433
+ const readback = device.createBuffer({ size: 256 * 64, usage: 0x01 | 0x08 });
434
+ const encoder = device.createCommandEncoder();
435
+ encoder.copyTextureToBuffer(
436
+ { texture: target },
437
+ { buffer: readback, bytesPerRow: 256 },
438
+ [64, 64],
439
+ );
440
+ device.queue.submit([encoder.finish()]);
441
+ await readback.mapAsync(1);
442
+ const pixel = new Uint8Array(readback.getMappedRange()).slice(
443
+ 32 * 256 + 32 * 4,
444
+ 32 * 256 + 32 * 4 + 4,
445
+ );
446
+ readback.unmap();
447
+ readback.destroy();
448
+ if (layout.writeDepth) {
449
+ // Tint 0.5 * red texture 1 * instance 0.5 * written depth 0.25,
450
+ // encoded into the sRGB display surface. Clear depth 1 cannot pass.
451
+ const linearRed = 0.5 * 0.5 * 0.25;
452
+ const encodedRed = Math.round((1.055 * linearRed ** (1 / 2.4) - 0.055) * 255);
453
+ expect(Math.abs((pixel[0] ?? 0) - encodedRed)).toBeLessThanOrEqual(2);
454
+ } else expect(pixel[0]).toBeGreaterThan(50);
455
+ expect(pixel[1]).toBeLessThan(5);
456
+ expect(pixel[2]).toBeLessThan(5);
457
+ } finally {
458
+ // Negative material/depth cases return before a readback fence. Drain their
459
+ // submitted work before releasing this headless host's GPU resources.
460
+ if (device !== undefined) await device.queue.onSubmittedWorkDone();
461
+ if (vfx !== undefined && attachedWorld !== undefined)
462
+ await vfx.detachWorld({ world: attachedWorld });
463
+ renderer?.dispose();
464
+ target?.destroy();
465
+ // This headless fixture owns the raw device. Renderer.dispose releases
466
+ // renderer resources but deliberately leaves device lifetime to its host.
467
+ // Finish this case's device lifetime before starting the next combination.
468
+ if (device !== undefined) {
469
+ device.destroy();
470
+ expect((await device.lost).reason).toBe('destroyed');
471
+ }
472
+ }
473
+ }
@@ -581,21 +581,21 @@ describe('feat-20260604 M2 w10: FullscreenPostProcessPass dawn tests', () => {
581
581
  rgbByteDiffCount,
582
582
  `paired readback must expose the existing FXAA difference; diagnostics=${JSON.stringify(diagnostics)}; ` +
583
583
  `noneSurface=${JSON.stringify({
584
- displayEncoded: inspectionNone.displayEncoded,
585
- intermediateFormat: inspectionNone.intermediateFormat,
586
- surfaceStorage: inspectionNone.surfaceStorage,
587
- surfaceDisplay: inspectionNone.surfaceDisplay,
588
- endpoint: inspectionNone.endpoint,
589
- capability: inspectionNone.capability,
590
- error: inspectionNone.error,
584
+ displayEncoded: inspectionNone.output.displayEncoded,
585
+ intermediateFormat: inspectionNone.output.intermediateFormat,
586
+ surfaceStorage: inspectionNone.output.surfaceStorage,
587
+ surfaceDisplay: inspectionNone.output.surfaceDisplay,
588
+ endpoint: inspectionNone.output.endpoint,
589
+ capability: inspectionNone.output.capability,
590
+ error: inspectionNone.output.error,
591
591
  })}; fxaaSurface=${JSON.stringify({
592
- displayEncoded: inspectionFxaa.displayEncoded,
593
- intermediateFormat: inspectionFxaa.intermediateFormat,
594
- surfaceStorage: inspectionFxaa.surfaceStorage,
595
- surfaceDisplay: inspectionFxaa.surfaceDisplay,
596
- endpoint: inspectionFxaa.endpoint,
597
- capability: inspectionFxaa.capability,
598
- error: inspectionFxaa.error,
592
+ displayEncoded: inspectionFxaa.output.displayEncoded,
593
+ intermediateFormat: inspectionFxaa.output.intermediateFormat,
594
+ surfaceStorage: inspectionFxaa.output.surfaceStorage,
595
+ surfaceDisplay: inspectionFxaa.output.surfaceDisplay,
596
+ endpoint: inspectionFxaa.output.endpoint,
597
+ capability: inspectionFxaa.output.capability,
598
+ error: inspectionFxaa.output.error,
599
599
  standardOutputNone,
600
600
  standardOutputFxaa,
601
601
  })}`,
@@ -51,6 +51,7 @@ import { MeshRenderer } from '../../../render/src/components/mesh-renderer';
51
51
  import type { MeshGpuHandles } from '../../../render/src/device/gpu-residency';
52
52
  import { GpuResidencyCache } from '../../../render/src/device/gpu-residency';
53
53
  import type { GpuBuffer } from '../../../render/src/gpu-resource';
54
+ import { InstanceCollectionStore } from '../../../render/src/instances';
54
55
  import type {
55
56
  PipelineBuilderContext,
56
57
  PipelineBuilderShaderModuleFactory,
@@ -380,7 +381,7 @@ function drawPublished(renderer: RendererType, world: WorldType) {
380
381
  kind: 'mesh',
381
382
  vertices,
382
383
  indices: new Uint16Array([0, 1, 2]),
383
- attributes: { position: positions },
384
+ attributes: { ...buildMeshAttributeMapForUvSets(1), position: positions },
384
385
  aabb,
385
386
  materialSlots: [{ slotName: 'Default' }],
386
387
  submeshes: [
@@ -683,23 +684,10 @@ function drawPublished(renderer: RendererType, world: WorldType) {
683
684
 
684
685
  {
685
686
  // --- from instances.test.ts ---
686
- // w14 - Instances component schema migration to `array<f32>` (M3, AC-06).
687
+ // Renderer-owned Instances association (E1 large-instance cutover).
687
688
  //
688
- // Locks AC-06 (requirements.md): the `transforms: 'array<f32>'` field
689
- // resolves to a fresh `Float32Array` snapshot at the get-site. The stride
690
- // contract (length must be a non-zero multiple of 16) lives at the
691
- // RenderSystem extract entry (`packages/runtime/src/render-system-extract.ts`)
692
- // + the AI user set-site -- the ECS layer is stride-agnostic
693
- // post-feat-20260515-buffer-array-vocab-collapse (decision §2.3).
694
- //
695
- // feat-20260515-buffer-array-vocab-collapse M3 / w16: the legacy
696
- // component-level per-component stride defineComponent option was retired;
697
- // stride violations now route the runtime-side
698
- // `instance-transforms-stride-mismatch` error through the Layer-3
699
- // ErrorHandler from the RenderSystem extract entry rather than from the
700
- // ECS write paths. The runtime defensive coverage lives in
701
- // `render-system-stride.test.ts` (w14); this file covers the ECS-side
702
- // schema + happy-path read invariants only.
689
+ // ECS keeps only the collection identity; Render validates and owns the
690
+ // matrix payload, revision and dirty ranges in InstanceCollectionStore.
703
691
  //
704
692
  // Test path note: project convention places runtime tests under
705
693
  // `src/__tests__/` (see `instances.test-d.ts` header for the rationale).
@@ -709,20 +697,25 @@ function drawPublished(renderer: RendererType, world: WorldType) {
709
697
  readonly detail: unknown;
710
698
  }
711
699
 
712
- function makeHarness(): { world: World; collected: CollectedError[] } {
700
+ function makeHarness(): {
701
+ world: World;
702
+ collected: CollectedError[];
703
+ instanceCollections: InstanceCollectionStore;
704
+ } {
713
705
  const collected: CollectedError[] = [];
714
706
  const world = new World();
715
- return { world, collected };
707
+ return { world, collected, instanceCollections: new InstanceCollectionStore() };
716
708
  }
717
709
 
718
- describe('w14 - Instances { transforms: array<f32> } schema (AC-06)', () => {
719
- it('Instances.schema.transforms is the array<f32> keyword (no legacy buffer/count fields)', () => {
710
+ describe('renderer-owned Instances association', () => {
711
+ it('Instances schema carries only collectionId (no managed payload fields)', () => {
720
712
  expect(Instances.name).toBe('Instances');
721
713
  expect(Object.keys(Instances.fields).length).toBe(1);
722
- expect(Instances.fields.transforms?.type).toBe('array<f32>');
723
- // Legacy fields retired in M3.
714
+ expect(Instances.fields.collectionId?.type).toBe('u32');
715
+ expect(Instances.fields.collectionId?.default).toBe(0);
724
716
  expect(Instances.fields.buffer).toBeUndefined();
725
717
  expect(Instances.fields.count).toBeUndefined();
718
+ expect(Instances.fields.transforms).toBeUndefined();
726
719
  });
727
720
 
728
721
  it('Instances component carries no per-component stride option (decision §2.3 migration)', () => {
@@ -732,8 +725,8 @@ function drawPublished(renderer: RendererType, world: WorldType) {
732
725
  expect((Instances as unknown as Record<string, unknown>).arrayStride).toBeUndefined();
733
726
  });
734
727
 
735
- it('(legal) spawn with 16 f32 multiples succeeds; snapshot length reflects element count', () => {
736
- const { world, collected } = makeHarness();
728
+ it('(legal) collection creation and ECS association preserve the matrix payload', () => {
729
+ const { world, collected, instanceCollections } = makeHarness();
737
730
  const N = 4;
738
731
  const transforms = new Float32Array(N * 16);
739
732
  // Fill column 3 with distinguishable translations so the read path can
@@ -742,27 +735,29 @@ function drawPublished(renderer: RendererType, world: WorldType) {
742
735
  transforms[i * 16 + 12] = i + 1;
743
736
  transforms[i * 16 + 15] = 1;
744
737
  }
745
- const e = world.spawn({ component: Instances, data: { transforms } }).unwrap();
746
- // No stride error fires on the ECS write path post-feat-20260515.
747
- expect(collected.filter((c) => c.code === 'instance-transforms-stride-mismatch').length).toBe(
748
- 0,
749
- );
750
- const snap = world.get(e, Instances).unwrap().transforms;
738
+ const collection = instanceCollections.create({ transforms }).unwrap();
739
+ const e = world
740
+ .spawn({
741
+ component: Instances,
742
+ data: { collectionId: collection.collectionId },
743
+ })
744
+ .unwrap();
745
+ expect(collected).toHaveLength(0);
746
+ const association = world.get(e, Instances).unwrap();
747
+ expect(association.collectionId).toBe(collection.collectionId);
748
+ const snap = instanceCollections.snapshot(collection.collectionId).unwrap().transforms;
751
749
  expect(snap.length).toBe(N * 16);
752
750
  // Sanity: column 3 translation x of instance 2 reads back as 3.
753
751
  expect(snap[2 * 16 + 12]).toBe(3);
754
752
  });
755
753
 
756
- it('(ECS layer is stride-agnostic) spawn with 17 f32 does NOT route a stride error from ECS', () => {
757
- const { world, collected } = makeHarness();
758
- world.spawn({ component: Instances, data: { transforms: new Float32Array(17) } });
759
- // The runtime-side defensive lives in render-system-extract; the ECS
760
- // write path itself MUST NOT route any stride error code.
761
- expect(collected.filter((c) => c.code === 'instance-transforms-stride-mismatch').length).toBe(
762
- 0,
763
- );
764
- // Negative anchor for the retired ECS-layer code.
765
- expect(collected.filter((c) => c.code === 'managed-array-stride-mismatch').length).toBe(0);
754
+ it('rejects malformed matrix payloads before publishing an ECS association', () => {
755
+ const { world, collected, instanceCollections } = makeHarness();
756
+ const malformed = instanceCollections.create({ transforms: new Float32Array(17) });
757
+ expect(malformed.ok).toBe(false);
758
+ expect(collected).toHaveLength(0);
759
+ const e = world.spawn({ component: Instances, data: {} }).unwrap();
760
+ expect(world.get(e, Instances).unwrap().collectionId).toBe(0);
766
761
  });
767
762
  });
768
763
  }
@@ -879,7 +874,6 @@ function drawPublished(renderer: RendererType, world: WorldType) {
879
874
  iboBytes: 0,
880
875
  indexCount: 0,
881
876
  indexFormat: 'uint16',
882
- layout: '12F',
883
877
  vertexCount: 2,
884
878
  indexed: false,
885
879
  topology: 'line-list',
@@ -2341,7 +2335,11 @@ function drawPublished(renderer: RendererType, world: WorldType) {
2341
2335
  { component: C.MeshRenderer, data: { materials: materialHandles } },
2342
2336
  { component: C.MeshFilter, data: { assetHandle: meshHandle } },
2343
2337
  { component: C.Transform, data: originTransform() },
2344
- { component: C.Instances, data: { transforms } },
2338
+ { component: C.GlobalTransform, data: {} },
2339
+ {
2340
+ component: C.Instances,
2341
+ data: { collectionId: _renderer.instances.create({ transforms }).unwrap().collectionId },
2342
+ },
2345
2343
  );
2346
2344
  return world;
2347
2345
  }
@@ -2518,6 +2516,7 @@ function drawPublished(renderer: RendererType, world: WorldType) {
2518
2516
  vertices: new Float32Array(4 * 12), // 4 verts x 12 floats
2519
2517
  indices,
2520
2518
  attributes: {
2519
+ ...buildMeshAttributeMapForUvSets(1),
2521
2520
  position: new Float32Array(4 * 3),
2522
2521
  },
2523
2522
  materialSlots: [{ slotName: 'Default' }],