@solidrt/2d 0.0.66 → 0.0.68
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/AGENTS.md +246 -69
- package/README.md +33 -18
- package/examples/README.md +29 -0
- package/examples/anim.tsx +1 -1
- package/examples/atlases.tsx +151 -0
- package/examples/camera-probe.tsx +7 -7
- package/examples/camera.tsx +2 -3
- package/examples/markers.tsx +185 -0
- package/examples/parity.tsx +2 -2
- package/examples/pick.tsx +1 -1
- package/examples/split-screen.tsx +1 -1
- package/examples/springs.tsx +1 -1
- package/examples/sprites.tsx +1 -1
- package/examples/text.tsx +143 -0
- package/examples/tiles.tsx +1 -1
- package/examples/views.tsx +3 -3
- package/package.json +3 -3
- package/src/atlas.ts +75 -12
- package/src/camera2d.ts +47 -34
- package/src/collision.ts +20 -0
- package/src/components/camera2d.tsx +6 -13
- package/src/components/group.tsx +8 -11
- package/src/components/index.ts +3 -1
- package/src/components/sprite-layer.tsx +51 -14
- package/src/components/sprite.tsx +19 -9
- package/src/components/text2d.tsx +77 -0
- package/src/components/tile-layer.tsx +11 -5
- package/src/components/view2d.tsx +2 -0
- package/src/dispatch.ts +16 -4
- package/src/extrude.ts +156 -0
- package/src/font.ts +207 -0
- package/src/frames.ts +65 -55
- package/src/index.ts +37 -18
- package/src/layer.ts +273 -45
- package/src/pack.ts +70 -0
- package/src/pick.ts +25 -1
- package/src/records.ts +286 -121
- package/src/shaders.ts +224 -77
- package/src/shots.ts +23 -9
- package/src/text-layout.ts +163 -0
- package/src/text.ts +240 -0
- package/src/tiles.ts +111 -71
- package/src/views.ts +21 -13
package/src/pick.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
// Picking narrowphase, pure: no GPU or GUI imports, so its test
|
|
2
2
|
// (tests/pick.test.ts) exercises this module headless on the flux binary.
|
|
3
|
-
//
|
|
3
|
+
// records.ts walks its draw order calling pointInSprite per record
|
|
4
|
+
// (topmost first); layer.ts runs it over the core's candidates when a
|
|
5
|
+
// screen-size clamp is in play, sizing the rect with core's
|
|
6
|
+
// screenSizeScale (the JS twin of the vertex stages' clamp); floorReach
|
|
7
|
+
// bounds that candidate search.
|
|
4
8
|
|
|
5
9
|
/** Exact containment test against a rotated rect (center, size, rotation). */
|
|
6
10
|
export function pointInSprite(
|
|
@@ -20,3 +24,23 @@ export function pointInSprite(
|
|
|
20
24
|
let ly = dx * s + dy * c
|
|
21
25
|
return Math.abs(lx) <= w / 2 && Math.abs(ly) <= h / 2
|
|
22
26
|
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* How far, in view pixels, a floored `w x h` sprite can reach from its
|
|
30
|
+
* center once the floor is in force: half its drawn diagonal at the floor,
|
|
31
|
+
* which depends on the aspect only (the smaller axis is minScreenPx, the
|
|
32
|
+
* larger minScreenPx times the aspect). The candidate radius a pick
|
|
33
|
+
* searches around the pointer, divided by the zoom; 0 when the floor is
|
|
34
|
+
* off or the sprite is collapsed. A ceiling only shrinks the rect, so it
|
|
35
|
+
* never widens the reach. The reach is highest at a size transition's
|
|
36
|
+
* ends (the aspect of two linearly moving axes is monotone), so the
|
|
37
|
+
* targets bound the flight.
|
|
38
|
+
*/
|
|
39
|
+
export function floorReach(w: number, h: number, minScreenPx: number): number {
|
|
40
|
+
let aw = Math.abs(w)
|
|
41
|
+
let ah = Math.abs(h)
|
|
42
|
+
let smallest = Math.min(aw, ah)
|
|
43
|
+
if (!(minScreenPx > 0 && smallest > 0)) return 0
|
|
44
|
+
let aspect = Math.max(aw, ah) / smallest
|
|
45
|
+
return (minScreenPx * Math.hypot(1, aspect)) / 2
|
|
46
|
+
}
|
package/src/records.ts
CHANGED
|
@@ -1,28 +1,42 @@
|
|
|
1
1
|
// The records layer: the raw escape hatch for motion only JS can compute
|
|
2
2
|
// (bespoke flocking, per-frame gameplay logic over every entity at large
|
|
3
|
-
// populations). Sprites are
|
|
3
|
+
// populations). Sprites are 20 JS-owned floats in one canonical
|
|
4
4
|
// Float32Array ordered by draw order (insertion order - painter's
|
|
5
5
|
// algorithm, later over earlier; `orderBy` swaps that for a core-produced
|
|
6
|
-
// key order at publish, records untouched); mutations batch
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
6
|
+
// key order at publish, records untouched); mutations batch, and the flush
|
|
7
|
+
// (ahead of the frame's paint, or at a microtask outside a frame)
|
|
8
|
+
// publishes what changed: the dirty record range as one buffer write, or
|
|
9
|
+
// under `orderBy` the drawn prefix whole through the zero-copy write lease
|
|
10
|
+
// (the core gathers it into key order during the copy, and a byte range
|
|
11
|
+
// has no stable position under a permutation). A moved sprite is 20 float
|
|
12
|
+
// stores plus its share of one memcpy per dirty frame; a static layer
|
|
13
|
+
// publishes nothing and therefore costs nothing.
|
|
14
|
+
//
|
|
15
|
+
// The record vocabulary is @solidrt/3d's record mesh's, one dimension
|
|
16
|
+
// down: `records(layer)` is the mirror, `updateRecords(layer, { first?,
|
|
17
|
+
// count? })` publishes a range, `setRecordCount(layer, n)` dials the drawn
|
|
18
|
+
// prefix. The POPULATION differs by kind and keeps its own verbs - a
|
|
19
|
+
// record mesh's records are opaque data (setRecords), a record layer's
|
|
20
|
+
// are sprites (addSprite/destroySprite) - so the copy-in here is
|
|
21
|
+
// `records(layer).set(src)` followed by updateRecords.
|
|
11
22
|
//
|
|
12
23
|
// This is NOT the default live layer - that is layer.ts, where sprites are
|
|
13
24
|
// spatial arena nodes core producers can reach. Use this when a JS loop
|
|
14
|
-
// writes every record every frame anyway: `
|
|
15
|
-
// ~2.4x faster than setSprite at 30k sprites
|
|
16
|
-
// 30.8ms via setSprite, purely call overhead). It
|
|
17
|
-
// land; it is the where-motion-is-computed axis, not
|
|
25
|
+
// writes every record every frame anyway: `records(layer)` +
|
|
26
|
+
// `updateRecords(layer)` is ~2.4x faster than setSprite at 30k sprites
|
|
27
|
+
// (measured 12.9ms raw vs 30.8ms via setSprite, purely call overhead). It
|
|
28
|
+
// shrinks as producers land; it is the where-motion-is-computed axis, not
|
|
29
|
+
// a "game tier".
|
|
18
30
|
//
|
|
19
31
|
// Layer space, camera, pointer dispatch and the sprite functions
|
|
20
32
|
// (addSprite/setSprite/...) are shared with the node layer; picking here is
|
|
21
33
|
// the JS reverse walk (pointInSprite), since records have no nodes.
|
|
22
|
-
import { getOwner, onCleanup } from "@solidrt/core"
|
|
23
|
-
import { beginBufferWrite, createBuffer, destroyBuffer, endBufferWrite } from "@solidrt/core/gpu"
|
|
24
|
-
import type { BufferId
|
|
25
|
-
import {
|
|
34
|
+
import { getOwner, onBeforeRender, onCleanup, runWithOwner } from "@solidrt/core"
|
|
35
|
+
import { beginBufferWrite, checkScreenSize, createBuffer, destroyBuffer, endBufferWrite, screenSizeScale, writeBuffer } from "@solidrt/core/gpu"
|
|
36
|
+
import type { BufferId } from "@solidrt/core/gpu"
|
|
37
|
+
import { checkAtlases, frameIndex } from "./atlas.ts"
|
|
38
|
+
import type { Atlas } from "./atlas.ts"
|
|
39
|
+
import { fullFrame, isFrame, writeFrame } from "./frames.ts"
|
|
26
40
|
import { checkTint, readFrame } from "./layer.ts"
|
|
27
41
|
import type { LayerBase, Sprite, SpriteLayerOptions, SpriteOptions, SpriteState } from "./layer.ts"
|
|
28
42
|
import { pointInSprite } from "./pick.ts"
|
|
@@ -30,12 +44,25 @@ import { createSpritePipeline, INSTANCE_ATTRIBUTES, VERTEX } from "./shaders.ts"
|
|
|
30
44
|
import { createViews } from "./views.ts"
|
|
31
45
|
|
|
32
46
|
// Floats per instance record:
|
|
33
|
-
// [cx, cy, w, h, u0, v0, u1, v1, rot, tintR, tintG, tintB, tintA
|
|
34
|
-
|
|
47
|
+
// [cx, cy, w, h, u0, v0, u1, v1, rot, tintR, tintG, tintB, tintA,
|
|
48
|
+
// minScreenPx, maxScreenPx, atlas, outlineR, outlineG, outlineB,
|
|
49
|
+
// outlineWidth]
|
|
50
|
+
export const INSTANCE_FLOATS = 20
|
|
51
|
+
// Bytes per record: the stride of the GPU buffer and the mirror alike.
|
|
52
|
+
const RECORD_BYTES = INSTANCE_FLOATS * Float32Array.BYTES_PER_ELEMENT
|
|
35
53
|
|
|
36
54
|
// Float offset of cy in a record - what `orderBy: "y"` keys on.
|
|
37
55
|
const Y_FIELD_OFFSET = 1
|
|
38
|
-
|
|
56
|
+
// Float offsets of the screen-size clamp in a record.
|
|
57
|
+
const MIN_PX_FIELD_OFFSET = 13
|
|
58
|
+
const MAX_PX_FIELD_OFFSET = 14
|
|
59
|
+
// Float offset of the atlas sampler index (the frame's texture's position
|
|
60
|
+
// in the layer's atlas list).
|
|
61
|
+
const ATLAS_FIELD_OFFSET = 15
|
|
62
|
+
// Float offset of the outline (rgb plus a width in world pixels) a
|
|
63
|
+
// distance-field atlas draws under the sprite; zero on a colour atlas and
|
|
64
|
+
// on every sprite the raw writer leaves alone.
|
|
65
|
+
const OUTLINE_FIELD_OFFSET = 16
|
|
39
66
|
const RESOLVED = Promise.resolve()
|
|
40
67
|
|
|
41
68
|
export type RecordLayerOptions = Omit<SpriteLayerOptions, "orderBy"> & {
|
|
@@ -56,42 +83,103 @@ export type RecordLayerOptions = Omit<SpriteLayerOptions, "orderBy"> & {
|
|
|
56
83
|
}
|
|
57
84
|
|
|
58
85
|
export type RecordLayer = LayerBase & {
|
|
59
|
-
/**
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
|
|
67
|
-
* cache the array across addSprite - growth replaces it.
|
|
68
|
-
*/
|
|
69
|
-
records: Float32Array
|
|
70
|
-
/**
|
|
71
|
-
* Run `fn` with the CURRENT record array and return its result - the
|
|
72
|
-
* hoist-proof form of `records`. A cached array reference silently
|
|
73
|
-
* becomes a dead copy after growth (writes publish nothing); reading
|
|
74
|
-
* through withRecords at use time always hits the live array.
|
|
75
|
-
*/
|
|
76
|
-
withRecords<T>(fn: (records: Float32Array) => T): T
|
|
77
|
-
/** Mark the records dirty and schedule the publish (the raw-path commit). */
|
|
78
|
-
touch(): void
|
|
86
|
+
/** The record mirror; read it through records(). Replaced by growth. */
|
|
87
|
+
_records: Float32Array
|
|
88
|
+
/** The record range [lo, hi) written since the last publish, or null. */
|
|
89
|
+
_dirty: [number, number] | null
|
|
90
|
+
/** The count dial (setRecordCount): how many leading sprites draw and
|
|
91
|
+
* pick; Infinity (the identity of the min against the live count)
|
|
92
|
+
* until a dial is set. */
|
|
93
|
+
_dial: number
|
|
79
94
|
_order: SpriteState[]
|
|
80
95
|
}
|
|
81
96
|
|
|
97
|
+
/** Options of `updateRecords`: the record range (default the whole
|
|
98
|
+
* mirror, capacity wide). */
|
|
99
|
+
export type UpdateRecordsOptions = { first?: number; count?: number }
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The layer's record mirror - the raw power path. Layout per sprite is
|
|
103
|
+
* INSTANCE_FLOATS floats: [cx, cy, w, h, u0, v0, u1, v1, rot, tintR,
|
|
104
|
+
* tintG, tintB, tintA, minScreenPx, maxScreenPx, atlas, outlineR,
|
|
105
|
+
* outlineG, outlineB, outlineWidth], record i at i * INSTANCE_FLOATS;
|
|
106
|
+
* `atlas` is the frame's texture as its index in the layer's `atlases`
|
|
107
|
+
* list, and the outline is read only by a distance-field atlas (see
|
|
108
|
+
* `Atlas.sdf`): rgb in 0..1 and a width in world pixels, zero for none. Record order is draw order - unless the
|
|
109
|
+
* layer was created with `orderBy`, which draws in key order while record
|
|
110
|
+
* i keeps meaning sprite i. Write fields directly for large per-frame
|
|
111
|
+
* populations, then updateRecords the range. Read it AT USE TIME: addSprite
|
|
112
|
+
* past capacity replaces the array, and a hoisted reference becomes a dead
|
|
113
|
+
* copy whose writes publish nothing. Do not cache indices across
|
|
114
|
+
* destroySprite - records shift. @solidrt/3d's records(mesh).
|
|
115
|
+
*/
|
|
116
|
+
export function records(layer: RecordLayer): Float32Array {
|
|
117
|
+
return layer._records
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Publish records [first, first + count) of the mirror at the layer's
|
|
122
|
+
* next flush - the partial rewrite a population stepped in JS wants (ten
|
|
123
|
+
* moved records of ten thousand cost ten); the whole mirror by default.
|
|
124
|
+
* Write the mirror first, through records(), then call this. The range
|
|
125
|
+
* is against the mirror's capacity, not the live sprites: a record past
|
|
126
|
+
* the last live sprite is accepted and never drawn. Under `orderBy` any
|
|
127
|
+
* range republishes the drawn prefix whole - the core gathers it into
|
|
128
|
+
* key order, and a byte range has no stable position under a
|
|
129
|
+
* permutation. @solidrt/3d's updateRecords(mesh).
|
|
130
|
+
*/
|
|
131
|
+
export function updateRecords(layer: RecordLayer, options: UpdateRecordsOptions = {}): void {
|
|
132
|
+
let capacity = layer._records.length / INSTANCE_FLOATS
|
|
133
|
+
let first = options.first ?? 0
|
|
134
|
+
let count = options.count ?? capacity - first
|
|
135
|
+
if (!Number.isInteger(first) || !Number.isInteger(count) || first < 0 || count < 0 || first + count > capacity) {
|
|
136
|
+
throw new Error("updateRecords: range [" + first + ", " + (first + count) + ") is outside the layer's " + capacity + " records")
|
|
137
|
+
}
|
|
138
|
+
if (count > 0) markRecords(layer, first, first + count)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Dial how many leading sprites draw and pick: the first `count` of the
|
|
143
|
+
* live sprites (a count past them is all of them), in force as sprites
|
|
144
|
+
* come and go - a sprite added past the dial stays undrawn, and unpicked,
|
|
145
|
+
* until the dial moves. The write-ahead idiom: add a pool at mount, write
|
|
146
|
+
* the live prefix each frame, then dial it. An undialed layer draws every
|
|
147
|
+
* live sprite; `layer.count` stays the live total either way.
|
|
148
|
+
* @solidrt/3d's setRecordCount(mesh).
|
|
149
|
+
*/
|
|
150
|
+
export function setRecordCount(layer: RecordLayer, count: number): void {
|
|
151
|
+
if (!(Number.isInteger(count) && count >= 0)) throw new Error("setRecordCount: count must be a non-negative integer, got " + count)
|
|
152
|
+
if (count === layer._dial) return
|
|
153
|
+
layer._dial = count
|
|
154
|
+
layer._schedule()
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Extend the layer's dirty range over records [lo, hi) and schedule the
|
|
158
|
+
// flush that publishes it.
|
|
159
|
+
function markRecords(layer: RecordLayer, lo: number, hi: number): void {
|
|
160
|
+
if (layer._dirty === null) layer._dirty = [lo, hi]
|
|
161
|
+
else {
|
|
162
|
+
if (lo < layer._dirty[0]) layer._dirty[0] = lo
|
|
163
|
+
if (hi > layer._dirty[1]) layer._dirty[1] = hi
|
|
164
|
+
}
|
|
165
|
+
layer._schedule()
|
|
166
|
+
}
|
|
167
|
+
|
|
82
168
|
/**
|
|
83
|
-
* Create a records layer over
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
169
|
+
* Create a records layer over its atlases (see createSpriteLayer: the
|
|
170
|
+
* list every frame must come from, bound as one draw; not owned). Like
|
|
171
|
+
* createSpriteLayer it renders nothing by itself: `createView` is where
|
|
172
|
+
* it shows. Disposed automatically with the owning reactive scope (opt
|
|
173
|
+
* out with `{ autoFree: false }`).
|
|
87
174
|
*/
|
|
88
|
-
export function createRecordLayer(
|
|
175
|
+
export function createRecordLayer(atlases: Atlas[], opts?: RecordLayerOptions): RecordLayer {
|
|
176
|
+
let atlasIndex = checkAtlases("createRecordLayer", atlases)
|
|
89
177
|
let capacity = opts?.capacity ?? 1024
|
|
90
178
|
if (!(capacity > 0 && Number.isInteger(capacity))) {
|
|
91
179
|
throw new Error(`createRecordLayer: capacity must be a positive integer, got ${capacity}`)
|
|
92
180
|
}
|
|
93
181
|
let label = opts?.label ?? "sprites"
|
|
94
|
-
let
|
|
182
|
+
let buffer: BufferId = createBuffer(capacity * RECORD_BYTES, {
|
|
95
183
|
label: `${label}-records`,
|
|
96
184
|
autoFree: false,
|
|
97
185
|
})
|
|
@@ -104,65 +192,110 @@ export function createRecordLayer(atlas: TextureId, opts?: RecordLayerOptions):
|
|
|
104
192
|
: orderBy === "y"
|
|
105
193
|
? { field: Y_FIELD_OFFSET }
|
|
106
194
|
: { field: orderBy.field, descending: orderBy.descending }
|
|
107
|
-
let
|
|
195
|
+
let ordered = instanceOrder !== undefined
|
|
196
|
+
let gpu = createSpritePipeline(label, VERTEX, [INSTANCE_ATTRIBUTES], opts?.blend ?? "alpha", atlases)
|
|
108
197
|
|
|
109
198
|
let disposed = false
|
|
110
|
-
let dirty = false
|
|
111
199
|
let scheduled = false
|
|
200
|
+
// The drawn count the views were last told.
|
|
112
201
|
let published = 0
|
|
202
|
+
// The publish pass of every frame runs the pending flush ahead of the
|
|
203
|
+
// paint, so a write made anywhere in the frame's JS is in that frame's
|
|
204
|
+
// picture; the microtask stays for writes made outside a frame.
|
|
205
|
+
// Registered outside any owner: dispose unhooks it (an autoFree: false
|
|
206
|
+
// layer outlives the owner it was created in).
|
|
207
|
+
let unhook = runWithOwner(null, () =>
|
|
208
|
+
onBeforeRender(
|
|
209
|
+
() => {
|
|
210
|
+
if (scheduled) flush()
|
|
211
|
+
},
|
|
212
|
+
{ publish: true },
|
|
213
|
+
),
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
// How many leading sprites draw: the dial against the live count.
|
|
217
|
+
let drawn = () => Math.min(layer._dial, layer._order.length)
|
|
218
|
+
// Publish the first `n` records whole through the write lease: a
|
|
219
|
+
// replacement buffer's first fill (its contents are unspecified), and
|
|
220
|
+
// every publish of an ordered layer (the gather runs in the copy).
|
|
221
|
+
let publishWhole = (target: BufferId, n: number) => {
|
|
222
|
+
let out = beginBufferWrite(target)
|
|
223
|
+
out.set(layer._records.subarray(0, n * INSTANCE_FLOATS))
|
|
224
|
+
endBufferWrite(target, n * RECORD_BYTES)
|
|
225
|
+
}
|
|
113
226
|
|
|
114
|
-
// The GPU buffer's record capacity; the
|
|
115
|
-
// (addSprite) and the
|
|
227
|
+
// The GPU buffer's record capacity; the mirror grows ahead of it
|
|
228
|
+
// (addSprite) and the flush catches the buffer up: a larger buffer is
|
|
116
229
|
// created, written in full, swapped in, and the old one destroyed. The
|
|
117
230
|
// entries hold the old buffer alive until the swaps land, so the destroy
|
|
118
231
|
// is safe to issue right after.
|
|
119
232
|
let gpuCapacity = capacity
|
|
120
233
|
let flush = () => {
|
|
121
234
|
scheduled = false
|
|
122
|
-
if (disposed
|
|
123
|
-
dirty =
|
|
124
|
-
|
|
125
|
-
let
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
235
|
+
if (disposed) return
|
|
236
|
+
let dirty = layer._dirty
|
|
237
|
+
layer._dirty = null
|
|
238
|
+
let mirror = layer._records
|
|
239
|
+
let live = layer._order.length
|
|
240
|
+
let count = drawn()
|
|
241
|
+
if (mirror.length > gpuCapacity * INSTANCE_FLOATS) {
|
|
242
|
+
gpuCapacity = mirror.length / INSTANCE_FLOATS
|
|
243
|
+
let grown = createBuffer(mirror.length * Float32Array.BYTES_PER_ELEMENT, { label: `${label}-records`, autoFree: false })
|
|
244
|
+
// The replacement is filled whole before the swap (the entry must
|
|
245
|
+
// never point at an unwritten buffer): the live prefix, or under
|
|
246
|
+
// orderBy the drawn one, which is all the gather may see.
|
|
247
|
+
publishWhole(grown, ordered ? count : live)
|
|
135
248
|
views.setBuffers([grown])
|
|
136
249
|
views.setCount(count)
|
|
137
|
-
if (
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
// One more publish, now under the swapped-in order, restores key
|
|
250
|
+
if (ordered) {
|
|
251
|
+
// That publish landed BEFORE the swap, so the order had not yet
|
|
252
|
+
// followed to the grown buffer and it went out ungathered. One
|
|
253
|
+
// more publish, now under the swapped-in order, restores key
|
|
142
254
|
// order - growth frames only.
|
|
143
|
-
|
|
144
|
-
again.set(layer.records.subarray(0, count * FLOATS_PER_SPRITE))
|
|
145
|
-
endBufferWrite(grown, count * FLOATS_PER_SPRITE * 4)
|
|
255
|
+
publishWhole(grown, count)
|
|
146
256
|
}
|
|
147
|
-
destroyBuffer(
|
|
148
|
-
|
|
257
|
+
destroyBuffer(buffer)
|
|
258
|
+
buffer = grown
|
|
149
259
|
published = count
|
|
150
|
-
|
|
260
|
+
return
|
|
261
|
+
}
|
|
262
|
+
if (ordered) {
|
|
263
|
+
// The gathered set IS the drawn prefix: a dirty record or a moved
|
|
264
|
+
// dial republishes it whole.
|
|
265
|
+
if (dirty !== null || count !== published) publishWhole(buffer, count)
|
|
266
|
+
} else if (dirty !== null) {
|
|
267
|
+
// The dirty range, clipped to the live sprites (a record past them
|
|
268
|
+
// is never drawn): one partial write at its byte offset.
|
|
269
|
+
let lo = dirty[0]
|
|
270
|
+
let hi = Math.min(dirty[1], live)
|
|
271
|
+
if (hi > lo) writeBuffer(buffer, mirror.subarray(lo * INSTANCE_FLOATS, hi * INSTANCE_FLOATS), lo * RECORD_BYTES)
|
|
272
|
+
}
|
|
273
|
+
if (count !== published) {
|
|
151
274
|
views.setCount(count)
|
|
152
275
|
published = count
|
|
153
276
|
}
|
|
154
277
|
}
|
|
155
278
|
|
|
156
|
-
//
|
|
157
|
-
|
|
279
|
+
// The checks a write runs before ANY field lands - the fields a record
|
|
280
|
+
// has no room for, and the frame's atlas index (or a throw for a frame
|
|
281
|
+
// from an undeclared sheet) - so a rejected write leaves the record, and
|
|
282
|
+
// on addSprite the layer, as it was.
|
|
283
|
+
let checkWrite = (verb: string, opts: SpriteOptions): number => {
|
|
158
284
|
if (opts.renderOrder !== undefined) {
|
|
159
|
-
throw new Error(
|
|
285
|
+
throw new Error(`${verb}: record layers have no renderOrder field; order by a record field with orderBy { field }`)
|
|
160
286
|
}
|
|
161
287
|
if (opts.visible !== undefined) {
|
|
162
|
-
throw new Error(
|
|
288
|
+
throw new Error(`${verb}: record sprites have no visibility; hide by zeroing w or h`)
|
|
163
289
|
}
|
|
164
|
-
|
|
165
|
-
|
|
290
|
+
if (opts.frame === undefined) return -1
|
|
291
|
+
if (!isFrame(opts.frame)) throw new Error(`${verb}: not a frame, got ${JSON.stringify(opts.frame)}`)
|
|
292
|
+
return frameIndex(verb, atlasIndex, opts.frame)
|
|
293
|
+
}
|
|
294
|
+
// Record layout: [cx, cy, w, h, u0, v0, u1, v1, rot, tintR, tintG, tintB,
|
|
295
|
+
// tintA, minScreenPx, maxScreenPx, atlas, outline rgb + width]
|
|
296
|
+
let writeRecord = (sprite: SpriteState, opts: SpriteOptions, atlas: number) => {
|
|
297
|
+
let at = sprite._slot * INSTANCE_FLOATS
|
|
298
|
+
let r = layer._records
|
|
166
299
|
if (opts.x !== undefined) r[at] = opts.x
|
|
167
300
|
if (opts.y !== undefined) r[at + 1] = opts.y
|
|
168
301
|
if (opts.w !== undefined) r[at + 2] = opts.w
|
|
@@ -174,6 +307,7 @@ export function createRecordLayer(atlas: TextureId, opts?: RecordLayerOptions):
|
|
|
174
307
|
if (opts.frame !== undefined) {
|
|
175
308
|
let f = opts.frame
|
|
176
309
|
writeFrame(r, at + 4, f.u0, f.v0, f.u1, f.v1, sprite._flipX, sprite._flipY)
|
|
310
|
+
r[at + ATLAS_FIELD_OFFSET] = atlas
|
|
177
311
|
} else if (flipX || flipY) {
|
|
178
312
|
// No new frame: toggle the changed axes on the stored UVs.
|
|
179
313
|
writeFrame(r, at + 4, r[at + 4]!, r[at + 5]!, r[at + 6]!, r[at + 7]!, flipX, flipY)
|
|
@@ -185,21 +319,31 @@ export function createRecordLayer(atlas: TextureId, opts?: RecordLayerOptions):
|
|
|
185
319
|
r[at + 11] = opts.tint[2]
|
|
186
320
|
r[at + 12] = opts.tint[3]
|
|
187
321
|
}
|
|
322
|
+
if (opts.minScreenPx !== undefined) r[at + MIN_PX_FIELD_OFFSET] = opts.minScreenPx
|
|
323
|
+
if (opts.maxScreenPx !== undefined) r[at + MAX_PX_FIELD_OFFSET] = opts.maxScreenPx
|
|
324
|
+
}
|
|
325
|
+
// The clamp pair against the STORED bounds, before any write lands.
|
|
326
|
+
let checkClamp = (verb: string, sprite: SpriteState, opts: SpriteOptions): void => {
|
|
327
|
+
if (opts.minScreenPx === undefined && opts.maxScreenPx === undefined) return
|
|
328
|
+
let at = sprite._slot * INSTANCE_FLOATS
|
|
329
|
+
let r = layer._records
|
|
330
|
+
checkScreenSize(verb, opts.minScreenPx ?? r[at + MIN_PX_FIELD_OFFSET]!, opts.maxScreenPx ?? r[at + MAX_PX_FIELD_OFFSET]!)
|
|
188
331
|
}
|
|
189
332
|
|
|
190
333
|
let views = createViews({
|
|
191
334
|
label,
|
|
192
335
|
pipeline: gpu.pipeline,
|
|
193
336
|
quad: gpu.quad,
|
|
194
|
-
|
|
195
|
-
buffers: () => [
|
|
337
|
+
atlases: atlases.map(a => a.texture),
|
|
338
|
+
buffers: () => [buffer],
|
|
196
339
|
count: () => published,
|
|
197
340
|
tint: () => tint,
|
|
198
|
-
pick: (x, y) => layer.pick(x, y),
|
|
341
|
+
pick: (x, y, zoom) => layer.pick(x, y, zoom),
|
|
199
342
|
order: instanceOrder,
|
|
200
343
|
})
|
|
201
344
|
|
|
202
345
|
let layer: RecordLayer = {
|
|
346
|
+
atlases,
|
|
203
347
|
get count() {
|
|
204
348
|
return layer._order.length
|
|
205
349
|
},
|
|
@@ -213,13 +357,20 @@ export function createRecordLayer(atlas: TextureId, opts?: RecordLayerOptions):
|
|
|
213
357
|
if (disposed) throw new Error("createView: layer is disposed")
|
|
214
358
|
return views.create(vopts)
|
|
215
359
|
},
|
|
216
|
-
pick(x, y) {
|
|
217
|
-
// Topmost first: reverse draw order
|
|
360
|
+
pick(x, y, zoom = 1) {
|
|
361
|
+
// Topmost first: reverse draw order over the DRAWN prefix (a sprite
|
|
362
|
+
// past the dial is undrawn and unpicked), exact rotated-rect
|
|
363
|
+
// containment of the drawn rect (the record's size under its clamp
|
|
364
|
+
// at this zoom).
|
|
365
|
+
if (!(zoom > 0)) throw new Error(`pick: zoom must be positive, got ${zoom}`)
|
|
218
366
|
let out: Sprite[] = []
|
|
219
|
-
let r = layer.
|
|
220
|
-
for (let i =
|
|
221
|
-
let at = i *
|
|
222
|
-
|
|
367
|
+
let r = layer._records
|
|
368
|
+
for (let i = drawn() - 1; i >= 0; i--) {
|
|
369
|
+
let at = i * INSTANCE_FLOATS
|
|
370
|
+
let w = r[at + 2]!
|
|
371
|
+
let h = r[at + 3]!
|
|
372
|
+
let scale = screenSizeScale(w, h, zoom, r[at + MIN_PX_FIELD_OFFSET]!, r[at + MAX_PX_FIELD_OFFSET]!)
|
|
373
|
+
if (pointInSprite(x, y, r[at]!, r[at + 1]!, w * scale, h * scale, r[at + 8]!)) {
|
|
223
374
|
out.push(layer._order[i]!)
|
|
224
375
|
}
|
|
225
376
|
}
|
|
@@ -228,54 +379,71 @@ export function createRecordLayer(atlas: TextureId, opts?: RecordLayerOptions):
|
|
|
228
379
|
dispose() {
|
|
229
380
|
if (disposed) return
|
|
230
381
|
disposed = true
|
|
382
|
+
unhook()
|
|
231
383
|
for (let sprite of layer._order) sprite.layer = null
|
|
232
384
|
layer._order.length = 0
|
|
233
385
|
views.dispose()
|
|
234
|
-
destroyBuffer(
|
|
386
|
+
destroyBuffer(buffer)
|
|
235
387
|
gpu.dispose()
|
|
236
388
|
},
|
|
237
389
|
_add(opts) {
|
|
238
390
|
if (opts?.parent) {
|
|
239
391
|
throw new Error("addSprite: record layers have no groups (parent is the node layer's)")
|
|
240
392
|
}
|
|
393
|
+
// An absent key and an explicit undefined both take the default (the
|
|
394
|
+
// options convention everywhere; a plain spread would let undefined
|
|
395
|
+
// win), the node layer's rule.
|
|
396
|
+
let record: SpriteOptions = {
|
|
397
|
+
...opts,
|
|
398
|
+
x: opts?.x ?? 0,
|
|
399
|
+
y: opts?.y ?? 0,
|
|
400
|
+
w: opts?.w ?? 0,
|
|
401
|
+
h: opts?.h ?? 0,
|
|
402
|
+
frame: opts?.frame ?? fullFrame(atlases[0]!),
|
|
403
|
+
rotation: opts?.rotation ?? 0,
|
|
404
|
+
tint: opts?.tint ?? [1, 1, 1, 1],
|
|
405
|
+
minScreenPx: opts?.minScreenPx ?? 0,
|
|
406
|
+
maxScreenPx: opts?.maxScreenPx ?? 0,
|
|
407
|
+
}
|
|
408
|
+
let atlas = checkWrite("addSprite", record)
|
|
241
409
|
let index = layer._order.length
|
|
242
|
-
if ((index + 1) *
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
layer.
|
|
410
|
+
if ((index + 1) * INSTANCE_FLOATS > layer._records.length) {
|
|
411
|
+
// Growth replaces the mirror (doubling); the flush catches the GPU
|
|
412
|
+
// buffer up with a whole publish.
|
|
413
|
+
let next = new Float32Array(layer._records.length * 2)
|
|
414
|
+
next.set(layer._records)
|
|
415
|
+
layer._records = next
|
|
246
416
|
}
|
|
247
417
|
let sprite: SpriteState = { layer, node: null, _slot: index, _x: 0, _y: 0, _w: 0, _h: 0, _rot: 0, _flipX: false, _flipY: false, _visible: true, _parent: null }
|
|
248
418
|
layer._order.push(sprite)
|
|
249
|
-
writeRecord(sprite,
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
frame: FULL_FRAME,
|
|
255
|
-
rotation: 0,
|
|
256
|
-
tint: [1, 1, 1, 1],
|
|
257
|
-
...opts,
|
|
258
|
-
})
|
|
259
|
-
layer._schedule()
|
|
419
|
+
writeRecord(sprite, record, atlas)
|
|
420
|
+
// No outline until a raw writer sets one: the slot may hold a
|
|
421
|
+
// shifted-out sprite's record otherwise.
|
|
422
|
+
layer._records.fill(0, index * INSTANCE_FLOATS + OUTLINE_FIELD_OFFSET, index * INSTANCE_FLOATS + OUTLINE_FIELD_OFFSET + 4)
|
|
423
|
+
markRecords(layer, index, index + 1)
|
|
260
424
|
return sprite
|
|
261
425
|
},
|
|
262
426
|
_write(sprite, opts) {
|
|
263
|
-
|
|
264
|
-
|
|
427
|
+
checkClamp("setSprite", sprite, opts)
|
|
428
|
+
let atlas = checkWrite("setSprite", opts)
|
|
429
|
+
writeRecord(sprite, opts, atlas)
|
|
430
|
+
markRecords(layer, sprite._slot, sprite._slot + 1)
|
|
265
431
|
},
|
|
266
432
|
_read(sprite) {
|
|
267
|
-
let at = sprite._slot *
|
|
268
|
-
let r = layer.
|
|
433
|
+
let at = sprite._slot * INSTANCE_FLOATS
|
|
434
|
+
let r = layer._records
|
|
269
435
|
return {
|
|
270
436
|
x: r[at]!,
|
|
271
437
|
y: r[at + 1]!,
|
|
272
438
|
w: r[at + 2]!,
|
|
273
439
|
h: r[at + 3]!,
|
|
274
|
-
frame: readFrame(r, at + 4, sprite),
|
|
440
|
+
frame: readFrame(r, at + 4, sprite, atlases[r[at + ATLAS_FIELD_OFFSET]!]!.texture),
|
|
275
441
|
flipX: sprite._flipX,
|
|
276
442
|
flipY: sprite._flipY,
|
|
277
443
|
rotation: r[at + 8]!,
|
|
278
444
|
tint: [r[at + 9]!, r[at + 10]!, r[at + 11]!, r[at + 12]!],
|
|
445
|
+
minScreenPx: r[at + MIN_PX_FIELD_OFFSET]!,
|
|
446
|
+
maxScreenPx: r[at + MAX_PX_FIELD_OFFSET]!,
|
|
279
447
|
// Record sprites have no key field and no visibility (see
|
|
280
448
|
// writeRecord's throws).
|
|
281
449
|
renderOrder: 0,
|
|
@@ -283,30 +451,27 @@ export function createRecordLayer(atlas: TextureId, opts?: RecordLayerOptions):
|
|
|
283
451
|
}
|
|
284
452
|
},
|
|
285
453
|
_destroy(sprite) {
|
|
286
|
-
// Later sprites shift down one draw slot (order preserved)
|
|
454
|
+
// Later sprites shift down one draw slot (order preserved): the
|
|
455
|
+
// shifted records are dirty, and the count shrinks either way.
|
|
287
456
|
sprite.layer = null
|
|
288
457
|
let index = sprite._slot
|
|
289
458
|
let order = layer._order
|
|
290
|
-
let r = layer.
|
|
291
|
-
|
|
459
|
+
let r = layer._records
|
|
460
|
+
let last = order.length - 1
|
|
461
|
+
r.copyWithin(index * INSTANCE_FLOATS, (index + 1) * INSTANCE_FLOATS, order.length * INSTANCE_FLOATS)
|
|
292
462
|
order.splice(index, 1)
|
|
293
463
|
for (let i = index; i < order.length; i++) order[i]!._slot = i
|
|
294
|
-
|
|
464
|
+
if (index < last) markRecords(layer, index, last)
|
|
465
|
+
else layer._schedule()
|
|
295
466
|
},
|
|
296
467
|
_schedule() {
|
|
297
|
-
if (disposed) return
|
|
298
|
-
dirty = true
|
|
299
|
-
if (scheduled) return
|
|
468
|
+
if (disposed || scheduled) return
|
|
300
469
|
scheduled = true
|
|
301
470
|
RESOLVED.then(flush)
|
|
302
471
|
},
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
},
|
|
307
|
-
touch() {
|
|
308
|
-
layer._schedule()
|
|
309
|
-
},
|
|
472
|
+
_records: new Float32Array(capacity * INSTANCE_FLOATS),
|
|
473
|
+
_dirty: null,
|
|
474
|
+
_dial: Infinity,
|
|
310
475
|
_order: [],
|
|
311
476
|
}
|
|
312
477
|
if (opts?.autoFree !== false && getOwner()) onCleanup(() => layer.dispose())
|