@waica/engine 0.22.0 → 0.24.0

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 (47) hide show
  1. package/README.md +33 -0
  2. package/dist/assets/asset-loader.d.ts +1 -1
  3. package/dist/assets/asset-loader.js +1 -1
  4. package/dist/assets/texture-backend.d.ts +1 -1
  5. package/dist/assets/texture-backend.js +1 -1
  6. package/dist/camera.js +1 -1
  7. package/dist/collision-category.js +5 -2
  8. package/dist/collision-dispatch.d.ts +7 -1
  9. package/dist/collision-dispatch.js +18 -11
  10. package/dist/components/animated-sprite.d.ts +2 -0
  11. package/dist/components/animated-sprite.js +42 -7
  12. package/dist/components/sprite.d.ts +6 -0
  13. package/dist/components/sprite.js +53 -5
  14. package/dist/components/tilemap.js +3 -1
  15. package/dist/entity.d.ts +1 -1
  16. package/dist/entity.js +1 -1
  17. package/dist/game.d.ts +22 -3
  18. package/dist/game.js +68 -45
  19. package/dist/index.d.ts +3 -2
  20. package/dist/index.js +3 -1
  21. package/dist/particle-batch.d.ts +1 -1
  22. package/dist/particle-batch.js +4 -1
  23. package/dist/particle-sort-bounds.d.ts +1 -1
  24. package/dist/particle-sort-bounds.js +1 -1
  25. package/dist/pointer.js +1 -1
  26. package/dist/render-output.d.ts +27 -0
  27. package/dist/render-output.js +44 -0
  28. package/dist/render-readiness.d.ts +37 -0
  29. package/dist/render-readiness.js +61 -0
  30. package/dist/render-sort.d.ts +14 -0
  31. package/dist/render-sort.js +37 -0
  32. package/dist/runtime-bridge.d.ts +18 -2
  33. package/dist/runtime-bridge.js +7 -1
  34. package/dist/scene.d.ts +10 -0
  35. package/dist/spatial-broadphase.d.ts +7 -1
  36. package/dist/spatial-broadphase.js +16 -45
  37. package/dist/spatial-pairs.d.ts +17 -0
  38. package/dist/spatial-pairs.js +79 -0
  39. package/dist/sprite-batch.d.ts +93 -0
  40. package/dist/sprite-batch.js +223 -0
  41. package/dist/sprite-batches.d.ts +107 -0
  42. package/dist/sprite-batches.js +260 -0
  43. package/dist/sprite-draw-collector.d.ts +42 -0
  44. package/dist/sprite-draw-collector.js +107 -0
  45. package/dist/sprite-runs.d.ts +40 -0
  46. package/dist/sprite-runs.js +49 -0
  47. package/package.json +1 -1
package/dist/game.js CHANGED
@@ -1,4 +1,4 @@
1
- import * as THREE from 'three';
1
+ import * as THREE from 'three/webgpu';
2
2
  import { gameViewport } from './anchored-pieces.js';
3
3
  import { AssetLoader } from './assets/asset-loader.js';
4
4
  import { AudioSubsystem } from './audio/audio-subsystem.js';
@@ -15,9 +15,12 @@ import { Pointer } from './pointer.js';
15
15
  import { activeRuntimeBridgeHook, EngineRuntimeBridge, } from './runtime-bridge.js';
16
16
  import { RuntimeInspector } from './runtime-inspection.js';
17
17
  import { projectIsometric, unprojectIsometric } from './projection.js';
18
- import { isYSortBatchParticipant, isYSortParticipant, ySortZ, } from './render-sort.js';
18
+ import { canvasBackground, drawStraightToCanvas, mapUvPerVertex } from './render-output.js';
19
+ import { RenderReadiness } from './render-readiness.js';
20
+ import { applyYSort } from './render-sort.js';
19
21
  import { loadScene, registryEntry, spawnFromJson, } from './scene.js';
20
22
  import { sceneDrainsOf } from './scene-drains.js';
23
+ import { createSpriteBatches } from './sprite-batches.js';
21
24
  import { createSpatialQuery } from './spatial-query.js';
22
25
  import { Stats } from './stats.js';
23
26
  import { anchoredPiecesOf, GameUi } from './ui.js';
@@ -69,9 +72,12 @@ export class Game {
69
72
  * or collisions — the editor's edit mode.
70
73
  */
71
74
  simulate = true;
72
- // TODO(H1): migrate to WebGPURenderer (three/webgpu) with automatic WebGL2 fallback.
75
+ /** WebGPU when the browser offers it, else three's WebGL2 fallback (ADR 0025). */
73
76
  renderer;
77
+ readiness;
74
78
  resizeObserver;
79
+ /** Scene-scoped Sprite Batches (ADR 0024); `render.batch: false` opts a scene out. */
80
+ spriteBatches;
75
81
  updateFns = new Set();
76
82
  invalidUpdateCompositions = new WeakMap();
77
83
  /**
@@ -102,6 +108,8 @@ export class Game {
102
108
  liveSceneName = null;
103
109
  /** True for the whole extent of a runFrame() call, incl. its tail. */
104
110
  insideFrame = false;
111
+ /** Set by dispose(): a disposed Game never starts again nor reports its renderer (review #3). */
112
+ disposed = false;
105
113
  /** A loadSceneByName() enqueued while insideFrame; applied at the next runFrame's start. */
106
114
  pendingSceneLoad = null;
107
115
  constructor(options) {
@@ -133,14 +141,25 @@ export class Game {
133
141
  // catalog is registered at call time, which outlives every scene.
134
142
  resolveAsset: (uri) => this.sceneCatalog?.registry.resolveAsset?.(uri) ?? uri,
135
143
  });
144
+ this.spriteBatches = createSpriteBatches(this, this.assets);
136
145
  this.cameraEffects = new CameraEffects({
137
146
  host: () => canvas.parentElement ?? document.body,
138
147
  // One screen pixel in world units: the shake snaps to it (never the base).
139
148
  pixel: () => (this.resolution ? this.viewHeight / this.resolution.height : null),
140
149
  });
141
- this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
150
+ this.renderer = new THREE.WebGPURenderer({ canvas, antialias: true });
142
151
  this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
143
- this.scene.background = new THREE.Color(background);
152
+ // The model view on the CPU, as three's WebGL renderer computed it: a Sprite
153
+ // Batch instance carries that same matrix (ADR 0024), so batched and
154
+ // per-sprite draws stay pixel-identical.
155
+ this.renderer.highPrecision = true;
156
+ drawStraightToCanvas(this.renderer);
157
+ mapUvPerVertex(this.renderer);
158
+ this.readiness = new RenderReadiness(this.renderer, (error) => {
159
+ if (!this.disposed)
160
+ activeRuntimeBridgeHook()?.fail?.({ code: 'render-backend-failed', message: error.message });
161
+ });
162
+ this.scene.background = canvasBackground(background);
144
163
  this.camera = new THREE.OrthographicCamera();
145
164
  this.camera.position.z = 10;
146
165
  this.pointer = new Pointer(canvas, {
@@ -153,6 +172,19 @@ export class Game {
153
172
  this.resizeObserver = new ResizeObserver(() => this.resize());
154
173
  this.resizeObserver.observe(canvas);
155
174
  }
175
+ /**
176
+ * Resolves once the renderer can draw; rejects, naming both Render
177
+ * Backends, when neither initializes. Every call returns the same promise.
178
+ * Frames before it simulate but draw nothing, so a host awaits it before
179
+ * its first frame (ADR 0025).
180
+ */
181
+ ready() {
182
+ return this.readiness.promise;
183
+ }
184
+ /** The Render Backend the renderer settled on; null until ready() resolved. */
185
+ get backend() {
186
+ return this.readiness.backend;
187
+ }
156
188
  /** Creates a live entity in the scene. */
157
189
  spawn(name) {
158
190
  const entity = new Entity(this, name);
@@ -205,6 +237,8 @@ export class Game {
205
237
  entity.destroy();
206
238
  // Drains transferred by the destruction cascade above are scene-scoped too.
207
239
  sceneDrainsOf(this).clear();
240
+ // After the cascade freed every slot: the batches go with their scene.
241
+ this.spriteBatches.unload();
208
242
  this.registry = null;
209
243
  this.renderSort = null;
210
244
  this.sceneProjection = null;
@@ -299,6 +333,7 @@ export class Game {
299
333
  */
300
334
  setSceneRender(json) {
301
335
  this.renderSort = json?.sort === 'y' ? 'y' : null;
336
+ this.spriteBatches.enabled = json?.batch !== false;
302
337
  const projection = json?.projection === 'isometric' ? 'isometric' : null;
303
338
  if (projection === this.sceneProjection)
304
339
  return;
@@ -331,6 +366,8 @@ export class Game {
331
366
  this.setViewHeight(this.sceneCamera.zoom);
332
367
  }
333
368
  start() {
369
+ if (this.disposed)
370
+ return;
334
371
  const activation = activeRuntimeBridgeHook();
335
372
  if (activation) {
336
373
  if (!this.runtimeBridge) {
@@ -350,19 +387,27 @@ export class Game {
350
387
  loadScene: (name) => this.loadSceneByName(name),
351
388
  availableScenes: () => this.availableScenes,
352
389
  assets: () => this.assets.status,
390
+ backend: () => this.backend,
353
391
  });
354
392
  activation.register(this.runtimeBridge);
355
393
  this.audio.setSilenced(true);
356
394
  window.addEventListener('pagehide', this.unregisterRuntimeBridge);
357
395
  }
358
396
  this.renderSurface();
397
+ // Started before ready(): the paused frame draws once the renderer can, frame unchanged.
398
+ if (!this.readiness.isReady) {
399
+ this.readiness.whenReady(() => {
400
+ if (this.runtimeBridge)
401
+ this.renderSurface();
402
+ });
403
+ }
359
404
  return;
360
405
  }
361
406
  this.resetClock();
362
- this.renderer.setAnimationLoop((time) => this.tick(time));
407
+ this.setAnimationLoop((time) => this.tick(time));
363
408
  }
364
409
  stop() {
365
- this.renderer.setAnimationLoop(null);
410
+ this.setAnimationLoop(null);
366
411
  }
367
412
  /** Internal: called by Entity.destroy(). Its Anchored Pieces go (or freeze) with it. */
368
413
  removeEntity(entity) {
@@ -385,6 +430,7 @@ export class Game {
385
430
  }
386
431
  /** Shuts the game down completely (loop, input, GPU). */
387
432
  dispose() {
433
+ this.disposed = true;
388
434
  this.stop();
389
435
  this.unregisterRuntimeBridge();
390
436
  this.input.dispose();
@@ -399,10 +445,18 @@ export class Game {
399
445
  for (const entity of [...this.entities])
400
446
  entity.destroy();
401
447
  sceneDrainsOf(this).clear();
448
+ this.spriteBatches.unload();
402
449
  // After the entities: their clones go with the cascade above, the
403
450
  // cached bases go here, exactly once (ADR 0019).
404
451
  this.assets.dispose();
405
- this.renderer.dispose();
452
+ this.readiness.disposeRenderer();
453
+ }
454
+ /**
455
+ * three installs the loop once its init settled; a failed init is
456
+ * reported by ready(), not by every loop change made before it.
457
+ */
458
+ setAnimationLoop(loop) {
459
+ this.renderer.setAnimationLoop(loop).catch(() => { });
406
460
  }
407
461
  /**
408
462
  * Real-time playback for the Runtime Bridge: the same clock-driven loop
@@ -413,7 +467,7 @@ export class Game {
413
467
  */
414
468
  resumeRuntime(onStep) {
415
469
  this.resetClock();
416
- this.renderer.setAnimationLoop((time) => this.tick(time, onStep));
470
+ this.setAnimationLoop((time) => this.tick(time, onStep));
417
471
  }
418
472
  /** Forgets the clock and any partial step, so the next frame runs no burst. */
419
473
  resetClock() {
@@ -541,40 +595,6 @@ export class Game {
541
595
  this.runtimeBridge = null;
542
596
  this.audio.setSilenced(false);
543
597
  };
544
- /** Under y-sort, re-derives every participant's z from layer band + entity Y. */
545
- applyYSort() {
546
- const singles = [];
547
- const batches = [];
548
- const entries = [];
549
- for (const entity of this.entities) {
550
- for (const component of entity.components) {
551
- if (isYSortBatchParticipant(component)) {
552
- const batchEntries = component.ySortEntries();
553
- batches.push({ participant: component, start: entries.length, count: batchEntries.length });
554
- entries.push(...batchEntries);
555
- }
556
- else if (isYSortParticipant(component)) {
557
- singles.push({ participant: component, index: entries.length });
558
- entries.push({ layer: component.layer, y: entity.node.position.y });
559
- }
560
- }
561
- }
562
- for (const participant of sceneDrainsOf(this).participants()) {
563
- const batchEntries = participant.ySortEntries();
564
- batches.push({ participant, start: entries.length, count: batchEntries.length });
565
- entries.push(...batchEntries);
566
- }
567
- const z = ySortZ(entries);
568
- for (const { participant, index } of singles) {
569
- const sortZ = z[index];
570
- if (sortZ === undefined)
571
- throw new Error(`ySortZ returned no z for y-sort participant ${index}`);
572
- participant.setSortZ(sortZ);
573
- }
574
- for (const { participant, start, count } of batches) {
575
- participant.setSortZs(z.slice(start, start + count));
576
- }
577
- }
578
598
  renderSurface() {
579
599
  if (this.sceneProjection === 'isometric') {
580
600
  for (const entity of this.entities) {
@@ -584,7 +604,7 @@ export class Game {
584
604
  }
585
605
  }
586
606
  if (this.renderSort === 'y')
587
- this.applyYSort();
607
+ applyYSort(this.entities, sceneDrainsOf(this).participants());
588
608
  this.ui.setActive(this.simulate);
589
609
  // The shake offset exists only while drawing (ADR 0020): Anchored Pieces
590
610
  // and the render see the drawn center, then the base comes back exactly,
@@ -595,6 +615,9 @@ export class Game {
595
615
  this.camera.position.y = y + shake.y;
596
616
  try {
597
617
  anchoredPiecesOf(this.ui).place();
618
+ // Before ready() the frame simulated, but three cannot draw yet.
619
+ if (!this.readiness.isReady)
620
+ return;
598
621
  if (this.resolution) {
599
622
  // Letterbox bars: clear the whole canvas, then render inside the scissor.
600
623
  this.renderer.setScissorTest(false);
@@ -602,7 +625,7 @@ export class Game {
602
625
  this.renderer.clear(true, false, false);
603
626
  this.renderer.setScissorTest(true);
604
627
  }
605
- this.renderer.render(this.scene, this.camera);
628
+ this.spriteBatches.drawFrame(this.scene, this.camera, () => this.renderer.render(this.scene, this.camera));
606
629
  }
607
630
  finally {
608
631
  this.camera.position.x = x;
package/dist/index.d.ts CHANGED
@@ -37,7 +37,8 @@ export type { GamepadControl } from './gamepad.js';
37
37
  export { Pointer } from './pointer.js';
38
38
  export type { PointerCamera, PointerDeps, PointerPick, PointerResolution } from './pointer.js';
39
39
  export { RUNTIME_BRIDGE_CAPABILITIES, RUNTIME_BRIDGE_PROTOCOL_VERSION, RUNTIME_BRIDGE_SYMBOL, RuntimeBridgeOperationError, } from './runtime-bridge.js';
40
- export type { RuntimeBridge, RuntimeBridgeActivation, RuntimeControlRequest, RuntimeControlResult, RuntimeMetadata, RuntimeMode, } from './runtime-bridge.js';
40
+ export type { RuntimeBridge, RuntimeBridgeActivation, RuntimeBridgeFailure, RuntimeControlRequest, RuntimeControlResult, RuntimeMetadata, RuntimeMode, } from './runtime-bridge.js';
41
+ export type { RenderBackend } from './render-readiness.js';
41
42
  export { RUNTIME_PROJECTION_LIMITS } from './runtime-inspection.js';
42
43
  export type { ProjectedValue, ProjectionIssue, ProjectionMarker, ProjectionMarkerKind, RuntimeComponentSnapshot, RuntimeEntitySnapshot, RuntimeSnapshot, RuntimeSnapshotAudio, RuntimeSnapshotCamera, RuntimeSnapshotFilters, RuntimeSnapshotTime, RuntimeSnapshotUi, RuntimeTransformSnapshot, } from './runtime-inspection.js';
43
44
  export type { ArchetypeArt, ArchetypeManifest, BrowserArchetypeManifest, EntityTemplate, } from './archetype.js';
@@ -76,4 +77,4 @@ export { StateMachine, evaluateTrigger, nextTransition } from './state/state-mac
76
77
  export type { StateJson, StateTransitionJson, TriggerEnv } from './state/state-machine.js';
77
78
  export { defineRole, defineStates, installArchetype, logicSet, registeredLogicSets, registeredRoles, resetRegistries, roleDefinition, } from './state/hooks.js';
78
79
  export type { ArchetypeBundle, RoleDefinition, RoleGraph, StateContext, StateHooks, StateLogic, } from './state/hooks.js';
79
- export * as THREE from 'three';
80
+ export * as THREE from 'three/webgpu';
package/dist/index.js CHANGED
@@ -42,4 +42,6 @@ export { StateMachine, evaluateTrigger, nextTransition } from './state/state-mac
42
42
  export { defineRole, defineStates, installArchetype, logicSet, registeredLogicSets, registeredRoles, resetRegistries, roleDefinition, } from './state/hooks.js';
43
43
  // Explicit escape hatch while our own API grows: a single source of three
44
44
  // for the whole workspace. The thesis is that three stays an implementation detail.
45
- export * as THREE from 'three';
45
+ // It is the WebGPU build the engine draws with (ADR 0025): no classic WebGL renderer,
46
+ // and node materials (TSL) instead of GLSL shaders.
47
+ export * as THREE from 'three/webgpu';
@@ -1,4 +1,4 @@
1
- import * as THREE from 'three';
1
+ import * as THREE from 'three/webgpu';
2
2
  import type { AssetLoader } from './assets/asset-loader.js';
3
3
  import type { ParticleBlend, ParticleOverflow } from './components/particle-emitter.js';
4
4
  import { type ParticleSpawn } from './particle-pool.js';
@@ -1,4 +1,4 @@
1
- import * as THREE from 'three';
1
+ import * as THREE from 'three/webgpu';
2
2
  import { reportRejection } from './report-rejection.js';
3
3
  import { ParticlePool, particleValueAt, } from './particle-pool.js';
4
4
  import { projectIsometric } from './projection.js';
@@ -116,6 +116,9 @@ export class ParticleBatch {
116
116
  colors.needsUpdate = true;
117
117
  this.sortIndices();
118
118
  this.mesh.geometry.setDrawRange(0, this.active * 6);
119
+ // WebGPURenderer issues a zero-count draw (WebGLRenderer skipped it):
120
+ // an empty batch is not drawn at all.
121
+ this.mesh.visible = this.active > 0;
119
122
  }
120
123
  ySortEntries() {
121
124
  this.sortEntrySlots();
@@ -1,3 +1,3 @@
1
- import * as THREE from 'three';
1
+ import * as THREE from 'three/webgpu';
2
2
  /** Keeps Three's transparent-object sort key on live vertices, never unused capacity. */
3
3
  export declare function refreshParticleSortBounds(geometry: THREE.BufferGeometry, active: number, emptyZ: number): void;
@@ -1,4 +1,4 @@
1
- import * as THREE from 'three';
1
+ import * as THREE from 'three/webgpu';
2
2
  /** Keeps Three's transparent-object sort key on live vertices, never unused capacity. */
3
3
  export function refreshParticleSortBounds(geometry, active, emptyZ) {
4
4
  const positions = geometry.getAttribute('position');
package/dist/pointer.js CHANGED
@@ -140,7 +140,7 @@ export class Pointer {
140
140
  renderX <= centerX + halfW &&
141
141
  renderY >= centerY - halfH &&
142
142
  renderY <= centerY + halfH) {
143
- // Matches Game.applyYSort's key exactly: the entity's node
143
+ // Matches applyYSort (render-sort.ts)'s key exactly: the entity's node
144
144
  // position, which for an isometric scene is the projected Y (the
145
145
  // same `anchor.y` just used to place this box), not the logical one.
146
146
  hits.push({ entity, layer: box.layer, y: anchor.y });
@@ -0,0 +1,27 @@
1
+ import * as THREE from 'three/webgpu';
2
+ /**
3
+ * Makes WebGPURenderer draw the way WebGLRenderer did (ADR 0025): straight
4
+ * into the 8-bit canvas, each material encoding its color to sRGB in its own
5
+ * shader, so alpha blending happens on sRGB values. By default three renders
6
+ * into a linear half-float target and converts it in an extra full-screen
7
+ * pass: blending in linear space, which changes every translucent edge, and
8
+ * one more draw per frame. A render target keeps the linear working space.
9
+ */
10
+ export declare function drawStraightToCanvas(renderer: THREE.WebGPURenderer): void;
11
+ /**
12
+ * The scene background for a canvas drawn straight: three clears with a
13
+ * color's working-space (linear) components, and the canvas stores sRGB, so
14
+ * the clear carries the color's sRGB components — what WebGLRenderer wrote.
15
+ */
16
+ export declare function canvasBackground(color: THREE.ColorRepresentation): THREE.Color;
17
+ /**
18
+ * Applies a material map's UV transform (offset, repeat) per vertex, as
19
+ * WebGLRenderer's `vMapUv` did, instead of per fragment as three's node
20
+ * materials do by default. At a quad's corners the UV is 0 or 1, so the
21
+ * transformed corner UV is the same float however a shader compiler fuses
22
+ * the multiply and add — per fragment it is not, and a sprite whose sheet
23
+ * offset is not 0 could then sample a texel row apart in two shaders of the
24
+ * same picture (a Sprite Batch and its per-sprite fallback, ADR 0024).
25
+ * Textures with their own UV node, or sampled outside a mesh, stay three's.
26
+ */
27
+ export declare function mapUvPerVertex(renderer: THREE.WebGPURenderer): void;
@@ -0,0 +1,44 @@
1
+ import { uv, vertexStage, workingToColorSpace } from 'three/tsl';
2
+ import * as THREE from 'three/webgpu';
3
+ /**
4
+ * Makes WebGPURenderer draw the way WebGLRenderer did (ADR 0025): straight
5
+ * into the 8-bit canvas, each material encoding its color to sRGB in its own
6
+ * shader, so alpha blending happens on sRGB values. By default three renders
7
+ * into a linear half-float target and converts it in an extra full-screen
8
+ * pass: blending in linear space, which changes every translucent edge, and
9
+ * one more draw per frame. A render target keeps the linear working space.
10
+ */
11
+ export function drawStraightToCanvas(renderer) {
12
+ // The working space as output: three then needs no intermediate target.
13
+ renderer.outputColorSpace = THREE.ColorManagement.workingColorSpace;
14
+ const context = renderer.contextNode.value;
15
+ context.getOutput = (output, builder) => builder.renderer.getRenderTarget() === null ? workingToColorSpace(output, THREE.SRGBColorSpace) : output;
16
+ }
17
+ /**
18
+ * The scene background for a canvas drawn straight: three clears with a
19
+ * color's working-space (linear) components, and the canvas stores sRGB, so
20
+ * the clear carries the color's sRGB components — what WebGLRenderer wrote.
21
+ */
22
+ export function canvasBackground(color) {
23
+ const { r, g, b } = new THREE.Color(color).getRGB({ r: 0, g: 0, b: 0 }, THREE.SRGBColorSpace);
24
+ return new THREE.Color().setRGB(r, g, b, THREE.LinearSRGBColorSpace);
25
+ }
26
+ /**
27
+ * Applies a material map's UV transform (offset, repeat) per vertex, as
28
+ * WebGLRenderer's `vMapUv` did, instead of per fragment as three's node
29
+ * materials do by default. At a quad's corners the UV is 0 or 1, so the
30
+ * transformed corner UV is the same float however a shader compiler fuses
31
+ * the multiply and add — per fragment it is not, and a sprite whose sheet
32
+ * offset is not 0 could then sample a texel row apart in two shaders of the
33
+ * same picture (a Sprite Batch and its per-sprite fallback, ADR 0024).
34
+ * Textures with their own UV node, or sampled outside a mesh, stay three's.
35
+ */
36
+ export function mapUvPerVertex(renderer) {
37
+ const context = renderer.contextNode.value;
38
+ context.getUV = (texture, builder) => {
39
+ if (texture.uvNode !== null || !builder.object.isMesh)
40
+ return null;
41
+ texture.setUpdateMatrix(false);
42
+ return vertexStage(texture.getTransformedUV(uv(texture.value.channel)));
43
+ };
44
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * The GPU API a Game draws through (CONTEXT.md): `webgpu` when the browser
3
+ * offers it, otherwise three's own WebGL2 fallback. The browser decides; no
4
+ * option forces either one (ADR 0025).
5
+ */
6
+ export type RenderBackend = 'webgpu' | 'webgl2';
7
+ /** The part of three's WebGPURenderer readiness depends on. */
8
+ export interface InitializingRenderer {
9
+ init(): Promise<unknown>;
10
+ dispose(): Promise<void>;
11
+ /** After init(), the backend it settled on: the WebGPU one, or the WebGL2 fallback. */
12
+ readonly backend: object;
13
+ }
14
+ /**
15
+ * A renderer's asynchronous initialization, owned by its Game: started at
16
+ * construction, settled once, and the only gate between a frame and a draw.
17
+ * `promise` is what `game.ready()` hands out; a failure rejects it with an
18
+ * error naming both Render Backends and is also reported to `onFailure`.
19
+ */
20
+ export declare class RenderReadiness {
21
+ private readonly renderer;
22
+ readonly promise: Promise<void>;
23
+ private state;
24
+ constructor(renderer: InitializingRenderer, onFailure: (error: Error) => void);
25
+ /** Runs `then` once the renderer is ready; never after a failed init. */
26
+ whenReady(then: () => void): void;
27
+ /** True once the renderer can draw. */
28
+ get isReady(): boolean;
29
+ /** The Render Backend after a successful init; null before it and after a failure. */
30
+ get backend(): RenderBackend | null;
31
+ /**
32
+ * Frees the renderer: right away once it is ready, after init settles
33
+ * while it is pending (three cannot dispose a renderer mid-init), never
34
+ * after a failed init (nothing was allocated).
35
+ */
36
+ disposeRenderer(): void;
37
+ }
@@ -0,0 +1,61 @@
1
+ /** three flags its WebGPU backend with `isWebGPUBackend`; its WebGL2 fallback has no such flag. */
2
+ function backendOf(backend) {
3
+ return 'isWebGPUBackend' in backend && backend.isWebGPUBackend === true ? 'webgpu' : 'webgl2';
4
+ }
5
+ function describeCause(cause) {
6
+ return cause instanceof Error ? cause.message : String(cause);
7
+ }
8
+ /**
9
+ * A renderer's asynchronous initialization, owned by its Game: started at
10
+ * construction, settled once, and the only gate between a frame and a draw.
11
+ * `promise` is what `game.ready()` hands out; a failure rejects it with an
12
+ * error naming both Render Backends and is also reported to `onFailure`.
13
+ */
14
+ export class RenderReadiness {
15
+ renderer;
16
+ promise;
17
+ state = { status: 'pending' };
18
+ constructor(renderer, onFailure) {
19
+ this.renderer = renderer;
20
+ this.promise = renderer.init().then(() => {
21
+ this.state = { status: 'ready', backend: backendOf(renderer.backend) };
22
+ }, (cause) => {
23
+ this.state = { status: 'failed' };
24
+ const error = new Error(`No Render Backend could initialize: neither webgpu nor webgl2 is available (${describeCause(cause)}).`, { cause });
25
+ onFailure(error);
26
+ throw error;
27
+ });
28
+ // The rejection belongs to whoever awaits game.ready(); a Game nobody
29
+ // asked must not surface it as an unhandled rejection on top of that.
30
+ this.promise.catch(() => { });
31
+ }
32
+ /** Runs `then` once the renderer is ready; never after a failed init. */
33
+ whenReady(then) {
34
+ this.promise.then(then, () => { });
35
+ }
36
+ /** True once the renderer can draw. */
37
+ get isReady() {
38
+ return this.state.status === 'ready';
39
+ }
40
+ /** The Render Backend after a successful init; null before it and after a failure. */
41
+ get backend() {
42
+ return this.state.status === 'ready' ? this.state.backend : null;
43
+ }
44
+ /**
45
+ * Frees the renderer: right away once it is ready, after init settles
46
+ * while it is pending (three cannot dispose a renderer mid-init), never
47
+ * after a failed init (nothing was allocated).
48
+ */
49
+ disposeRenderer() {
50
+ const dispose = () => {
51
+ this.renderer.dispose().catch((error) => {
52
+ console.error('[waica] could not dispose the renderer', error);
53
+ });
54
+ };
55
+ if (this.isReady) {
56
+ dispose();
57
+ return;
58
+ }
59
+ this.promise.then(dispose, () => { });
60
+ }
61
+ }
@@ -35,3 +35,17 @@ export declare function isYSortBatchParticipant(value: unknown): value is YSortB
35
35
  * apart, so their band is exactly the old fixed 0.01.
36
36
  */
37
37
  export declare function ySortZ(entries: readonly YSortEntry[]): number[];
38
+ /** What applyYSort reads of an entity: its components and the Y its node draws at. */
39
+ export interface YSortEntity {
40
+ readonly components: readonly unknown[];
41
+ readonly node: {
42
+ readonly position: {
43
+ readonly y: number;
44
+ };
45
+ };
46
+ }
47
+ /**
48
+ * Under y-sort, re-derives every participant's z from layer band + entity Y:
49
+ * the scene's entities, then the batch participants its drains hold.
50
+ */
51
+ export declare function applyYSort(entities: readonly YSortEntity[], drains: readonly YSortBatchParticipant[]): void;
@@ -48,3 +48,40 @@ export function ySortZ(entries) {
48
48
  }
49
49
  return z;
50
50
  }
51
+ /**
52
+ * Under y-sort, re-derives every participant's z from layer band + entity Y:
53
+ * the scene's entities, then the batch participants its drains hold.
54
+ */
55
+ export function applyYSort(entities, drains) {
56
+ const singles = [];
57
+ const batches = [];
58
+ const entries = [];
59
+ for (const entity of entities) {
60
+ for (const component of entity.components) {
61
+ if (isYSortBatchParticipant(component)) {
62
+ const batchEntries = component.ySortEntries();
63
+ batches.push({ participant: component, start: entries.length, count: batchEntries.length });
64
+ entries.push(...batchEntries);
65
+ }
66
+ else if (isYSortParticipant(component)) {
67
+ singles.push({ participant: component, index: entries.length });
68
+ entries.push({ layer: component.layer, y: entity.node.position.y });
69
+ }
70
+ }
71
+ }
72
+ for (const participant of drains) {
73
+ const batchEntries = participant.ySortEntries();
74
+ batches.push({ participant, start: entries.length, count: batchEntries.length });
75
+ entries.push(...batchEntries);
76
+ }
77
+ const z = ySortZ(entries);
78
+ for (const { participant, index } of singles) {
79
+ const sortZ = z[index];
80
+ if (sortZ === undefined)
81
+ throw new Error(`ySortZ returned no z for y-sort participant ${index}`);
82
+ participant.setSortZ(sortZ);
83
+ }
84
+ for (const { participant, start, count } of batches) {
85
+ participant.setSortZs(z.slice(start, start + count));
86
+ }
87
+ }
@@ -1,4 +1,5 @@
1
1
  import type { AssetStatus } from './assets/asset-loader.js';
2
+ import type { RenderBackend } from './render-readiness.js';
2
3
  import type { RuntimeSnapshot, RuntimeSnapshotFilters } from './runtime-inspection.js';
3
4
  export declare const RUNTIME_BRIDGE_PROTOCOL_VERSION: 1;
4
5
  export declare const RUNTIME_BRIDGE_SYMBOL: unique symbol;
@@ -20,9 +21,13 @@ export type RuntimeMode = 'paused' | 'real-time';
20
21
  * from `game.cameraEffects`; the `scene` operation stays a hard cut.
21
22
  * `analog-actions` (issue #75, ADR 0023) announces that `hold` takes an
22
23
  * optional `value` in (0, 1] and that every control result carries
23
- * `actionValues` beside `heldActions`.
24
+ * `actionValues` beside `heldActions`. `render-backend` (issue #140, ADR
25
+ * 0025) announces that every metadata carries `backend`: the Render Backend
26
+ * once the renderer is ready, null before — a Run Session waits for it — and
27
+ * that a renderer that fails to initialize reports through the activation's
28
+ * `fail`.
24
29
  */
25
- export declare const RUNTIME_BRIDGE_CAPABILITIES: readonly ['click', 'scene', 'fixed-step', 'assets', 'camera-effects', 'analog-actions'];
30
+ export declare const RUNTIME_BRIDGE_CAPABILITIES: readonly ['click', 'scene', 'fixed-step', 'assets', 'camera-effects', 'analog-actions', 'render-backend'];
26
31
  export interface RuntimeMetadata {
27
32
  bridgeVersion: typeof RUNTIME_BRIDGE_PROTOCOL_VERSION;
28
33
  engineVersion: string;
@@ -32,6 +37,8 @@ export interface RuntimeMetadata {
32
37
  capabilities: readonly string[];
33
38
  /** `game.assets.status` at the moment of the read — reading it never advances `frame`. */
34
39
  assets: AssetStatus;
40
+ /** The Render Backend once `game.ready()` resolved; null until then (and for good after a failed init). */
41
+ backend: RenderBackend | null;
35
42
  }
36
43
  export type RuntimeControlRequest = {
37
44
  operation: 'press' | 'release';
@@ -76,11 +83,18 @@ export interface RuntimeBridge {
76
83
  inspect(filters?: RuntimeSnapshotFilters): RuntimeSnapshot;
77
84
  control(request: RuntimeControlRequest): RuntimeControlResult;
78
85
  }
86
+ /** Why a Game can never become operational; reported through the activation. */
87
+ export interface RuntimeBridgeFailure {
88
+ code: 'render-backend-failed';
89
+ message: string;
90
+ }
79
91
  /** Ephemeral pre-page hook installed by the owner of a browser context. */
80
92
  export interface RuntimeBridgeActivation {
81
93
  readonly protocolVersion: typeof RUNTIME_BRIDGE_PROTOCOL_VERSION;
82
94
  register(bridge: RuntimeBridge): void;
83
95
  unregister(bridge: RuntimeBridge): void;
96
+ /** Optional: told when the Game's renderer fails to initialize, so the owner stops waiting. */
97
+ fail?(failure: RuntimeBridgeFailure): void;
84
98
  }
85
99
  export declare function activeRuntimeBridgeHook(): RuntimeBridgeActivation | null;
86
100
  export interface RuntimeBridgeHost {
@@ -104,6 +118,8 @@ export interface RuntimeBridgeHost {
104
118
  availableScenes(): string[];
105
119
  /** A fresh `game.assets.status`. */
106
120
  assets(): AssetStatus;
121
+ /** The Render Backend, null until the renderer is ready. */
122
+ backend(): RenderBackend | null;
107
123
  }
108
124
  export declare class EngineRuntimeBridge implements RuntimeBridge {
109
125
  readonly surface: HTMLCanvasElement;
@@ -20,7 +20,11 @@ export const RUNTIME_BRIDGE_SYMBOL = Symbol.for('@waica/runtime-bridge/v1');
20
20
  * from `game.cameraEffects`; the `scene` operation stays a hard cut.
21
21
  * `analog-actions` (issue #75, ADR 0023) announces that `hold` takes an
22
22
  * optional `value` in (0, 1] and that every control result carries
23
- * `actionValues` beside `heldActions`.
23
+ * `actionValues` beside `heldActions`. `render-backend` (issue #140, ADR
24
+ * 0025) announces that every metadata carries `backend`: the Render Backend
25
+ * once the renderer is ready, null before — a Run Session waits for it — and
26
+ * that a renderer that fails to initialize reports through the activation's
27
+ * `fail`.
24
28
  */
25
29
  export const RUNTIME_BRIDGE_CAPABILITIES = [
26
30
  'click',
@@ -29,6 +33,7 @@ export const RUNTIME_BRIDGE_CAPABILITIES = [
29
33
  'assets',
30
34
  'camera-effects',
31
35
  'analog-actions',
36
+ 'render-backend',
32
37
  ];
33
38
  export class RuntimeBridgeOperationError extends Error {
34
39
  code;
@@ -79,6 +84,7 @@ export class EngineRuntimeBridge {
79
84
  simulationTime: this.frame * SIMULATION_STEP,
80
85
  capabilities: RUNTIME_BRIDGE_CAPABILITIES,
81
86
  assets: this.host.assets(),
87
+ backend: this.host.backend(),
82
88
  };
83
89
  }
84
90
  inspect(filters = {}) {
package/dist/scene.d.ts CHANGED
@@ -37,6 +37,16 @@ export interface SceneRenderJson {
37
37
  sort?: 'y';
38
38
  /** Logical positions are projected to the fixed 2:1 render lattice. */
39
39
  projection?: 'isometric';
40
+ /**
41
+ * Sprite Batches (ADR 0024). Absent or true: sprites that share art —
42
+ * same texture (an AnimatedSprite's current sheet), pixel-art filtering and
43
+ * shape — and sit next to each other in draw order are drawn together in
44
+ * one call, each one an instance with its own placement, color and frame;
45
+ * what is drawn in front of what never changes. false: one mesh, one
46
+ * material and one draw call per sprite, the pre-batching path — set it to
47
+ * rule batching out of a visual difference or while debugging draw order.
48
+ */
49
+ batch?: boolean;
40
50
  }
41
51
  export interface SceneJson {
42
52
  waicaScene: 1 | 2 | 3;