@forgeax/engine-runtime 0.1.28 → 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 (113) 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 -32
  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-rendering-materials.browser.test.ts +617 -0
  101. package/src/__tests__/wave1-rendering-p0.browser.test.ts +751 -0
  102. package/src/__tests__/wave1-rendering-recovery.browser.test.ts +666 -0
  103. package/src/__tests__/wave1-shadow-diagnostic.browser.test.ts +582 -0
  104. package/src/__tests__/world-scene-despawn.test.ts +8 -8
  105. package/src/__tests__/world-scene-instantiate.test.ts +23 -29
  106. package/src/__tests__/world-scene-nested.test.ts +90 -163
  107. package/src/__tests__/world-scene-override.test.ts +17 -16
  108. package/src/__tests__/zero-compression-zero-load.integration.test.ts +1 -0
  109. package/src/collect-scene-asset.ts +637 -19
  110. package/src/components/__tests__/sprite-animation.test-d.ts +2 -2
  111. package/src/index.ts +1 -0
  112. package/src/scene-utils/mount-override-fold.ts +5 -14
  113. package/src/__tests__/node-shims.d.ts +0 -31
@@ -1,6 +1,15 @@
1
1
  import { readFileSync } from 'node:fs';
2
+ import { stdout } from 'node:process';
2
3
  import { fileURLToPath } from 'node:url';
4
+ import { World } from '@forgeax/engine-ecs';
5
+ import { createBoxGeometry } from '@forgeax/engine-geometry';
6
+ import { AssetGuid } from '@forgeax/engine-pack';
7
+ import { Camera, MeshFilter, MeshRenderer } from '@forgeax/engine-render';
8
+ import { Transform } from '@forgeax/engine-scene';
9
+ import type { MaterialAsset } from '@forgeax/engine-types';
10
+ import { buildEngineShaderManifest } from '@forgeax/engine-vite-plugin-shader';
3
11
  import { describe, expect, it } from 'vitest';
12
+ import { constructRuntimeRendererHost } from '../../renderer-host';
4
13
 
5
14
  type CookedRecord = {
6
15
  readonly specializationKey: string;
@@ -44,6 +53,13 @@ const fixture = JSON.parse(
44
53
  ),
45
54
  ) as { readonly assets: readonly MaterialRow[] };
46
55
 
56
+ const authoredShaderPackage = fileURLToPath(
57
+ new URL(
58
+ '../../../../../apps/hello/custom-shader/src/pulse-material.shader.pack.json',
59
+ import.meta.url,
60
+ ),
61
+ );
62
+
47
63
  const materialRows = fixture.assets.filter((asset) => asset.kind === 'material');
48
64
  const root = materialRows.find((asset) => asset.payload.parent === undefined);
49
65
  const derived = materialRows.find((asset) => asset.payload.parent === root?.guid);
@@ -98,8 +114,188 @@ describe('custom-shader cooked MaterialAsset fixture', () => {
98
114
  });
99
115
  });
100
116
 
101
- it('runs the real Dawn path for exactly 300 frames without unexpected RHI errors', () => {
102
- expect(fixture.assets.length).toBeGreaterThanOrEqual(2);
103
- expect(fixture.assets.every((asset) => asset.kind === 'material')).toBe(true);
104
- });
117
+ it('loads the published material and draws 300 real Engine frames', async () => {
118
+ const manifest = await buildEngineShaderManifest({
119
+ materialPackages: [authoredShaderPackage],
120
+ });
121
+ const dataUrl = (value: unknown) =>
122
+ `data:application/json,${encodeURIComponent(JSON.stringify(value))}`;
123
+ const gpuErrors: string[] = [];
124
+ let device: GPUDevice | undefined;
125
+ let target: GPUTexture | undefined;
126
+ const canvas = {
127
+ width: 64,
128
+ height: 64,
129
+ getContext: () => ({
130
+ configure(descriptor: GPUCanvasConfiguration) {
131
+ device = descriptor.device;
132
+ device.addEventListener('uncapturederror', (event) =>
133
+ gpuErrors.push(event.error.message),
134
+ );
135
+ target = device.createTexture({
136
+ size: [64, 64],
137
+ format: descriptor.format,
138
+ viewFormats: [
139
+ descriptor.format === 'bgra8unorm' ? 'bgra8unorm-srgb' : 'rgba8unorm-srgb',
140
+ ],
141
+ usage: 0x10 | 0x01,
142
+ });
143
+ },
144
+ unconfigure() {},
145
+ getCurrentTexture: () => target,
146
+ }),
147
+ addEventListener() {},
148
+ removeEventListener() {},
149
+ };
150
+ const constructed = await constructRuntimeRendererHost(
151
+ canvas as never,
152
+ {},
153
+ { shaderManifestUrl: dataUrl(manifest) },
154
+ );
155
+ if (!constructed.ok) throw constructed.error;
156
+ const { renderer, assets } = constructed.value;
157
+ const errors: unknown[] = [];
158
+ renderer.subscribe((event) => {
159
+ if (event.kind === 'error') errors.push(event.error);
160
+ });
161
+ const world = new World();
162
+ try {
163
+ // The fixture's parent-less material is the publication root. Keep the
164
+ // runtime smoke on the same authority used by the contract assertions
165
+ // above; the pack payload does not carry a second `role` discriminator.
166
+ if (root === undefined) throw new Error('missing root publication');
167
+ const packUrl = dataUrl(fixture);
168
+ assets.configurePackIndex(
169
+ dataUrl(
170
+ fixture.assets.map((asset) => ({
171
+ guid: asset.guid,
172
+ kind: asset.kind,
173
+ packageUrl: packUrl,
174
+ })),
175
+ ),
176
+ );
177
+ for (const [guid, color] of [
178
+ ['01935b00-7d8c-7c4e-9f12-345678abcd11', [220, 100, 40, 255]],
179
+ ['01935b00-7d8c-7c4e-9f12-345678abcd12', [128, 128, 255, 255]],
180
+ ] as const) {
181
+ const parsed = AssetGuid.parse(guid);
182
+ if (!parsed.ok) throw parsed.error;
183
+ const cataloged = assets.catalog(parsed.value, {
184
+ kind: 'texture',
185
+ shape: { viewDimension: '2d', extent: { width: 1, height: 1 } },
186
+ format: 'rgba8unorm',
187
+ data: new Uint8Array(color),
188
+ colorSpace: 'linear',
189
+ mips: { kind: 'none' },
190
+ });
191
+ if (!cataloged.ok) throw cataloged.error;
192
+ }
193
+ const rootGuid = AssetGuid.parse(root.guid);
194
+ if (!rootGuid.ok) throw rootGuid.error;
195
+ const loaded = await assets.loadByGuid<MaterialAsset>(rootGuid.value);
196
+ if (!loaded.ok) throw loaded.error;
197
+ const readiness = assets.getMaterialReadiness(root.guid);
198
+ expect(readiness?.status).toBe('Ready');
199
+ if (readiness?.status !== 'Ready') throw new Error('material never became ready');
200
+ expect(readiness.artifactDigest).toBe(root.payload.cooked?.artifactDigest);
201
+ const module = loaded.value.passes?.[0]?.program.module;
202
+ if (module === undefined) throw new Error('material has no program');
203
+ const installed = assets.shaderRegistry.findMaterialArtifact(module);
204
+ if (!installed.ok) throw installed.error;
205
+ const program = readiness.programs.find((candidate) =>
206
+ candidate.selections.some((selection) => selection.pass === 'Forward'),
207
+ );
208
+ if (program === undefined) throw new Error('material has no forward program');
209
+ expect(installed.value.source).toBe(new TextDecoder().decode(program.artifact.bytes));
210
+ world
211
+ .spawn(
212
+ { component: Transform, data: { pos: [0, 0, 3], quat: [0, 0, 0, 1], scale: [1, 1, 1] } },
213
+ { component: Camera, data: { fov: Math.PI / 4, aspect: 1, near: 0.1, far: 100 } },
214
+ )
215
+ .unwrap();
216
+ const attached = renderer.attach(world);
217
+ if (!attached.ok) throw attached.error;
218
+ const lease = attached.value;
219
+ const frame = { leases: [lease], camera: { lease }, environment: { lease } };
220
+ const draw = () => {
221
+ world.update().unwrap();
222
+ const rendered = renderer.draw(frame);
223
+ if (!rendered.ok) throw rendered.error;
224
+ };
225
+ const readPixel = async () => {
226
+ if (device === undefined || target === undefined) throw new Error('missing GPU surface');
227
+ const readback = device.createBuffer({
228
+ size: 256,
229
+ usage: 0x08 | 0x01,
230
+ });
231
+ try {
232
+ const encoder = device.createCommandEncoder();
233
+ encoder.copyTextureToBuffer(
234
+ { texture: target, origin: [32, 32] },
235
+ { buffer: readback, bytesPerRow: 256 },
236
+ [1, 1],
237
+ );
238
+ device.queue.submit([encoder.finish()]);
239
+ await readback.mapAsync(0x01);
240
+ const pixel = [...new Uint8Array(readback.getMappedRange()).slice(0, 4)];
241
+ readback.unmap();
242
+ return pixel;
243
+ } finally {
244
+ readback.destroy();
245
+ }
246
+ };
247
+ draw();
248
+ const background = await readPixel();
249
+ const mesh = createBoxGeometry(1, 1, 1).unwrap();
250
+ const entity = world
251
+ .spawn(
252
+ { component: Transform, data: { pos: [0, 0, 0], quat: [0, 0, 0, 1], scale: [1, 1, 1] } },
253
+ { component: MeshFilter, data: { assetHandle: world.allocSharedRef('MeshAsset', mesh) } },
254
+ {
255
+ component: MeshRenderer,
256
+ data: { materials: [world.internSharedRef('MaterialAsset', loaded.value)] },
257
+ },
258
+ )
259
+ .unwrap();
260
+ for (let index = 0; index < 300; index++) {
261
+ draw();
262
+ // Match the host's frame boundary so queued shader/texture preparation runs.
263
+ await new Promise<void>((resolve) => setImmediate(resolve));
264
+ }
265
+ const pixel = await readPixel();
266
+ expect(errors).toEqual([]);
267
+ expect(gpuErrors).toEqual([]);
268
+ expect(
269
+ pixel
270
+ .slice(0, 3)
271
+ .reduce((delta, value, index) => delta + Math.abs(value - (background[index] ?? 0)), 0),
272
+ ).toBeGreaterThan(32);
273
+ world.despawn(entity).unwrap();
274
+ draw();
275
+ const removed = await readPixel();
276
+ expect(removed).toEqual(background);
277
+ expect(errors).toEqual([]);
278
+ expect(gpuErrors).toEqual([]);
279
+ if (process.env.FORGEAX_MATERIAL_SMOKE_EVIDENCE === '1') {
280
+ stdout.write(
281
+ `MATERIAL_DRAW_EVIDENCE=${JSON.stringify({
282
+ status: 'pass',
283
+ renderer: 'forgeax-runtime',
284
+ backend: 'dawn-webgpu',
285
+ frames: 300,
286
+ rootGuid: root.guid,
287
+ rootArtifactDigest: readiness.artifactDigest,
288
+ materialIdentity: { ...readiness.record.receipt.identity, materialGuid: root.guid },
289
+ pixel,
290
+ background,
291
+ removed,
292
+ observed: { enumerate: true, load: true, bind: true, draw: true, readback: true },
293
+ })}\n`,
294
+ );
295
+ }
296
+ } finally {
297
+ renderer.dispose();
298
+ target?.destroy();
299
+ }
300
+ }, 60000);
105
301
  });
@@ -401,6 +401,14 @@ type RuntimeLayerError = RenderError | AssetRuntimeError | SkinError;
401
401
  it('exhaustive switch covers all members without a default branch', () => {
402
402
  function exhaustive(code: RuntimeLayerErrorCode): string {
403
403
  switch (code) {
404
+ case 'auto-exposure-invalid-parameter':
405
+ return 'auto exposure invalid parameter';
406
+ case 'auto-exposure-capability-unavailable':
407
+ return 'auto exposure capability unavailable';
408
+ case 'auto-exposure-stale-generation':
409
+ return 'auto exposure stale generation';
410
+ case 'auto-exposure-stage-failed':
411
+ return 'auto exposure stage failed';
404
412
  case 'lifecycle-construction-failed':
405
413
  return 'lifecycle construction failed';
406
414
  case 'world-lease-invalid':
@@ -586,6 +594,11 @@ type RuntimeLayerError = RenderError | AssetRuntimeError | SkinError;
586
594
  it('exhaustive switch covers all 3 new members alongside existing members', () => {
587
595
  function exhaustive(code: RuntimeLayerErrorCode): string {
588
596
  switch (code) {
597
+ case 'auto-exposure-invalid-parameter':
598
+ case 'auto-exposure-capability-unavailable':
599
+ case 'auto-exposure-stale-generation':
600
+ case 'auto-exposure-stage-failed':
601
+ return 'ok';
589
602
  case 'lifecycle-construction-failed':
590
603
  return 'ok';
591
604
  case 'world-lease-invalid':
@@ -0,0 +1,23 @@
1
+ import { it } from 'vitest';
2
+ import { particleDepthLayouts, verifyFeatureDepthMaterial } from './feature-depth-material.fixture';
3
+
4
+ it('samples depth written by a vertex-only prepared graphics pass', async () => {
5
+ await verifyFeatureDepthMaterial({
6
+ ...particleDepthLayouts[0],
7
+ view: false,
8
+ missing: 'none',
9
+ writeDepth: true,
10
+ });
11
+ }, 120_000);
12
+
13
+ it.each(
14
+ particleDepthLayouts.flatMap((layout) =>
15
+ [false, true].flatMap((view) =>
16
+ (['none', 'material', 'depth'] as const).map((missing) => ({ ...layout, view, missing })),
17
+ ),
18
+ ),
19
+ )(
20
+ 'combines depth/material/particle input: $name view=$view missing=$missing',
21
+ verifyFeatureDepthMaterial,
22
+ 120_000,
23
+ );