@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/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
- // layer.ts walks its draw order calling this per sprite (topmost first).
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 13 JS-owned floats in one canonical
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 to a microtask
7
- // whose flush publishes the live prefix through the zero-copy buffer write
8
- // lease.
9
- // A moved sprite is 13 float stores plus one bulk memcpy per dirty frame; a
10
- // static layer publishes nothing and therefore costs nothing.
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: `layer.records` + `touch()` is
15
- // ~2.4x faster than setSprite at 30k sprites (measured 12.9ms raw vs
16
- // 30.8ms via setSprite, purely call overhead). It shrinks as producers
17
- // land; it is the where-motion-is-computed axis, not a "game tier".
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, TextureId } from "@solidrt/core/gpu"
25
- import { FULL_FRAME, writeFrame } from "./frames.ts"
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
- export const FLOATS_PER_SPRITE = 13
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
- * The canonical record array - the raw power path. Layout per sprite is
61
- * FLOATS_PER_SPRITE floats: [cx, cy, w, h, u0, v0, u1, v1, rot, tintR,
62
- * tintG, tintB, tintA], record i at i * FLOATS_PER_SPRITE. Record order
63
- * is draw order - unless the layer was created with `orderBy`, which
64
- * draws in key order while record i keeps meaning sprite i. Write fields
65
- * directly for large per-frame populations, then call touch() once. Do
66
- * not cache indices across destroySprite - records shift - and do not
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 one atlas texture (the atlas is NOT owned).
84
- * Like createSpriteLayer it renders nothing by itself: `createView` is
85
- * where it shows. Disposed automatically with the owning reactive scope
86
- * (opt out with `{ autoFree: false }`).
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(atlas: TextureId, opts?: RecordLayerOptions): RecordLayer {
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 records: BufferId = createBuffer(capacity * FLOATS_PER_SPRITE * 4, {
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 gpu = createSpritePipeline(label, VERTEX, [INSTANCE_ATTRIBUTES])
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 canonical array grows ahead of it
115
- // (addSprite) and the publish catches the buffer up: a larger buffer is
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 || !dirty) return
123
- dirty = false
124
- let count = layer._order.length
125
- let grown: BufferId | null = null
126
- if (layer.records.length > gpuCapacity * FLOATS_PER_SPRITE) {
127
- gpuCapacity = layer.records.length / FLOATS_PER_SPRITE
128
- grown = createBuffer(layer.records.length * 4, { label: `${label}-records`, autoFree: false })
129
- }
130
- let target = grown ?? records
131
- let out = beginBufferWrite(target)
132
- out.set(layer.records.subarray(0, count * FLOATS_PER_SPRITE))
133
- endBufferWrite(target, count * FLOATS_PER_SPRITE * 4)
134
- if (grown !== null) {
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 (instanceOrder !== undefined) {
138
- // The growth publish above landed BEFORE the swap (the entry must
139
- // never point at an unwritten buffer), so the order had not yet
140
- // followed to the grown buffer and that publish went out ungathered.
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
- let again = beginBufferWrite(grown)
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(records)
148
- records = grown
257
+ destroyBuffer(buffer)
258
+ buffer = grown
149
259
  published = count
150
- } else if (count !== published) {
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
- // Record layout: [cx, cy, w, h, u0, v0, u1, v1, rot, tintR, tintG, tintB, tintA]
157
- let writeRecord = (sprite: SpriteState, opts: SpriteOptions) => {
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("setSprite: record layers have no renderOrder field; order by a record field with orderBy { field }")
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("setSprite: record sprites have no visibility; hide by zeroing w or h")
288
+ throw new Error(`${verb}: record sprites have no visibility; hide by zeroing w or h`)
163
289
  }
164
- let at = sprite._slot * FLOATS_PER_SPRITE
165
- let r = layer.records
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
- atlas,
195
- buffers: () => [records],
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, exact rotated-rect containment.
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.records
220
- for (let i = layer._order.length - 1; i >= 0; i--) {
221
- let at = i * FLOATS_PER_SPRITE
222
- if (pointInSprite(x, y, r[at]!, r[at + 1]!, r[at + 2]!, r[at + 3]!, r[at + 8]!)) {
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(records)
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) * FLOATS_PER_SPRITE > layer.records.length) {
243
- let next = new Float32Array(layer.records.length * 2)
244
- next.set(layer.records)
245
- layer.records = next
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
- x: 0,
251
- y: 0,
252
- w: 0,
253
- h: 0,
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
- writeRecord(sprite, opts)
264
- layer._schedule()
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 * FLOATS_PER_SPRITE
268
- let r = layer.records
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.records
291
- r.copyWithin(index * FLOATS_PER_SPRITE, (index + 1) * FLOATS_PER_SPRITE, order.length * FLOATS_PER_SPRITE)
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
- layer._schedule()
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
- records: new Float32Array(capacity * FLOATS_PER_SPRITE),
304
- withRecords(fn) {
305
- return fn(layer.records)
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())