@waica/engine 0.22.0 → 0.23.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.
- package/README.md +14 -0
- package/dist/collision-category.js +5 -2
- package/dist/collision-dispatch.d.ts +7 -1
- package/dist/collision-dispatch.js +18 -11
- package/dist/components/animated-sprite.d.ts +2 -0
- package/dist/components/animated-sprite.js +41 -6
- package/dist/components/sprite.d.ts +6 -0
- package/dist/components/sprite.js +52 -4
- package/dist/game.d.ts +2 -0
- package/dist/game.js +9 -1
- package/dist/scene.d.ts +10 -0
- package/dist/spatial-broadphase.d.ts +7 -1
- package/dist/spatial-broadphase.js +16 -45
- package/dist/spatial-pairs.d.ts +17 -0
- package/dist/spatial-pairs.js +79 -0
- package/dist/sprite-batch.d.ts +93 -0
- package/dist/sprite-batch.js +211 -0
- package/dist/sprite-batches.d.ts +107 -0
- package/dist/sprite-batches.js +260 -0
- package/dist/sprite-draw-collector.d.ts +42 -0
- package/dist/sprite-draw-collector.js +107 -0
- package/dist/sprite-runs.d.ts +40 -0
- package/dist/sprite-runs.js +49 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -150,6 +150,20 @@ The cues that hook a `StateMachine` must come after it and after the `ParticleEm
|
|
|
150
150
|
|
|
151
151
|
`active`, `capacity`, and `emitting` are the only fields exposed by Runtime Snapshot. Particle arrays remain private. This first CPU implementation is intended for bounded 2D effects, not collision, per-particle scripting, GPU simulation, trails, rotation, or sub-emitters.
|
|
152
152
|
|
|
153
|
+
## Sprite Batches
|
|
154
|
+
|
|
155
|
+
`Sprite` and `AnimatedSprite` are drawn through **Sprite Batches** by default (ADR 0024). Every frame, after y-sort, the engine orders everything it draws exactly as three would without batching — layer bands, then y-sort, then spawn order, with particle batches, tilemaps and any other mesh in between — and each run of consecutive sprites that share a batch key becomes one instanced draw call. The key is the art and how it is sampled: the texture (an `AnimatedSprite`'s current sheet), `pixelArt` and `shape`. Each sprite is one instance carrying its own placement, color, frame and flip, so tinting, resizing, re-anchoring or animating a sprite never creates a material, and what is drawn in front of what never changes.
|
|
156
|
+
|
|
157
|
+
- **On by default.** A scene without a `render` block, or with `render.batch: true`, batches. Nothing to configure: sprites of one texture in a row draw in one call, a sprite of other art (or a particle batch, a tilemap) in between starts a new run.
|
|
158
|
+
- **Culling and Group order kept.** A sprite outside the camera is skipped for the frame, as three culls a sprite's own mesh, and an off-camera renderable never splits a run. A run hangs under a Group with its sprites' Group `renderOrder`, so `entity.node.renderOrder` orders batched sprites exactly as it orders their meshes without batching.
|
|
159
|
+
- **Shared art.** All live sprites of one key share one material and one texture clone, scene-scoped: released with the scene at `unloadScene()` and `game.dispose()`. A sprite whose texture fails moves alone to the untextured entry of its shape and keeps showing its `color`.
|
|
160
|
+
- **Reusable slots and buffers.** A destroyed sprite's slot goes to the next spawn of its key. Each run draws through a pooled instance buffer sized by that run: 16 instances at first, doubling when a longer run needs it, never shrinking during the scene. A key drawn as several runs keeps one buffer per run, so instance memory stays proportional to the sprites drawn. Once the buffers have grown to a scene's longest runs (and as many runs as it ever splits into), spawning, destroying and reordering sprites allocates no GPU objects.
|
|
161
|
+
- **Turning it off.** Scene JSON `"render": { "batch": false }` (the editor's **Sprite batching** toggle in the scene inspector) restores the per-sprite path: one mesh, one material and one draw call per sprite. Use it to rule batching out when a scene looks different than you expect, or while debugging draw order; the cost is one draw call per sprite, which is what dominates frame time with thousands of sprites.
|
|
162
|
+
|
|
163
|
+
Under batching, the object a sprite adds to its entity is a hidden placement anchor that is never drawn. Hide or show a batched sprite through its entity (`entity.node.visible`, as `Health` blinking does); the anchor's own `visible` is not read for the batched draw, and setting it to true makes three also draw the anchor as a plain quad (without the sprite's tint or frame) — leave it alone, or use `render.batch: false` when code needs one real mesh per sprite.
|
|
164
|
+
|
|
165
|
+
How much batching saves depends on how interleaved a scene is: one texture over the whole scene draws in one call, while a top-down scene that alternates many textures by y gains little. Runtime Snapshots are unchanged — batches are not entities, and sprites report the same state either way.
|
|
166
|
+
|
|
153
167
|
## Component lifecycle
|
|
154
168
|
|
|
155
169
|
Waica keeps the lifecycle boundaries distinct:
|
|
@@ -5,7 +5,10 @@ export function validCollisionLayer(value) {
|
|
|
5
5
|
}
|
|
6
6
|
/** Package-internal exact directional interest check. */
|
|
7
7
|
export function collisionMaskTargets(mask, targetLayer) {
|
|
8
|
-
if (!
|
|
8
|
+
if (!Array.isArray(mask))
|
|
9
9
|
return false;
|
|
10
|
-
|
|
10
|
+
// Membership first: with explicit masks it rejects most pairs of a dense
|
|
11
|
+
// scene before the layer's syntax check runs. A '*' mask still checks it.
|
|
12
|
+
const named = mask.some((entry) => typeof entry === 'string' && (entry === '*' || entry === targetLayer));
|
|
13
|
+
return named && validCollisionLayer(targetLayer);
|
|
11
14
|
}
|
|
@@ -4,5 +4,11 @@ export interface CollisionDispatchStats {
|
|
|
4
4
|
readonly candidatePairs: number;
|
|
5
5
|
readonly narrowphaseCalls: number;
|
|
6
6
|
}
|
|
7
|
-
/**
|
|
7
|
+
/**
|
|
8
|
+
* Game's package-internal trigger dispatch implementation. Pairs come from a
|
|
9
|
+
* frozen grid; each pair is then checked live, most selective checks first
|
|
10
|
+
* (liveness, masks, then the snapshotted Hitbox still mounted). The checks
|
|
11
|
+
* have no side effects, so their order only changes how fast a pair is
|
|
12
|
+
* rejected.
|
|
13
|
+
*/
|
|
8
14
|
export declare function dispatchCollisions(game: Game): CollisionDispatchStats;
|
|
@@ -3,34 +3,41 @@ import { collisionMaskTargets } from './collision-category.js';
|
|
|
3
3
|
import { collisionOverlap } from './collision-shape.js';
|
|
4
4
|
import { Hitbox } from './components/hitbox.js';
|
|
5
5
|
import { createHitboxBroadphase } from './spatial-broadphase.js';
|
|
6
|
-
/**
|
|
6
|
+
/**
|
|
7
|
+
* Game's package-internal trigger dispatch implementation. Pairs come from a
|
|
8
|
+
* frozen grid; each pair is then checked live, most selective checks first
|
|
9
|
+
* (liveness, masks, then the snapshotted Hitbox still mounted). The checks
|
|
10
|
+
* have no side effects, so their order only changes how fast a pair is
|
|
11
|
+
* rejected.
|
|
12
|
+
*/
|
|
7
13
|
export function dispatchCollisions(game) {
|
|
8
|
-
|
|
14
|
+
let candidatePairs = 0;
|
|
9
15
|
let narrowphaseCalls = 0;
|
|
10
|
-
|
|
16
|
+
createHitboxBroadphase(game).forEachPair((first, second) => {
|
|
17
|
+
candidatePairs += 1;
|
|
11
18
|
const a = first.entity;
|
|
12
19
|
const b = second.entity;
|
|
13
20
|
if (!a.alive || !b.alive)
|
|
14
|
-
|
|
15
|
-
if (a.get(Hitbox) !== first.hitbox || b.get(Hitbox) !== second.hitbox)
|
|
16
|
-
continue;
|
|
21
|
+
return;
|
|
17
22
|
const notifyA = collisionMaskTargets(first.hitbox.collidesWith, second.hitbox.layer);
|
|
18
23
|
const notifyB = collisionMaskTargets(second.hitbox.collidesWith, first.hitbox.layer);
|
|
19
24
|
if (!notifyA && !notifyB)
|
|
20
|
-
|
|
25
|
+
return;
|
|
26
|
+
if (a.get(Hitbox) !== first.hitbox || b.get(Hitbox) !== second.hitbox)
|
|
27
|
+
return;
|
|
21
28
|
narrowphaseCalls += 1;
|
|
22
29
|
if (!collisionOverlap(collisionBody(first.hitbox), collisionBody(second.hitbox)))
|
|
23
|
-
|
|
30
|
+
return;
|
|
24
31
|
if (notifyA) {
|
|
25
32
|
for (const component of [...a.components])
|
|
26
33
|
component.onCollide?.(b);
|
|
27
34
|
}
|
|
28
35
|
if (!a.alive || !b.alive)
|
|
29
|
-
|
|
36
|
+
return;
|
|
30
37
|
if (notifyB) {
|
|
31
38
|
for (const component of [...b.components])
|
|
32
39
|
component.onCollide?.(a);
|
|
33
40
|
}
|
|
34
|
-
}
|
|
35
|
-
return { candidatePairs
|
|
41
|
+
});
|
|
42
|
+
return { candidatePairs, narrowphaseCalls };
|
|
36
43
|
}
|
|
@@ -113,6 +113,8 @@ export declare class AnimatedSprite extends Component implements YSortParticipan
|
|
|
113
113
|
* touches game.assets, so it counts nowhere and warns about nothing.
|
|
114
114
|
*/
|
|
115
115
|
private sheetTexture;
|
|
116
|
+
/** The batch key of a sheet: its art, this sprite's filtering, a rectangle. */
|
|
117
|
+
private sheetKey;
|
|
116
118
|
private showFrame;
|
|
117
119
|
/** Repositions/rescales the displayed frame inside its anchored full-size box. */
|
|
118
120
|
private syncQuad;
|
|
@@ -4,6 +4,9 @@ import { ClipPlayer } from '../animation/clip-player.js';
|
|
|
4
4
|
import { locateFrame, sheetCell } from '../animation/sheet.js';
|
|
5
5
|
import { spritePlacement } from '../sprite-placement.js';
|
|
6
6
|
import { reportRejection } from '../report-rejection.js';
|
|
7
|
+
import { spriteBatchesOf, spriteInstanceOf } from '../sprite-batches.js';
|
|
8
|
+
/** Where a frame on a failed sheet draws: the flat (white) untextured quad. */
|
|
9
|
+
const UNTEXTURED = { texture: null, pixelArt: false, shape: 'rectangle' };
|
|
7
10
|
const clampAnchor = (value) => Math.min(1, Math.max(0, value));
|
|
8
11
|
/**
|
|
9
12
|
* Sprite animated from one or more spritesheets. The main sheet is the
|
|
@@ -139,6 +142,8 @@ export class AnimatedSprite extends Component {
|
|
|
139
142
|
texs = [];
|
|
140
143
|
// Clones whose sheet failed to load: applyFrame never installs them (CA-4).
|
|
141
144
|
failedSheets = new Set();
|
|
145
|
+
// The drawn quad, or under Sprite Batches (ADR 0024) the hidden anchor of
|
|
146
|
+
// this sprite's instance, whose frame UV is read from the sheet clone.
|
|
142
147
|
mesh;
|
|
143
148
|
frame = 0;
|
|
144
149
|
// Current frame's quad scale relative to the sheet's largest cell: always
|
|
@@ -169,8 +174,17 @@ export class AnimatedSprite extends Component {
|
|
|
169
174
|
tex.repeat.set(1 / Math.max(1, sheet.cols), 1 / Math.max(1, sheet.rows));
|
|
170
175
|
return tex;
|
|
171
176
|
});
|
|
172
|
-
const
|
|
173
|
-
|
|
177
|
+
const batches = spriteBatchesOf(this.game);
|
|
178
|
+
if (batches?.enabled) {
|
|
179
|
+
const instance = batches.attach(this.sheetKey(0));
|
|
180
|
+
instance.shownSheet = 0;
|
|
181
|
+
instance.uvSource = this.texs[0] ?? instance.uvSource;
|
|
182
|
+
this.mesh = instance.anchor;
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
const material = new THREE.MeshBasicMaterial({ map: this.texs[0], transparent: true });
|
|
186
|
+
this.mesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), material);
|
|
187
|
+
}
|
|
174
188
|
this.mesh.position.z = this.layer * 0.01;
|
|
175
189
|
this.syncQuad();
|
|
176
190
|
this.entity.node.add(this.mesh);
|
|
@@ -193,9 +207,16 @@ export class AnimatedSprite extends Component {
|
|
|
193
207
|
this.showFrame(this.player.advance(dt));
|
|
194
208
|
}
|
|
195
209
|
onDestroy() {
|
|
196
|
-
this.mesh
|
|
197
|
-
|
|
198
|
-
|
|
210
|
+
const instance = spriteInstanceOf(this.mesh);
|
|
211
|
+
if (instance) {
|
|
212
|
+
instance.release();
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
this.mesh?.removeFromParent();
|
|
216
|
+
this.mesh?.geometry.dispose();
|
|
217
|
+
this.mesh?.material.dispose();
|
|
218
|
+
}
|
|
219
|
+
this.mesh = undefined;
|
|
199
220
|
// Only this sprite's clones: the cached bases live with the Game.
|
|
200
221
|
for (const tex of this.texs)
|
|
201
222
|
tex.dispose();
|
|
@@ -225,6 +246,10 @@ export class AnimatedSprite extends Component {
|
|
|
225
246
|
}), 'sheet texture settle');
|
|
226
247
|
return texture;
|
|
227
248
|
}
|
|
249
|
+
/** The batch key of a sheet: its art, this sprite's filtering, a rectangle. */
|
|
250
|
+
sheetKey(index) {
|
|
251
|
+
return { texture: this.sheets[index]?.texture ?? '', pixelArt: this.pixelArt, shape: 'rectangle' };
|
|
252
|
+
}
|
|
228
253
|
showFrame(index) {
|
|
229
254
|
this.frame = index;
|
|
230
255
|
this.applyFrame();
|
|
@@ -254,7 +279,17 @@ export class AnimatedSprite extends Component {
|
|
|
254
279
|
const tex = this.texs[located.sheet];
|
|
255
280
|
if (!sheet || !tex)
|
|
256
281
|
return;
|
|
257
|
-
|
|
282
|
+
const instance = spriteInstanceOf(this.mesh);
|
|
283
|
+
if (instance) {
|
|
284
|
+
const failed = this.failedSheets.has(tex);
|
|
285
|
+
if (instance.shownSheet !== located.sheet || instance.shownSheetFailed !== failed) {
|
|
286
|
+
instance.moveTo(failed ? UNTEXTURED : this.sheetKey(located.sheet));
|
|
287
|
+
instance.shownSheet = located.sheet;
|
|
288
|
+
instance.shownSheetFailed = failed;
|
|
289
|
+
}
|
|
290
|
+
instance.uvSource = tex;
|
|
291
|
+
}
|
|
292
|
+
else if (this.mesh) {
|
|
258
293
|
const map = this.failedSheets.has(tex) ? null : tex;
|
|
259
294
|
if (this.mesh.material.map !== map) {
|
|
260
295
|
this.mesh.material.map = map;
|
|
@@ -68,6 +68,12 @@ export declare class Sprite extends Component implements YSortParticipant {
|
|
|
68
68
|
set shape(value: SpriteShape);
|
|
69
69
|
private mesh?;
|
|
70
70
|
onReady(): void;
|
|
71
|
+
private buildMesh;
|
|
72
|
+
/**
|
|
73
|
+
* One instance in the batch of this sprite's key, tinted like the material
|
|
74
|
+
* the per-sprite path would build: white over a texture, `color` without.
|
|
75
|
+
*/
|
|
76
|
+
private joinBatch;
|
|
71
77
|
onDestroy(): void;
|
|
72
78
|
/**
|
|
73
79
|
* CA-4's failure rule: an image that never arrives leaves the flat
|
|
@@ -2,6 +2,7 @@ import * as THREE from 'three';
|
|
|
2
2
|
import { Component } from '../component.js';
|
|
3
3
|
import { spritePlacement } from '../sprite-placement.js';
|
|
4
4
|
import { reportRejection } from '../report-rejection.js';
|
|
5
|
+
import { spriteBatchesOf, spriteInstanceOf } from '../sprite-batches.js';
|
|
5
6
|
const clampAnchor = (value) => Math.min(1, Math.max(0, value));
|
|
6
7
|
/**
|
|
7
8
|
* Textured or flat-color quad. In the unified pipeline, a 2D sprite is a
|
|
@@ -41,7 +42,11 @@ export class Sprite extends Component {
|
|
|
41
42
|
}
|
|
42
43
|
set color(value) {
|
|
43
44
|
this._color = value;
|
|
44
|
-
this.mesh
|
|
45
|
+
const instance = spriteInstanceOf(this.mesh);
|
|
46
|
+
if (instance)
|
|
47
|
+
instance.color.setHex(value);
|
|
48
|
+
else
|
|
49
|
+
this.mesh?.material.color.setHex(value);
|
|
45
50
|
}
|
|
46
51
|
_offsetX = 0;
|
|
47
52
|
_offsetY = 0;
|
|
@@ -102,11 +107,30 @@ export class Sprite extends Component {
|
|
|
102
107
|
this._shape = value === 'circle' ? 'circle' : 'rectangle';
|
|
103
108
|
if (!this.mesh)
|
|
104
109
|
return;
|
|
110
|
+
const instance = spriteInstanceOf(this.mesh);
|
|
111
|
+
if (instance) {
|
|
112
|
+
instance.moveTo({ ...instance.key, shape: this._shape });
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
105
115
|
this.mesh.geometry.dispose();
|
|
106
116
|
this.mesh.geometry = this.createGeometry();
|
|
107
117
|
}
|
|
118
|
+
// The drawn quad, or under Sprite Batches (ADR 0024) the hidden anchor of
|
|
119
|
+
// this sprite's instance: the same placement either way.
|
|
108
120
|
mesh;
|
|
109
121
|
onReady() {
|
|
122
|
+
const batches = spriteBatchesOf(this.game);
|
|
123
|
+
if (batches?.enabled)
|
|
124
|
+
this.joinBatch(batches);
|
|
125
|
+
else
|
|
126
|
+
this.buildMesh();
|
|
127
|
+
if (!this.mesh)
|
|
128
|
+
return;
|
|
129
|
+
this.mesh.position.z = this.layer * 0.01;
|
|
130
|
+
this.syncQuad();
|
|
131
|
+
this.entity.node.add(this.mesh);
|
|
132
|
+
}
|
|
133
|
+
buildMesh() {
|
|
110
134
|
const material = new THREE.MeshBasicMaterial({ color: this.color, transparent: true });
|
|
111
135
|
if (this.texture) {
|
|
112
136
|
// Its own clone of the cached base (game.assets, ADR 0019): filters
|
|
@@ -125,11 +149,35 @@ export class Sprite extends Component {
|
|
|
125
149
|
}), 'sprite texture settle');
|
|
126
150
|
}
|
|
127
151
|
this.mesh = new THREE.Mesh(this.createGeometry(), material);
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* One instance in the batch of this sprite's key, tinted like the material
|
|
155
|
+
* the per-sprite path would build: white over a texture, `color` without.
|
|
156
|
+
*/
|
|
157
|
+
joinBatch(batches) {
|
|
158
|
+
const key = { texture: this.texture || null, pixelArt: this.pixelArt, shape: this._shape };
|
|
159
|
+
const instance = batches.attach(key);
|
|
160
|
+
instance.color.setHex(this.texture ? 0xffffff : this.color);
|
|
161
|
+
this.mesh = instance.anchor;
|
|
162
|
+
const settled = batches.settled(key);
|
|
163
|
+
if (!settled)
|
|
164
|
+
return;
|
|
165
|
+
reportRejection(settled.then((outcome) => {
|
|
166
|
+
// CA-4's failure rule, copy-on-write: only this sprite leaves the
|
|
167
|
+
// shared entry, for the untextured one of its shape, with its color.
|
|
168
|
+
if (outcome !== 'failed' || spriteInstanceOf(this.mesh) !== instance)
|
|
169
|
+
return;
|
|
170
|
+
instance.moveTo({ ...instance.key, texture: null });
|
|
171
|
+
instance.color.setHex(this.color);
|
|
172
|
+
}), 'sprite texture settle');
|
|
131
173
|
}
|
|
132
174
|
onDestroy() {
|
|
175
|
+
const instance = spriteInstanceOf(this.mesh);
|
|
176
|
+
if (instance) {
|
|
177
|
+
instance.release();
|
|
178
|
+
this.mesh = undefined;
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
133
181
|
this.mesh?.removeFromParent();
|
|
134
182
|
this.mesh?.geometry.dispose();
|
|
135
183
|
// Only this sprite's clone: the cached base lives with the Game.
|
package/dist/game.d.ts
CHANGED
|
@@ -103,6 +103,8 @@ export declare class Game {
|
|
|
103
103
|
simulate: boolean;
|
|
104
104
|
private readonly renderer;
|
|
105
105
|
private readonly resizeObserver;
|
|
106
|
+
/** Scene-scoped Sprite Batches (ADR 0024); `render.batch: false` opts a scene out. */
|
|
107
|
+
private readonly spriteBatches;
|
|
106
108
|
private readonly updateFns;
|
|
107
109
|
private readonly invalidUpdateCompositions;
|
|
108
110
|
/**
|
package/dist/game.js
CHANGED
|
@@ -18,6 +18,7 @@ import { projectIsometric, unprojectIsometric } from './projection.js';
|
|
|
18
18
|
import { isYSortBatchParticipant, isYSortParticipant, ySortZ, } from './render-sort.js';
|
|
19
19
|
import { loadScene, registryEntry, spawnFromJson, } from './scene.js';
|
|
20
20
|
import { sceneDrainsOf } from './scene-drains.js';
|
|
21
|
+
import { createSpriteBatches } from './sprite-batches.js';
|
|
21
22
|
import { createSpatialQuery } from './spatial-query.js';
|
|
22
23
|
import { Stats } from './stats.js';
|
|
23
24
|
import { anchoredPiecesOf, GameUi } from './ui.js';
|
|
@@ -72,6 +73,8 @@ export class Game {
|
|
|
72
73
|
// TODO(H1): migrate to WebGPURenderer (three/webgpu) with automatic WebGL2 fallback.
|
|
73
74
|
renderer;
|
|
74
75
|
resizeObserver;
|
|
76
|
+
/** Scene-scoped Sprite Batches (ADR 0024); `render.batch: false` opts a scene out. */
|
|
77
|
+
spriteBatches;
|
|
75
78
|
updateFns = new Set();
|
|
76
79
|
invalidUpdateCompositions = new WeakMap();
|
|
77
80
|
/**
|
|
@@ -133,6 +136,7 @@ export class Game {
|
|
|
133
136
|
// catalog is registered at call time, which outlives every scene.
|
|
134
137
|
resolveAsset: (uri) => this.sceneCatalog?.registry.resolveAsset?.(uri) ?? uri,
|
|
135
138
|
});
|
|
139
|
+
this.spriteBatches = createSpriteBatches(this, this.assets);
|
|
136
140
|
this.cameraEffects = new CameraEffects({
|
|
137
141
|
host: () => canvas.parentElement ?? document.body,
|
|
138
142
|
// One screen pixel in world units: the shake snaps to it (never the base).
|
|
@@ -205,6 +209,8 @@ export class Game {
|
|
|
205
209
|
entity.destroy();
|
|
206
210
|
// Drains transferred by the destruction cascade above are scene-scoped too.
|
|
207
211
|
sceneDrainsOf(this).clear();
|
|
212
|
+
// After the cascade freed every slot: the batches go with their scene.
|
|
213
|
+
this.spriteBatches.unload();
|
|
208
214
|
this.registry = null;
|
|
209
215
|
this.renderSort = null;
|
|
210
216
|
this.sceneProjection = null;
|
|
@@ -299,6 +305,7 @@ export class Game {
|
|
|
299
305
|
*/
|
|
300
306
|
setSceneRender(json) {
|
|
301
307
|
this.renderSort = json?.sort === 'y' ? 'y' : null;
|
|
308
|
+
this.spriteBatches.enabled = json?.batch !== false;
|
|
302
309
|
const projection = json?.projection === 'isometric' ? 'isometric' : null;
|
|
303
310
|
if (projection === this.sceneProjection)
|
|
304
311
|
return;
|
|
@@ -399,6 +406,7 @@ export class Game {
|
|
|
399
406
|
for (const entity of [...this.entities])
|
|
400
407
|
entity.destroy();
|
|
401
408
|
sceneDrainsOf(this).clear();
|
|
409
|
+
this.spriteBatches.unload();
|
|
402
410
|
// After the entities: their clones go with the cascade above, the
|
|
403
411
|
// cached bases go here, exactly once (ADR 0019).
|
|
404
412
|
this.assets.dispose();
|
|
@@ -602,7 +610,7 @@ export class Game {
|
|
|
602
610
|
this.renderer.clear(true, false, false);
|
|
603
611
|
this.renderer.setScissorTest(true);
|
|
604
612
|
}
|
|
605
|
-
this.renderer.render(this.scene, this.camera);
|
|
613
|
+
this.spriteBatches.drawFrame(this.scene, this.camera, () => this.renderer.render(this.scene, this.camera));
|
|
606
614
|
}
|
|
607
615
|
finally {
|
|
608
616
|
this.camera.position.x = x;
|
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;
|
|
@@ -15,7 +15,13 @@ export interface SpatialBroadphaseStats {
|
|
|
15
15
|
export interface SpatialBroadphase<T> {
|
|
16
16
|
readonly stats: SpatialBroadphaseStats;
|
|
17
17
|
candidates(bounds: CollisionBounds): T[];
|
|
18
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Visits every distinct pair sharing a cell, or involving an overflow
|
|
20
|
+
* entry, once, in ascending query order. The grid is frozen at creation,
|
|
21
|
+
* so callbacks that move, spawn or destroy entities do not change which
|
|
22
|
+
* pairs are visited.
|
|
23
|
+
*/
|
|
24
|
+
forEachPair(visit: (first: T, second: T) => void): void;
|
|
19
25
|
}
|
|
20
26
|
/**
|
|
21
27
|
* Package-internal fresh uniform grid. Bodies above the fixed occupancy cap
|
|
@@ -4,6 +4,7 @@ import { Hitbox } from './components/hitbox.js';
|
|
|
4
4
|
import { Solid } from './components/solid.js';
|
|
5
5
|
import { Tilemap } from './components/tilemap.js';
|
|
6
6
|
import { sceneSolids } from './scene-solids.js';
|
|
7
|
+
import { forEachCandidatePair } from './spatial-pairs.js';
|
|
7
8
|
import { usableCollisionBody } from './spatial-query-geometry.js';
|
|
8
9
|
const CELL_OCCUPANCY_CAP = 256;
|
|
9
10
|
function rangeSize(range) {
|
|
@@ -54,13 +55,6 @@ function entryValue(entries, index) {
|
|
|
54
55
|
throw new Error(`Spatial broadphase has no entry at index ${index}`);
|
|
55
56
|
return entry.value;
|
|
56
57
|
}
|
|
57
|
-
/** The entry index stored at `position` of a bucket that is iterated within its bounds. */
|
|
58
|
-
function indexAt(bucket, position) {
|
|
59
|
-
const index = bucket[position];
|
|
60
|
-
if (index === undefined)
|
|
61
|
-
throw new Error(`Spatial broadphase bucket has no index at position ${position}`);
|
|
62
|
-
return index;
|
|
63
|
-
}
|
|
64
58
|
/** The indexable sources in query order, each with the cell range it covers. */
|
|
65
59
|
function spatialEntries(sources, cellSize, isIndexable) {
|
|
66
60
|
return sources
|
|
@@ -79,47 +73,25 @@ function spatialEntries(sources, cellSize, isIndexable) {
|
|
|
79
73
|
function spatialGrid(entries) {
|
|
80
74
|
const buckets = new Map();
|
|
81
75
|
const overflow = [];
|
|
76
|
+
const entryBuckets = [];
|
|
82
77
|
for (const [index, entry] of entries.entries()) {
|
|
78
|
+
const own = [];
|
|
79
|
+
entryBuckets.push(own);
|
|
83
80
|
if (!entry.range) {
|
|
84
81
|
overflow.push(index);
|
|
85
82
|
continue;
|
|
86
83
|
}
|
|
87
84
|
eachCell(entry.range, (key) => {
|
|
88
|
-
|
|
89
|
-
if (bucket)
|
|
90
|
-
bucket
|
|
91
|
-
|
|
92
|
-
buckets.set(key, [index]);
|
|
93
|
-
});
|
|
94
|
-
}
|
|
95
|
-
return { buckets, overflow };
|
|
96
|
-
}
|
|
97
|
-
/** Distinct index pairs sharing a cell, or involving an overflow entry, in ascending order. */
|
|
98
|
-
function candidatePairIndices(grid, entryCount) {
|
|
99
|
-
const pairKeys = new Set();
|
|
100
|
-
const width = entryCount;
|
|
101
|
-
const addPair = (left, right) => {
|
|
102
|
-
if (left === right)
|
|
103
|
-
return;
|
|
104
|
-
const first = Math.min(left, right);
|
|
105
|
-
const second = Math.max(left, right);
|
|
106
|
-
pairKeys.add(first * width + second);
|
|
107
|
-
};
|
|
108
|
-
for (const bucket of grid.buckets.values()) {
|
|
109
|
-
for (let left = 0; left < bucket.length; left += 1) {
|
|
110
|
-
for (let right = left + 1; right < bucket.length; right += 1) {
|
|
111
|
-
addPair(indexAt(bucket, left), indexAt(bucket, right));
|
|
85
|
+
let bucket = buckets.get(key);
|
|
86
|
+
if (!bucket) {
|
|
87
|
+
bucket = [];
|
|
88
|
+
buckets.set(key, bucket);
|
|
112
89
|
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
for (let index = 0; index < entryCount; index += 1) {
|
|
117
|
-
addPair(overflowIndex, index);
|
|
118
|
-
}
|
|
90
|
+
bucket.push(index);
|
|
91
|
+
own.push(bucket);
|
|
92
|
+
});
|
|
119
93
|
}
|
|
120
|
-
return
|
|
121
|
-
.map((key) => [Math.floor(key / width), key % width])
|
|
122
|
-
.sort(([a1, a2], [b1, b2]) => a1 - b1 || a2 - b2);
|
|
94
|
+
return { buckets, overflow, entryBuckets };
|
|
123
95
|
}
|
|
124
96
|
/**
|
|
125
97
|
* Package-internal fresh uniform grid. Bodies above the fixed occupancy cap
|
|
@@ -144,11 +116,10 @@ export function createSpatialBroadphase(sources, cellSize, isIndexable = usableC
|
|
|
144
116
|
.sort((a, b) => a - b)
|
|
145
117
|
.map((index) => entryValue(entries, index));
|
|
146
118
|
},
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
entryValue(entries, first),
|
|
150
|
-
|
|
151
|
-
]);
|
|
119
|
+
forEachPair(visit) {
|
|
120
|
+
forEachCandidatePair(grid, (first, second) => {
|
|
121
|
+
visit(entryValue(entries, first), entryValue(entries, second));
|
|
122
|
+
});
|
|
152
123
|
},
|
|
153
124
|
};
|
|
154
125
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** The grid shape the pair walk needs: which buckets each entry is in, and the overflow. */
|
|
2
|
+
export interface PairGrid {
|
|
3
|
+
/** Entries too large to bucket, ascending; they pair with every other entry. */
|
|
4
|
+
readonly overflow: readonly number[];
|
|
5
|
+
/** Per entry, the buckets it was added to, each ascending by entry index (empty for overflow entries). */
|
|
6
|
+
readonly entryBuckets: readonly (readonly (readonly number[])[])[];
|
|
7
|
+
}
|
|
8
|
+
type PairVisitor = (first: number, second: number) => void;
|
|
9
|
+
/**
|
|
10
|
+
* Visits every distinct index pair sharing a cell, or involving an overflow
|
|
11
|
+
* entry, in ascending (first, second) order. Buckets fill in index order, so
|
|
12
|
+
* each one is already ascending: a body in one cell, with no overflow after
|
|
13
|
+
* it, needs no deduplication or sort. Equivalent to collecting all pairs,
|
|
14
|
+
* deduplicating and sorting them, without allocating per pair.
|
|
15
|
+
*/
|
|
16
|
+
export declare function forEachCandidatePair(grid: PairGrid, visit: PairVisitor): void;
|
|
17
|
+
export {};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/** The entry index stored at `position` of a bucket that is iterated within its bounds. */
|
|
2
|
+
function indexAt(bucket, position) {
|
|
3
|
+
const index = bucket[position];
|
|
4
|
+
if (index === undefined)
|
|
5
|
+
throw new Error(`Spatial broadphase bucket has no index at position ${position}`);
|
|
6
|
+
return index;
|
|
7
|
+
}
|
|
8
|
+
/** First position in an ascending bucket whose index is greater than `index`. */
|
|
9
|
+
function firstAfter(bucket, index) {
|
|
10
|
+
let low = 0;
|
|
11
|
+
let high = bucket.length;
|
|
12
|
+
while (low < high) {
|
|
13
|
+
const middle = (low + high) >>> 1;
|
|
14
|
+
if (indexAt(bucket, middle) <= index)
|
|
15
|
+
low = middle + 1;
|
|
16
|
+
else
|
|
17
|
+
high = middle;
|
|
18
|
+
}
|
|
19
|
+
return low;
|
|
20
|
+
}
|
|
21
|
+
/** Calls `each` with every index of an ascending bucket greater than `first`. */
|
|
22
|
+
function eachLater(bucket, first, each) {
|
|
23
|
+
for (let position = firstAfter(bucket, first); position < bucket.length; position += 1) {
|
|
24
|
+
each(indexAt(bucket, position));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/** The later indices of `buckets`, deduplicated and ascending, in the walk's scratch list. */
|
|
28
|
+
function laterNeighbours(walk, buckets, first) {
|
|
29
|
+
const { marked, neighbours } = walk;
|
|
30
|
+
neighbours.length = 0;
|
|
31
|
+
for (const bucket of buckets) {
|
|
32
|
+
eachLater(bucket, first, (second) => {
|
|
33
|
+
if (marked[second] === first)
|
|
34
|
+
return;
|
|
35
|
+
marked[second] = first;
|
|
36
|
+
neighbours.push(second);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
return neighbours.sort((a, b) => a - b);
|
|
40
|
+
}
|
|
41
|
+
/** The pairs of bucketed entry `first` with every later entry sharing a cell or overflowing. */
|
|
42
|
+
function visitBucketedPairs(walk, first) {
|
|
43
|
+
const { grid, visit } = walk;
|
|
44
|
+
const own = grid.entryBuckets[first];
|
|
45
|
+
if (!own)
|
|
46
|
+
throw new Error(`Spatial broadphase has no buckets for entry ${first}`);
|
|
47
|
+
// Overflow is ascending, like a bucket: laterNeighbours takes only its later part.
|
|
48
|
+
const hasLaterOverflow = (grid.overflow.at(-1) ?? -1) > first;
|
|
49
|
+
const [only] = own;
|
|
50
|
+
if (own.length === 1 && only && !hasLaterOverflow) {
|
|
51
|
+
eachLater(only, first, (second) => visit(first, second));
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const buckets = hasLaterOverflow ? [...own, grid.overflow] : own;
|
|
55
|
+
for (const second of laterNeighbours(walk, buckets, first))
|
|
56
|
+
visit(first, second);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Visits every distinct index pair sharing a cell, or involving an overflow
|
|
60
|
+
* entry, in ascending (first, second) order. Buckets fill in index order, so
|
|
61
|
+
* each one is already ascending: a body in one cell, with no overflow after
|
|
62
|
+
* it, needs no deduplication or sort. Equivalent to collecting all pairs,
|
|
63
|
+
* deduplicating and sorting them, without allocating per pair.
|
|
64
|
+
*/
|
|
65
|
+
export function forEachCandidatePair(grid, visit) {
|
|
66
|
+
const entryCount = grid.entryBuckets.length;
|
|
67
|
+
const isOverflow = new Uint8Array(entryCount);
|
|
68
|
+
for (const index of grid.overflow)
|
|
69
|
+
isOverflow[index] = 1;
|
|
70
|
+
const walk = { grid, visit, marked: new Int32Array(entryCount).fill(-1), neighbours: [] };
|
|
71
|
+
for (let first = 0; first < entryCount; first += 1) {
|
|
72
|
+
if (!isOverflow[first]) {
|
|
73
|
+
visitBucketedPairs(walk, first);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
for (let second = first + 1; second < entryCount; second += 1)
|
|
77
|
+
visit(first, second);
|
|
78
|
+
}
|
|
79
|
+
}
|