@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.
- package/README.md +33 -0
- package/dist/assets/asset-loader.d.ts +1 -1
- package/dist/assets/asset-loader.js +1 -1
- package/dist/assets/texture-backend.d.ts +1 -1
- package/dist/assets/texture-backend.js +1 -1
- package/dist/camera.js +1 -1
- 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 +42 -7
- package/dist/components/sprite.d.ts +6 -0
- package/dist/components/sprite.js +53 -5
- package/dist/components/tilemap.js +3 -1
- package/dist/entity.d.ts +1 -1
- package/dist/entity.js +1 -1
- package/dist/game.d.ts +22 -3
- package/dist/game.js +68 -45
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -1
- package/dist/particle-batch.d.ts +1 -1
- package/dist/particle-batch.js +4 -1
- package/dist/particle-sort-bounds.d.ts +1 -1
- package/dist/particle-sort-bounds.js +1 -1
- package/dist/pointer.js +1 -1
- package/dist/render-output.d.ts +27 -0
- package/dist/render-output.js +44 -0
- package/dist/render-readiness.d.ts +37 -0
- package/dist/render-readiness.js +61 -0
- package/dist/render-sort.d.ts +14 -0
- package/dist/render-sort.js +37 -0
- package/dist/runtime-bridge.d.ts +18 -2
- package/dist/runtime-bridge.js +7 -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 +223 -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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import * as THREE from 'three/webgpu';
|
|
2
|
+
import type { TextureOutcome } from './assets/asset-loader.js';
|
|
3
|
+
export type SpriteBatchShape = 'rectangle' | 'circle';
|
|
4
|
+
/**
|
|
5
|
+
* What decides whether two sprites can share one draw: the art, its
|
|
6
|
+
* filtering and the quad's shape. `texture` null is the untextured entry of
|
|
7
|
+
* a shape; '' is an AnimatedSprite sheet with no url yet (a bare texture).
|
|
8
|
+
*/
|
|
9
|
+
export interface SpriteBatchKey {
|
|
10
|
+
texture: string | null;
|
|
11
|
+
pixelArt: boolean;
|
|
12
|
+
shape: SpriteBatchShape;
|
|
13
|
+
}
|
|
14
|
+
/** The first capacity of a batch's instance buffers; it doubles when full. */
|
|
15
|
+
export declare const INITIAL_SPRITE_BATCH_CAPACITY = 16;
|
|
16
|
+
/** Where a batch gets its key's texture: `game.assets` (ADR 0019). */
|
|
17
|
+
export interface SpriteBatchTextures {
|
|
18
|
+
texture(url: string): {
|
|
19
|
+
texture: THREE.Texture;
|
|
20
|
+
settled: Promise<TextureOutcome>;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/** The key's canonical string: equal keys share one batch. */
|
|
24
|
+
export declare function spriteBatchKeyId(key: SpriteBatchKey): string;
|
|
25
|
+
/** The unit quad every sprite of a shape draws, exactly as the per-sprite path builds it. */
|
|
26
|
+
export declare function spriteGeometry(shape: SpriteBatchShape): THREE.BufferGeometry;
|
|
27
|
+
/** What a run writes per sprite. */
|
|
28
|
+
export interface SpriteBatchInstance {
|
|
29
|
+
/** View × world of the sprite's quad. */
|
|
30
|
+
readonly modelView: THREE.Matrix4;
|
|
31
|
+
/** Linear RGB tint. */
|
|
32
|
+
readonly color: THREE.Color;
|
|
33
|
+
/** The map's repeat and offset (identity for an untransformed map). */
|
|
34
|
+
readonly uvRepeat: THREE.Vector2;
|
|
35
|
+
readonly uvOffset: THREE.Vector2;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* One pooled draw of a run: an instanced quad and its three typed instance
|
|
39
|
+
* attributes (view × world, tint, map UV transform), sized by the longest
|
|
40
|
+
* run it has drawn — 16 instances at first, doubling when a longer run
|
|
41
|
+
* needs it, never shrinking during the scene.
|
|
42
|
+
*/
|
|
43
|
+
export declare class SpriteRunMesh {
|
|
44
|
+
readonly mesh: THREE.InstancedMesh;
|
|
45
|
+
private readonly colors;
|
|
46
|
+
private readonly uvs;
|
|
47
|
+
constructor(base: THREE.BufferGeometry, material: THREE.MeshBasicNodeMaterial, capacity: number);
|
|
48
|
+
get capacity(): number;
|
|
49
|
+
/** Writes the sprite drawn `index`-th in this run. */
|
|
50
|
+
write(index: number, instance: SpriteBatchInstance): void;
|
|
51
|
+
/** Draws the first `count` written instances and uploads only those. */
|
|
52
|
+
commit(count: number): void;
|
|
53
|
+
dispose(): void;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* A Sprite Batch's resources for one key (CONTEXT.md, ADR 0024): the shared
|
|
57
|
+
* material, a slot per member, and the pooled run meshes its runs draw
|
|
58
|
+
* through — one per run of the key in a frame, each sized by its own run, so
|
|
59
|
+
* instance memory stays proportional to the sprites drawn. Released at
|
|
60
|
+
* unload (ADR 0011).
|
|
61
|
+
*/
|
|
62
|
+
export declare class SpriteBatch {
|
|
63
|
+
private readonly geometry;
|
|
64
|
+
readonly material: THREE.MeshBasicNodeMaterial;
|
|
65
|
+
/** When the key's texture settled; null for untextured and bare entries. */
|
|
66
|
+
readonly settled: Promise<TextureOutcome> | null;
|
|
67
|
+
private readonly slots;
|
|
68
|
+
private readonly freeSlots;
|
|
69
|
+
private _capacity;
|
|
70
|
+
private readonly runs;
|
|
71
|
+
private runsDrawn;
|
|
72
|
+
constructor(key: SpriteBatchKey, textures: SpriteBatchTextures, geometry: THREE.BufferGeometry);
|
|
73
|
+
/** The most members the key has held at once, rounded up to 16 × 2^n: no run is ever longer. */
|
|
74
|
+
get capacity(): number;
|
|
75
|
+
/** Live members. */
|
|
76
|
+
get size(): number;
|
|
77
|
+
/** Takes a freed slot first, growing the capacity geometrically only when none is left. */
|
|
78
|
+
join(): number;
|
|
79
|
+
/** Frees a member's slot for the next join, the same step. */
|
|
80
|
+
leave(slot: number): void;
|
|
81
|
+
/** Starts a frame: no run of the key drawn yet. */
|
|
82
|
+
beginFrame(): void;
|
|
83
|
+
/**
|
|
84
|
+
* The pooled mesh for the key's next run this frame, holding at least
|
|
85
|
+
* `length` instances. A pooled mesh is rebuilt only when this run is longer
|
|
86
|
+
* than any it drew before, doubling from its size (16 at first).
|
|
87
|
+
*/
|
|
88
|
+
nextRun(length: number): SpriteRunMesh;
|
|
89
|
+
/** Takes the pooled meshes no run used this frame out of the scene. */
|
|
90
|
+
detachUndrawn(): void;
|
|
91
|
+
/** Releases every GPU-side object this batch created; its texture clone included. */
|
|
92
|
+
dispose(): void;
|
|
93
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { attribute, context, mat3, positionLocal, uv, vec3, vertexStage } from 'three/tsl';
|
|
2
|
+
import * as THREE from 'three/webgpu';
|
|
3
|
+
/** The first capacity of a batch's instance buffers; it doubles when full. */
|
|
4
|
+
export const INITIAL_SPRITE_BATCH_CAPACITY = 16;
|
|
5
|
+
/** The key's canonical string: equal keys share one batch. */
|
|
6
|
+
export function spriteBatchKeyId(key) {
|
|
7
|
+
const texture = key.texture === null ? 'none' : `url:${key.texture}`;
|
|
8
|
+
return `${texture}|${key.texture === null ? false : key.pixelArt}|${key.shape}`;
|
|
9
|
+
}
|
|
10
|
+
/** The unit quad every sprite of a shape draws, exactly as the per-sprite path builds it. */
|
|
11
|
+
export function spriteGeometry(shape) {
|
|
12
|
+
return shape === 'circle' ? new THREE.CircleGeometry(0.5, 32) : new THREE.PlaneGeometry(1, 1);
|
|
13
|
+
}
|
|
14
|
+
/** The per-instance UV transform a run carries: repeat (x, y) and offset (z, w). */
|
|
15
|
+
const INSTANCE_UV = 'instanceUv';
|
|
16
|
+
/** Whether three is building the material for one of the batch's instanced run meshes. */
|
|
17
|
+
function drawsInstances(builder) {
|
|
18
|
+
return builder.object.isInstancedMesh === true;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The map UV of one instance: its repeat and offset as the same mat3 a
|
|
22
|
+
* texture's own matrix is, applied to the quad's UV per vertex — as every
|
|
23
|
+
* other map is (render-output.ts `mapUvPerVertex`) — so the corner UVs are
|
|
24
|
+
* the very floats the per-sprite draw interpolates.
|
|
25
|
+
*/
|
|
26
|
+
function instanceMapUv() {
|
|
27
|
+
const transform = attribute(INSTANCE_UV, 'vec4');
|
|
28
|
+
return vertexStage(mat3(transform.x, 0, 0, 0, transform.y, 0, transform.z, transform.w, 1).mul(vec3(uv(), 1)).xy);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Every batch's material, as a node material (ADR 0025). The instance
|
|
32
|
+
* matrix already holds view × world (three's own `modelViewMatrix` for that
|
|
33
|
+
* sprite, computed the same way on the CPU), so an instanced draw skips
|
|
34
|
+
* `modelViewMatrix`; the map's UV transform comes from the instance instead
|
|
35
|
+
* of the texture. Both apply to instanced draws only: the hidden anchors
|
|
36
|
+
* share this material, and if one were ever drawn as a plain mesh it gets
|
|
37
|
+
* three's own transforms.
|
|
38
|
+
*/
|
|
39
|
+
class SpriteBatchMaterial extends THREE.MeshBasicNodeMaterial {
|
|
40
|
+
constructor(parameters) {
|
|
41
|
+
super(parameters);
|
|
42
|
+
this.contextNode = context({
|
|
43
|
+
getUV: (texture, builder) => {
|
|
44
|
+
if (!drawsInstances(builder))
|
|
45
|
+
return null;
|
|
46
|
+
// The instance transform replaces the map's own matrix.
|
|
47
|
+
texture.setUpdateMatrix(false);
|
|
48
|
+
return instanceMapUv();
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
setupPositionView(builder) {
|
|
53
|
+
// three's instancing already multiplied positionLocal by the instance matrix.
|
|
54
|
+
return drawsInstances(builder) ? positionLocal : super.setupPositionView(builder);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function createMaterial(key, textures) {
|
|
58
|
+
// Double-sided: a flipped sprite mirrors through a negative x scale in its
|
|
59
|
+
// matrix, exactly like its own mesh; per instance there is no winding flip.
|
|
60
|
+
const material = new SpriteBatchMaterial({ transparent: true, side: THREE.DoubleSide });
|
|
61
|
+
material.forceSinglePass = true;
|
|
62
|
+
if (key.texture === null)
|
|
63
|
+
return { material, settled: null };
|
|
64
|
+
const { texture, settled } = key.texture
|
|
65
|
+
? textures.texture(key.texture)
|
|
66
|
+
: { texture: new THREE.Texture(), settled: null };
|
|
67
|
+
// One clone per key holds the filters, so pixelArt never leaks across keys.
|
|
68
|
+
if (key.pixelArt) {
|
|
69
|
+
texture.magFilter = THREE.NearestFilter;
|
|
70
|
+
texture.minFilter = THREE.NearestFilter;
|
|
71
|
+
}
|
|
72
|
+
material.map = texture;
|
|
73
|
+
return { material, settled };
|
|
74
|
+
}
|
|
75
|
+
/** Marks the first `count` items of an instance attribute for upload. */
|
|
76
|
+
function upload(attribute, count) {
|
|
77
|
+
attribute.clearUpdateRanges();
|
|
78
|
+
attribute.addUpdateRange(0, count * attribute.itemSize);
|
|
79
|
+
attribute.needsUpdate = true;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* One pooled draw of a run: an instanced quad and its three typed instance
|
|
83
|
+
* attributes (view × world, tint, map UV transform), sized by the longest
|
|
84
|
+
* run it has drawn — 16 instances at first, doubling when a longer run
|
|
85
|
+
* needs it, never shrinking during the scene.
|
|
86
|
+
*/
|
|
87
|
+
export class SpriteRunMesh {
|
|
88
|
+
mesh;
|
|
89
|
+
colors;
|
|
90
|
+
uvs;
|
|
91
|
+
constructor(base, material, capacity) {
|
|
92
|
+
// Its own copy of the quad's attributes, so disposing one run never frees
|
|
93
|
+
// buffers another geometry still binds.
|
|
94
|
+
const geometry = base.clone();
|
|
95
|
+
this.uvs = new THREE.InstancedBufferAttribute(new Float32Array(capacity * 4), 4).setUsage(THREE.DynamicDrawUsage);
|
|
96
|
+
geometry.setAttribute(INSTANCE_UV, this.uvs);
|
|
97
|
+
this.mesh = new THREE.InstancedMesh(geometry, material, capacity);
|
|
98
|
+
this.mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
|
|
99
|
+
this.colors = new THREE.InstancedBufferAttribute(new Float32Array(capacity * 3), 3).setUsage(THREE.DynamicDrawUsage);
|
|
100
|
+
this.mesh.instanceColor = this.colors;
|
|
101
|
+
// Drawn in an explicit renderOrder; its instances are already in view space.
|
|
102
|
+
this.mesh.frustumCulled = false;
|
|
103
|
+
this.mesh.matrixAutoUpdate = false;
|
|
104
|
+
this.mesh.boundingSphere = new THREE.Sphere();
|
|
105
|
+
}
|
|
106
|
+
get capacity() {
|
|
107
|
+
return this.mesh.instanceMatrix.count;
|
|
108
|
+
}
|
|
109
|
+
/** Writes the sprite drawn `index`-th in this run. */
|
|
110
|
+
write(index, instance) {
|
|
111
|
+
instance.modelView.toArray(this.mesh.instanceMatrix.array, index * 16);
|
|
112
|
+
instance.color.toArray(this.colors.array, index * 3);
|
|
113
|
+
const uv = this.uvs.array;
|
|
114
|
+
uv[index * 4] = instance.uvRepeat.x;
|
|
115
|
+
uv[index * 4 + 1] = instance.uvRepeat.y;
|
|
116
|
+
uv[index * 4 + 2] = instance.uvOffset.x;
|
|
117
|
+
uv[index * 4 + 3] = instance.uvOffset.y;
|
|
118
|
+
}
|
|
119
|
+
/** Draws the first `count` written instances and uploads only those. */
|
|
120
|
+
commit(count) {
|
|
121
|
+
this.mesh.count = count;
|
|
122
|
+
for (const attribute of [this.mesh.instanceMatrix, this.colors, this.uvs])
|
|
123
|
+
upload(attribute, count);
|
|
124
|
+
}
|
|
125
|
+
dispose() {
|
|
126
|
+
this.mesh.removeFromParent();
|
|
127
|
+
this.mesh.geometry.dispose();
|
|
128
|
+
this.mesh.dispose();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* A Sprite Batch's resources for one key (CONTEXT.md, ADR 0024): the shared
|
|
133
|
+
* material, a slot per member, and the pooled run meshes its runs draw
|
|
134
|
+
* through — one per run of the key in a frame, each sized by its own run, so
|
|
135
|
+
* instance memory stays proportional to the sprites drawn. Released at
|
|
136
|
+
* unload (ADR 0011).
|
|
137
|
+
*/
|
|
138
|
+
export class SpriteBatch {
|
|
139
|
+
geometry;
|
|
140
|
+
material;
|
|
141
|
+
/** When the key's texture settled; null for untextured and bare entries. */
|
|
142
|
+
settled;
|
|
143
|
+
// A slot reserves room in the key's capacity; it is not a position in any
|
|
144
|
+
// instance buffer, which a frame rewrites in draw order (CA-4 deviation).
|
|
145
|
+
slots = [];
|
|
146
|
+
freeSlots = [];
|
|
147
|
+
_capacity = INITIAL_SPRITE_BATCH_CAPACITY;
|
|
148
|
+
runs = [];
|
|
149
|
+
runsDrawn = 0;
|
|
150
|
+
constructor(key, textures, geometry) {
|
|
151
|
+
this.geometry = geometry;
|
|
152
|
+
const { material, settled } = createMaterial(key, textures);
|
|
153
|
+
this.material = material;
|
|
154
|
+
this.settled = settled;
|
|
155
|
+
}
|
|
156
|
+
/** The most members the key has held at once, rounded up to 16 × 2^n: no run is ever longer. */
|
|
157
|
+
get capacity() {
|
|
158
|
+
return this._capacity;
|
|
159
|
+
}
|
|
160
|
+
/** Live members. */
|
|
161
|
+
get size() {
|
|
162
|
+
return this.slots.length - this.freeSlots.length;
|
|
163
|
+
}
|
|
164
|
+
/** Takes a freed slot first, growing the capacity geometrically only when none is left. */
|
|
165
|
+
join() {
|
|
166
|
+
const reused = this.freeSlots.pop();
|
|
167
|
+
if (reused !== undefined) {
|
|
168
|
+
this.slots[reused] = true;
|
|
169
|
+
return reused;
|
|
170
|
+
}
|
|
171
|
+
this.slots.push(true);
|
|
172
|
+
while (this.slots.length > this._capacity)
|
|
173
|
+
this._capacity *= 2;
|
|
174
|
+
return this.slots.length - 1;
|
|
175
|
+
}
|
|
176
|
+
/** Frees a member's slot for the next join, the same step. */
|
|
177
|
+
leave(slot) {
|
|
178
|
+
if (this.slots[slot] !== true)
|
|
179
|
+
return;
|
|
180
|
+
this.slots[slot] = false;
|
|
181
|
+
this.freeSlots.push(slot);
|
|
182
|
+
}
|
|
183
|
+
/** Starts a frame: no run of the key drawn yet. */
|
|
184
|
+
beginFrame() {
|
|
185
|
+
this.runsDrawn = 0;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* The pooled mesh for the key's next run this frame, holding at least
|
|
189
|
+
* `length` instances. A pooled mesh is rebuilt only when this run is longer
|
|
190
|
+
* than any it drew before, doubling from its size (16 at first).
|
|
191
|
+
*/
|
|
192
|
+
nextRun(length) {
|
|
193
|
+
const index = this.runsDrawn;
|
|
194
|
+
this.runsDrawn += 1;
|
|
195
|
+
const pooled = this.runs[index];
|
|
196
|
+
if (pooled && pooled.capacity >= length)
|
|
197
|
+
return pooled;
|
|
198
|
+
let capacity = pooled?.capacity ?? INITIAL_SPRITE_BATCH_CAPACITY;
|
|
199
|
+
while (capacity < length)
|
|
200
|
+
capacity *= 2;
|
|
201
|
+
// Never past the key's reservation: a run cannot outnumber the members.
|
|
202
|
+
const run = new SpriteRunMesh(this.geometry, this.material, Math.min(capacity, Math.max(this._capacity, length)));
|
|
203
|
+
if (pooled) {
|
|
204
|
+
pooled.mesh.parent?.add(run.mesh);
|
|
205
|
+
pooled.dispose();
|
|
206
|
+
}
|
|
207
|
+
this.runs[index] = run;
|
|
208
|
+
return run;
|
|
209
|
+
}
|
|
210
|
+
/** Takes the pooled meshes no run used this frame out of the scene. */
|
|
211
|
+
detachUndrawn() {
|
|
212
|
+
for (let index = this.runsDrawn; index < this.runs.length; index += 1)
|
|
213
|
+
this.runs[index]?.mesh.removeFromParent();
|
|
214
|
+
}
|
|
215
|
+
/** Releases every GPU-side object this batch created; its texture clone included. */
|
|
216
|
+
dispose() {
|
|
217
|
+
for (const run of this.runs)
|
|
218
|
+
run.dispose();
|
|
219
|
+
this.runs.length = 0;
|
|
220
|
+
this.material.map?.dispose();
|
|
221
|
+
this.material.dispose();
|
|
222
|
+
}
|
|
223
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import * as THREE from 'three/webgpu';
|
|
2
|
+
import type { TextureOutcome } from './assets/asset-loader.js';
|
|
3
|
+
import type { Game } from './game.js';
|
|
4
|
+
import { SpriteBatch, type SpriteBatchInstance, type SpriteBatchKey, type SpriteBatchTextures } from './sprite-batch.js';
|
|
5
|
+
/** Where an instance reads its map's UV transform: an AnimatedSprite's sheet clone. */
|
|
6
|
+
export interface SpriteUvSource {
|
|
7
|
+
readonly offset: THREE.Vector2;
|
|
8
|
+
readonly repeat: THREE.Vector2;
|
|
9
|
+
}
|
|
10
|
+
export type SpriteAnchor = THREE.Mesh<THREE.BufferGeometry, THREE.MeshBasicNodeMaterial>;
|
|
11
|
+
/** Where an instance sits: its key, that key's batch and its slot there. */
|
|
12
|
+
export interface SpriteBatchSeat {
|
|
13
|
+
key: SpriteBatchKey;
|
|
14
|
+
batch: SpriteBatch;
|
|
15
|
+
slot: number;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* One sprite inside a Sprite Batch. Its `anchor` is a hidden mesh placed
|
|
19
|
+
* exactly where the sprite's own mesh would be — same parent, transform,
|
|
20
|
+
* shape geometry and creation order — so draw order, y-sort and the world
|
|
21
|
+
* matrix come out as before; the anchor itself is never drawn.
|
|
22
|
+
*/
|
|
23
|
+
export declare class SpriteInstance implements SpriteBatchInstance {
|
|
24
|
+
readonly anchor: SpriteAnchor;
|
|
25
|
+
private readonly owner;
|
|
26
|
+
seat: SpriteBatchSeat;
|
|
27
|
+
/** Linear RGB tint, what the per-sprite path writes into its material color. */
|
|
28
|
+
readonly color: THREE.Color;
|
|
29
|
+
/** Identity until an AnimatedSprite points it at its current sheet's clone. */
|
|
30
|
+
uvSource: SpriteUvSource;
|
|
31
|
+
readonly modelView: THREE.Matrix4;
|
|
32
|
+
/**
|
|
33
|
+
* For an AnimatedSprite: the sheet its key was last chosen for and whether
|
|
34
|
+
* that sheet had failed, so a frame on the same sheet skips the move.
|
|
35
|
+
*/
|
|
36
|
+
shownSheet: number;
|
|
37
|
+
shownSheetFailed: boolean;
|
|
38
|
+
constructor(anchor: SpriteAnchor, owner: SpriteBatches, seat: SpriteBatchSeat);
|
|
39
|
+
get key(): SpriteBatchKey;
|
|
40
|
+
get uvRepeat(): THREE.Vector2;
|
|
41
|
+
get uvOffset(): THREE.Vector2;
|
|
42
|
+
/** Moves to the batch of `key` (a shape change, a failed texture, another sheet); a no-op for the same key. */
|
|
43
|
+
moveTo(key: SpriteBatchKey): void;
|
|
44
|
+
/** Frees the slot and removes the anchor: the sprite was destroyed. */
|
|
45
|
+
release(): void;
|
|
46
|
+
}
|
|
47
|
+
/** The batch instance whose hidden anchor this is; undefined for an ordinary mesh. */
|
|
48
|
+
export declare function spriteInstanceOf(object: THREE.Object3D | undefined): SpriteInstance | undefined;
|
|
49
|
+
/**
|
|
50
|
+
* A Game's Sprite Batches (ADR 0024): one SpriteBatch per key, the hidden
|
|
51
|
+
* anchors of every batched sprite, and the per-frame pass that orders the
|
|
52
|
+
* frame like three would, groups runs and draws each through one instanced
|
|
53
|
+
* mesh. Scene-scoped: unload() releases everything (ADR 0011).
|
|
54
|
+
*/
|
|
55
|
+
export declare class SpriteBatches {
|
|
56
|
+
private readonly textures;
|
|
57
|
+
/** Whether sprites readied from now on batch: the scene's `render.batch !== false`. */
|
|
58
|
+
enabled: boolean;
|
|
59
|
+
private readonly batches;
|
|
60
|
+
private readonly geometries;
|
|
61
|
+
private readonly live;
|
|
62
|
+
private readonly root;
|
|
63
|
+
/** Under root, one Group per non-zero group order, so a run keeps its sprites' Group renderOrder. */
|
|
64
|
+
private readonly groups;
|
|
65
|
+
private readonly collector;
|
|
66
|
+
private camera;
|
|
67
|
+
private nextOrder;
|
|
68
|
+
private readonly pinned;
|
|
69
|
+
private readonly pinnedOrders;
|
|
70
|
+
private pinnedScene;
|
|
71
|
+
private sceneAutoUpdate;
|
|
72
|
+
private readonly visitor;
|
|
73
|
+
constructor(textures: SpriteBatchTextures);
|
|
74
|
+
/** A new batched sprite in the key's batch; the caller parents its hidden anchor. */
|
|
75
|
+
attach(key: SpriteBatchKey): SpriteInstance;
|
|
76
|
+
/** When the key's texture settled; null for an untextured or bare entry. */
|
|
77
|
+
settled(key: SpriteBatchKey): Promise<TextureOutcome> | null;
|
|
78
|
+
move(instance: SpriteInstance, key: SpriteBatchKey): void;
|
|
79
|
+
detach(instance: SpriteInstance): void;
|
|
80
|
+
/**
|
|
81
|
+
* The frame pass, right before `renderer.render`: orders every transparent
|
|
82
|
+
* renderable as three would, draws each run of same-key sprites through one
|
|
83
|
+
* instanced mesh, and pins that order with renderOrder. Returns what undoes
|
|
84
|
+
* the temporary renderOrder of the non-sprite renderables after the render.
|
|
85
|
+
*/
|
|
86
|
+
prepareFrame(scene: THREE.Scene, camera: THREE.Camera): () => void;
|
|
87
|
+
/** Runs `render` with this frame's runs drawn and the draw order pinned, then undoes the pin. */
|
|
88
|
+
drawFrame(scene: THREE.Scene, camera: THREE.Camera, render: () => void): void;
|
|
89
|
+
/** Releases every batch, geometry and run mesh; batching is on again for the next scene. */
|
|
90
|
+
unload(): void;
|
|
91
|
+
private batchFor;
|
|
92
|
+
private geometryFor;
|
|
93
|
+
/** Draws the frame's run `start`..`end` of `batch` through its next pooled run mesh, in its Group order. */
|
|
94
|
+
private drawRun;
|
|
95
|
+
/** Pins a non-sprite renderable's renderOrder to its place in the frame, until unpin. */
|
|
96
|
+
private pin;
|
|
97
|
+
/** After the render: every pinned renderOrder back, and the scene's own matrix updates. */
|
|
98
|
+
private readonly unpin;
|
|
99
|
+
/** The Group a run of `groupOrder` hangs from: three reads its renderOrder as the run's group order. */
|
|
100
|
+
private groupFor;
|
|
101
|
+
/** The instance with its view × world computed exactly as three computes `modelViewMatrix`. */
|
|
102
|
+
private placed;
|
|
103
|
+
}
|
|
104
|
+
/** Internal: the Game creates its batches here at construction, findable by its sprites. */
|
|
105
|
+
export declare function createSpriteBatches(game: Game, textures: SpriteBatchTextures): SpriteBatches;
|
|
106
|
+
/** A Game's Sprite Batches; undefined for anything that is not a constructed Game (a test stub). */
|
|
107
|
+
export declare function spriteBatchesOf(game: Game): SpriteBatches | undefined;
|