@solidrt/2d 0.0.56 → 0.0.58

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/tiles.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // never a quad per tile. On tiled GPUs the budget is primitive count, so a
4
4
  // 100x100 world must not be 10,000 quads per frame; baked, it is a few
5
5
  // chunk textures, and scrolling is a transform on the composited world
6
- // (see <TileLayer> in components.tsx), never a repaint.
6
+ // (see <TileLayer> in components/tile-layer.tsx), never a repaint.
7
7
  //
8
8
  // Each chunk is a small copy of the sprite pipeline (shaders.ts) with fixed
9
9
  // record slots - record localRow * chunkTiles + localCol IS that tile, an
@@ -15,11 +15,11 @@
15
15
  // Chunks allocate lazily on the first setTile that gives them content: an
16
16
  // empty chunk costs nothing - no records, no buffer, no texture - so a
17
17
  // sparse world is bounded by its content, and world size is bounded by
18
- // memory, not maxTextureSize. setTile batches to a microtask; the flush
19
- // publishes and re-bakes ONLY dirty chunks. A layer nobody edits publishes
20
- // nothing, renders nothing, and costs nothing per frame. Camera-driven
21
- // residency (bake far chunks on approach, evict them) is deliberately not
22
- // here yet - see okf/backlog/2d-baked-layers.md.
18
+ // memory, not maxTextureSize. setTile and setTiles batch to a microtask;
19
+ // the flush publishes and re-bakes ONLY dirty chunks. A layer nobody edits
20
+ // publishes nothing, renders nothing, and costs nothing per frame.
21
+ // Camera-driven residency (bake far chunks on approach, evict them) is
22
+ // deliberately not here yet - see okf/backlog/2d-baked-layers.md.
23
23
  import { getOwner, onCleanup } from "@solidrt/core"
24
24
  import {
25
25
  beginBufferWrite,
@@ -44,6 +44,19 @@ const RESOLVED = Promise.resolve()
44
44
 
45
45
  // Default chunk edge in pixels; the tile count per chunk derives from it.
46
46
  const CHUNK_TARGET_PX = 512
47
+ // The index that clears a cell in setTiles' index form: -1, which a
48
+ // Uint16Array holds as 0xffff (the same bits), so both spellings mean it
49
+ // and a frames table stops one short of it.
50
+ const CLEAR_INDEX = 0xffff
51
+
52
+ export type Tint = [number, number, number, number]
53
+
54
+ // Whether a value is a frame: four numeric UVs (the validation both write
55
+ // paths run before touching a record).
56
+ let isFrame = (f: unknown): f is Frame => {
57
+ let o = f as Frame | null
58
+ return typeof o === "object" && o !== null && typeof o.u0 === "number" && typeof o.v0 === "number" && typeof o.u1 === "number" && typeof o.v1 === "number"
59
+ }
47
60
 
48
61
  export type TileLayerOptions = {
49
62
  /** Per-chunk clear color (the name says the scope: chunks that never
@@ -71,6 +84,13 @@ export type TileLayerOptions = {
71
84
  chunkTiles?: number
72
85
  /** Layer tint, [r, g, b, a] in 0..1; see TileLayer.setTint. */
73
86
  tint?: [number, number, number, number]
87
+ /**
88
+ * The frames table setTiles' index cells name - a tileset: `grid()`'s
89
+ * array, or any Frame[] (up to 65535 entries), so a generated world is
90
+ * a Uint16Array of indices and never an array of frame objects. Fixed
91
+ * at creation; setTile takes frames directly either way.
92
+ */
93
+ frames?: Frame[]
74
94
  label?: string
75
95
  /** Skip the owner-scoped auto-dispose (see createTileLayer). */
76
96
  autoFree?: boolean
@@ -126,9 +146,25 @@ export type TileLayer = {
126
146
  * Batched; the microtask flush publishes and re-bakes ONLY the chunks
127
147
  * that changed, however many tiles did.
128
148
  */
129
- setTile(col: number, row: number, frame: Frame | null, opts?: { tint?: [number, number, number, number] }): void
149
+ setTile(col: number, row: number, frame: Frame | null, opts?: { tint?: Tint }): void
150
+ /**
151
+ * Set a rect of cells at once: `cols` x `rows` cells from `col`, `row`,
152
+ * `cells` in row-major order (`cols * rows` long) - frames (null clears)
153
+ * or, with a `frames` table on the layer, indices into it (-1 clears; a
154
+ * Uint16Array holds -1 as 0xffff, the same bits). Unity's SetTilesBlock,
155
+ * Phaser's putTilesAt: one locate and one dirty mark per chunk the rect
156
+ * touches, the per-cell loop inside the layer over its own records, the
157
+ * same batching and flush. A chunk the rect only clears never allocates,
158
+ * so a whole-world write of a sparse world stays sparse. `tint` applies
159
+ * to every cell set, as setTile's; absent, each cell keeps its own. A
160
+ * bad entry throws before any cell changes.
161
+ */
162
+ setTiles(col: number, row: number, cols: number, rows: number, cells: ArrayLike<Frame | null> | ArrayLike<number>, opts?: { tint?: Tint }): void
130
163
  /** The frame at a cell, or null when empty. */
131
164
  getTile(col: number, row: number): Frame | null
165
+ /** The frames table given at creation (what setTiles' indices name), or
166
+ * null. */
167
+ readonly frames: readonly Frame[] | null
132
168
  /**
133
169
  * Tint the whole layer, [r, g, b, a] in 0..1: a uniform multiplied over
134
170
  * every cell's own tint (day/night, a dimmed parallax plane). Not a
@@ -186,8 +222,27 @@ export function createTileLayer(
186
222
  )
187
223
  }
188
224
  let label = opts?.label ?? "tiles"
189
- let tint: [number, number, number, number] = opts?.tint ?? [1, 1, 1, 1]
225
+ let tint: Tint = opts?.tint ?? [1, 1, 1, 1]
190
226
  checkTint("createTileLayer", tint)
227
+ // The frames table, copied (the caller's array may move on) and checked
228
+ // once, plus its UVs as four floats per entry: an index write copies by
229
+ // offset instead of reading a frame object per cell.
230
+ let frames: Frame[] | null = opts?.frames ? opts.frames.slice() : null
231
+ let tableUv: Float32Array | null = null
232
+ if (frames !== null) {
233
+ if (frames.length >= CLEAR_INDEX) {
234
+ throw new Error(`createTileLayer: a frames table holds at most ${CLEAR_INDEX - 1} frames, got ${frames.length}`)
235
+ }
236
+ tableUv = new Float32Array(frames.length * 4)
237
+ for (let i = 0; i < frames.length; i++) {
238
+ let f = frames[i]!
239
+ if (!isFrame(f)) throw new Error(`createTileLayer: frames[${i}] is not a frame, got ${JSON.stringify(f)}`)
240
+ tableUv[i * 4] = f.u0
241
+ tableUv[i * 4 + 1] = f.v0
242
+ tableUv[i * 4 + 2] = f.u1
243
+ tableUv[i * 4 + 3] = f.v1
244
+ }
245
+ }
191
246
  let oversample = opts?.oversample ?? 1
192
247
  checkOversample("createTileLayer", oversample, chunkW, chunkH)
193
248
  let thrash = thrashSentinel(`tile layer "${label}"`)
@@ -269,13 +324,45 @@ export function createTileLayer(
269
324
  return chunk
270
325
  }
271
326
 
272
- let locate = (col: number, row: number, verb: string): [number, number] => {
327
+ let checkCell = (col: number, row: number, verb: string): void => {
273
328
  if (!(Number.isInteger(col) && Number.isInteger(row) && col >= 0 && col < cols && row >= 0 && row < rows)) {
274
329
  throw new Error(`${verb}: cell ${col}, ${row} outside the ${cols} x ${rows} grid`)
275
330
  }
276
- let index = Math.floor(row / chunkTiles) * chunkCols + Math.floor(col / chunkTiles)
277
- let at = ((row % chunkTiles) * chunkTiles + (col % chunkTiles)) * FLOATS_PER_SPRITE
278
- return [index, at]
331
+ }
332
+ // A cell's chunk index, and its record offset inside that chunk.
333
+ let chunkOf = (col: number, row: number): number => Math.floor(row / chunkTiles) * chunkCols + Math.floor(col / chunkTiles)
334
+ let slot = (col: number, row: number): number => ((row % chunkTiles) * chunkTiles + (col % chunkTiles)) * FLOATS_PER_SPRITE
335
+ // Write one cell's record: the quad at the cell, the frame's UVs, and
336
+ // the tint - the one given, else the default when the cell comes up from
337
+ // empty (a re-set keeps its tint: absent keys keep their values, like
338
+ // every options object here).
339
+ let writeCell = (r: Float32Array, at: number, col: number, row: number, u0: number, v0: number, u1: number, v1: number, cellTint: Tint | undefined): void => {
340
+ let fresh = r[at + 2] === 0
341
+ r[at] = (col + 0.5) * tileW
342
+ r[at + 1] = (row + 0.5) * tileH
343
+ r[at + 2] = tileW
344
+ r[at + 3] = tileH
345
+ r[at + 4] = u0
346
+ r[at + 5] = v0
347
+ r[at + 6] = u1
348
+ r[at + 7] = v1
349
+ if (cellTint !== undefined) {
350
+ r[at + 9] = cellTint[0]
351
+ r[at + 10] = cellTint[1]
352
+ r[at + 11] = cellTint[2]
353
+ r[at + 12] = cellTint[3]
354
+ } else if (fresh) {
355
+ r[at + 9] = 1
356
+ r[at + 10] = 1
357
+ r[at + 11] = 1
358
+ r[at + 12] = 1
359
+ }
360
+ }
361
+ // Clear one cell: a zero-size quad draws nothing (the tint stays, and
362
+ // a later set from empty resets it).
363
+ let clearCell = (r: Float32Array, at: number): void => {
364
+ r[at + 2] = 0
365
+ r[at + 3] = 0
279
366
  }
280
367
 
281
368
  let layer: TileLayer = {
@@ -306,50 +393,116 @@ export function createTileLayer(
306
393
  },
307
394
  setTile(col, row, frame, opts) {
308
395
  if (disposed) return
309
- let [index, at] = locate(col, row, "setTile")
396
+ checkCell(col, row, "setTile")
397
+ let index = chunkOf(col, row)
398
+ let at = slot(col, row)
310
399
  let chunk = resident.get(index)
311
400
  if (frame === null) {
312
401
  // Clearing a cell no chunk holds is a no-op, not an allocation.
313
402
  if (!chunk) return
314
- chunk.records[at + 2] = 0
315
- chunk.records[at + 3] = 0
403
+ clearCell(chunk.records, at)
316
404
  } else {
405
+ if (!isFrame(frame)) throw new Error(`setTile: not a frame, got ${JSON.stringify(frame)}`)
406
+ if (opts?.tint !== undefined) checkTint("setTile", opts.tint)
317
407
  chunk ??= allocate(index)
318
- let r = chunk.records
319
- // A cell coming up from empty starts at the default tint; a
320
- // re-set keeps its tint unless opts carries one (absent keys keep
321
- // their values, like every options object here).
322
- let fresh = r[at + 2] === 0
323
- r[at] = (col + 0.5) * tileW
324
- r[at + 1] = (row + 0.5) * tileH
325
- r[at + 2] = tileW
326
- r[at + 3] = tileH
327
- r[at + 4] = frame.u0
328
- r[at + 5] = frame.v0
329
- r[at + 6] = frame.u1
330
- r[at + 7] = frame.v1
331
- if (opts?.tint !== undefined) {
332
- checkTint("setTile", opts.tint)
333
- r[at + 9] = opts.tint[0]
334
- r[at + 10] = opts.tint[1]
335
- r[at + 11] = opts.tint[2]
336
- r[at + 12] = opts.tint[3]
337
- } else if (fresh) {
338
- r[at + 9] = 1
339
- r[at + 10] = 1
340
- r[at + 11] = 1
341
- r[at + 12] = 1
342
- }
408
+ writeCell(chunk.records, at, col, row, frame.u0, frame.v0, frame.u1, frame.v1, opts?.tint)
343
409
  }
344
410
  touch(chunk)
345
411
  },
412
+ setTiles(col, row, w, h, cells, opts) {
413
+ if (disposed) return
414
+ if (!(Number.isInteger(col) && Number.isInteger(row) && Number.isInteger(w) && Number.isInteger(h) && w > 0 && h > 0 && col >= 0 && row >= 0 && col + w <= cols && row + h <= rows)) {
415
+ throw new Error(`setTiles: rect ${col}, ${row} of ${w} x ${h} outside the ${cols} x ${rows} grid`)
416
+ }
417
+ if (cells.length !== w * h) throw new Error(`setTiles: a ${w} x ${h} rect takes ${w * h} cells, got ${cells.length}`)
418
+ if (opts?.tint !== undefined) checkTint("setTiles", opts.tint)
419
+ let cellTint = opts?.tint
420
+ // The two forms: indices into the table, or frames. Every entry is
421
+ // checked before the first write, so a bad one changes nothing.
422
+ let idx = typeof cells[0] === "number" ? (cells as ArrayLike<number>) : null
423
+ let objs = idx === null ? (cells as ArrayLike<Frame | null>) : null
424
+ if (idx !== null) {
425
+ if (frames === null) throw new Error("setTiles: index cells need a frames table (createTileLayer's frames option)")
426
+ let count = frames.length
427
+ for (let i = 0; i < idx.length; i++) {
428
+ let k = idx[i]!
429
+ if (k === -1 || k === CLEAR_INDEX) continue
430
+ if (!(Number.isInteger(k) && k >= 0 && k < count)) throw new Error(`setTiles: index ${k} at cell ${i} outside the ${count}-frame table`)
431
+ }
432
+ } else {
433
+ for (let i = 0; i < objs!.length; i++) {
434
+ let f = objs![i]
435
+ if (f !== null && f !== undefined && !isFrame(f)) throw new Error(`setTiles: cell ${i} is neither a frame nor null, got ${JSON.stringify(f)}`)
436
+ }
437
+ }
438
+ let uv = tableUv
439
+ // Chunk by chunk over the rect: each chunk's slice of the rect is a
440
+ // sub-rect walked row by row with the record offset advancing, one
441
+ // dirty mark at the end. A chunk the slice only clears is never
442
+ // allocated.
443
+ let cr0 = Math.floor(row / chunkTiles)
444
+ let cr1 = Math.floor((row + h - 1) / chunkTiles)
445
+ let cc0 = Math.floor(col / chunkTiles)
446
+ let cc1 = Math.floor((col + w - 1) / chunkTiles)
447
+ for (let cr = cr0; cr <= cr1; cr++) {
448
+ let rowA = Math.max(row, cr * chunkTiles)
449
+ let rowB = Math.min(row + h, (cr + 1) * chunkTiles)
450
+ for (let cc = cc0; cc <= cc1; cc++) {
451
+ let colA = Math.max(col, cc * chunkTiles)
452
+ let colB = Math.min(col + w, (cc + 1) * chunkTiles)
453
+ let index = cr * chunkCols + cc
454
+ let chunk = resident.get(index)
455
+ let r = chunk ? chunk.records : null
456
+ for (let y = rowA; y < rowB; y++) {
457
+ let at = slot(colA, y)
458
+ let i = (y - row) * w + (colA - col)
459
+ for (let x = colA; x < colB; x++, at += FLOATS_PER_SPRITE, i++) {
460
+ let u0: number
461
+ let v0: number
462
+ let u1: number
463
+ let v1: number
464
+ if (idx !== null) {
465
+ let k = idx[i]!
466
+ if (k === -1 || k === CLEAR_INDEX) {
467
+ if (r !== null) clearCell(r, at)
468
+ continue
469
+ }
470
+ let b = k * 4
471
+ u0 = uv![b]!
472
+ v0 = uv![b + 1]!
473
+ u1 = uv![b + 2]!
474
+ v1 = uv![b + 3]!
475
+ } else {
476
+ let f = objs![i]
477
+ if (f === null || f === undefined) {
478
+ if (r !== null) clearCell(r, at)
479
+ continue
480
+ }
481
+ u0 = f.u0
482
+ v0 = f.v0
483
+ u1 = f.u1
484
+ v1 = f.v1
485
+ }
486
+ if (r === null) {
487
+ chunk = allocate(index)
488
+ r = chunk.records
489
+ }
490
+ writeCell(r, at, x, y, u0, v0, u1, v1, cellTint)
491
+ }
492
+ }
493
+ if (chunk) touch(chunk)
494
+ }
495
+ }
496
+ },
346
497
  getTile(col, row) {
347
- let [index, at] = locate(col, row, "getTile")
348
- let chunk = resident.get(index)
498
+ checkCell(col, row, "getTile")
499
+ let chunk = resident.get(chunkOf(col, row))
500
+ let at = slot(col, row)
349
501
  if (!chunk || chunk.records[at + 2] === 0) return null
350
502
  let r = chunk.records
351
503
  return { u0: r[at + 4]!, v0: r[at + 5]!, u1: r[at + 6]!, v1: r[at + 7]! }
352
504
  },
505
+ frames,
353
506
  setTint(next) {
354
507
  if (disposed) return
355
508
  checkTint("setTint", next)
package/src/views.ts ADDED
@@ -0,0 +1,294 @@
1
+ // A layer's views: every rendering of a layer's world is a view - one
2
+ // draw target holding ONE entry over the layer's pipeline and instance
3
+ // buffers, with a camera and a viewport of its own - and a layer has no
4
+ // output but its views (Unity's scene renders only through Cameras,
5
+ // Godot's World2D only through Viewports; @solidrt/3d's scene.createView
6
+ // one dimension down). The window-filling main view, the minimap, the
7
+ // radar strip, the zoomed inset, two split-screen panes: all the same
8
+ // object. No sprite is mirrored, no record is written twice, and the
9
+ // core's key-order gather (the entry's instanceOrder) happens in the
10
+ // buffers at publish: ONE entry per buffer declares the order - the first
11
+ // live view's, re-homed to the next when that view goes - and every other
12
+ // entry reads the buffers already sorted. The layer fans buffer swaps
13
+ // (growth), the instance count and the tint out to every view through
14
+ // the registry below; the camera and the viewport are each view's own
15
+ // params, so a view costs no per-frame JS beyond its camera writes.
16
+ // Pointer events run the layer's dispatch (dispatch.ts) with the view's
17
+ // camera undone over the layer's pick, the view as the root of the walk:
18
+ // a sprite under a minimap gets its ordinary handlers, and the view's
19
+ // listeners are the last stop.
20
+ import { addDraw, createDrawTarget, destroyTexture, removeDraw, setDrawBuffers, setDrawRange, setTargetParams, setTargetSize } from "@solidrt/core/gpu"
21
+ import type { BufferId, BufferUpdate, DrawId, InstanceOrder, RenderPipelineId, TextureId } from "@solidrt/core/gpu"
22
+ import { applyCamera, cameraParams, checkCamera, defaultCamera, projectCamera, unprojectCamera } from "./camera.ts"
23
+ import type { CameraState, CameraUpdate } from "./camera.ts"
24
+ import { spriteDispatch } from "./dispatch.ts"
25
+ import type { PointerFeed } from "@solidrt/core"
26
+ import type { LayerPointerListener, Sprite, SpriteHandlers } from "./layer.ts"
27
+ import { checkOversample, thrashSentinel } from "./oversample.ts"
28
+
29
+ export type ViewOptions = {
30
+ /** View pixels: the viewport its camera maps the world onto. */
31
+ width: number
32
+ height: number
33
+ /** The camera to start from (a setCamera update); default the identity. */
34
+ camera?: CameraUpdate
35
+ /** Target texels per view pixel (see ViewHandle.setOversample); default 1. */
36
+ oversample?: number
37
+ clearColor?: [number, number, number, number]
38
+ label?: string
39
+ }
40
+
41
+ /**
42
+ * A view of a layer (LayerBase.createView): the viewport contract -
43
+ * texture, size, oversample, camera and pointer dispatch - over the
44
+ * layer's sprites, with a camera of its own. The sprites are the layer's:
45
+ * add, write and pick through the layer, tint through the layer (every
46
+ * view follows it). `dispose` is idempotent; views also die with their
47
+ * layer.
48
+ */
49
+ export type ViewHandle = {
50
+ /** The view's output: an ordinary texture id (`<texture src>`). */
51
+ texture: TextureId
52
+ /** Element handlers wiring the view's pointer events (sprites, groups
53
+ * and the root listeners); see handlersFor. */
54
+ handlers: SpriteHandlers
55
+ /** View pixels, as created or last set by setSize. */
56
+ readonly width: number
57
+ readonly height: number
58
+ setSize(width: number, height: number): void
59
+ /**
60
+ * Listen at the root of the event walk. Every down, move, up, wheel and
61
+ * tap arrives here after the hit sprite and its enclosing groups
62
+ * (`sprite` set) or as the walk's only stop over empty space (`sprite`
63
+ * null), unless a handler stopped it on the way. Listeners run in
64
+ * registration order and all of them run - the root is the last stop,
65
+ * there is nothing left to claim. Returns the remover. The app's own
66
+ * root handling (deselect on a miss, a marquee) and controls
67
+ * (createCamera2d's attach) meet here, which is why the root is a list
68
+ * where a sprite has plain fields.
69
+ */
70
+ listen(listener: LayerPointerListener): () => void
71
+ /** Target texels per view pixel; see setOversample. */
72
+ readonly oversample: number
73
+ /**
74
+ * Re-render at `n` target texels per view pixel (positive integer): the
75
+ * target resizes in place at its stable id; view pixels, records, camera
76
+ * and picking are untouched. Pick `n` as the ceiling of the device pixels
77
+ * one view pixel covers on screen (display scale times any designSize
78
+ * fit or layout scaling), which the components do in onLayout.
79
+ */
80
+ setOversample(n: number): void
81
+ setCamera(update: CameraUpdate): void
82
+ /** The camera as last set (a fresh object per call, every field
83
+ * present): the argument for projectCamera/unprojectCamera -
84
+ * @solidrt/3d's scene.camera(). */
85
+ camera(): CameraState
86
+ /** World (layer) pixels -> view pixels under the current camera:
87
+ * projectCamera over camera(). */
88
+ project(x: number, y: number): [number, number]
89
+ /** View pixels -> world (layer) pixels, the inverse: what pointer
90
+ * dispatch applies to every event. */
91
+ unproject(x: number, y: number): [number, number]
92
+ /**
93
+ * handlers for a leaf whose LAYOUT size differs from the view size
94
+ * (events scale by view/layout; a leaf laid out AT view size just uses
95
+ * `handlers`). `layout` is read per event, so a resize-reactive layout
96
+ * just works - @solidrt/3d's handlersFor, one dimension down.
97
+ */
98
+ handlersFor(layout: () => { width: number; height: number }): SpriteHandlers
99
+ dispose(): void
100
+ }
101
+
102
+ // What a layer hands its views: the pipeline and quad every entry draws
103
+ // with, the atlas, the key order one entry declares, and live reads of
104
+ // the state the layer fans out.
105
+ export type ViewDeps = {
106
+ label: string
107
+ pipeline: RenderPipelineId
108
+ quad: BufferId
109
+ atlas: TextureId
110
+ /** The layer's current instance binding, in the entry's own spelling
111
+ * (`instanceBuffers` on the node layer, `instanceBuffer` on records). */
112
+ buffers: () => BufferUpdate
113
+ /** The instance count as last published. */
114
+ count: () => number
115
+ tint: () => [number, number, number, number]
116
+ pick: (x: number, y: number) => Sprite[]
117
+ /** The layer's key order (`orderBy`), declared by ONE live entry. */
118
+ order?: InstanceOrder
119
+ }
120
+
121
+ /** The layer side of its views: create, and fan out what changes. */
122
+ export type Views = {
123
+ create(opts: ViewOptions): ViewHandle
124
+ /** The layer swapped its instance buffers (growth): every entry
125
+ * follows. */
126
+ setBuffers(update: BufferUpdate): void
127
+ setCount(count: number): void
128
+ setTint(tint: [number, number, number, number]): void
129
+ dispose(): void
130
+ }
131
+
132
+ type ViewRecord = { texture: TextureId; entry: DrawId; dispose(): void }
133
+
134
+ export function createViews(deps: ViewDeps): Views {
135
+ let views = new Set<ViewRecord>()
136
+ // The one entry declaring the key order (null until a view exists, or
137
+ // while the layer has no order).
138
+ let ordered: ViewRecord | null = null
139
+ let entryFor = (texture: TextureId, order: InstanceOrder | undefined): DrawId =>
140
+ addDraw(texture, deps.pipeline, null, {
141
+ buffer: deps.quad,
142
+ vertexCount: 4,
143
+ ...deps.buffers(),
144
+ instanceOrder: order,
145
+ instanceCount: deps.count(),
146
+ })
147
+ // The ordered view went: the next live view re-adds its entry with the
148
+ // order, so key order survives any one view's lifetime.
149
+ let rehome = () => {
150
+ if (deps.order === undefined) return
151
+ let next = views.values().next().value
152
+ if (!next) return
153
+ removeDraw(next.texture, next.entry)
154
+ next.entry = entryFor(next.texture, deps.order)
155
+ ordered = next
156
+ }
157
+ return {
158
+ create(opts) {
159
+ let width = opts.width
160
+ let height = opts.height
161
+ if (!(Number.isInteger(width) && width > 0 && Number.isInteger(height) && height > 0)) {
162
+ throw new Error(`createView: width and height must be positive integers, got ${width} x ${height}`)
163
+ }
164
+ let oversample = opts.oversample ?? 1
165
+ checkOversample("createView", oversample, width, height)
166
+ let label = opts.label ?? `${deps.label}-view`
167
+ let cam = defaultCamera()
168
+ if (opts.camera) {
169
+ checkCamera(opts.camera)
170
+ applyCamera(cam, opts.camera)
171
+ }
172
+ let texture = createDrawTarget(
173
+ width * oversample,
174
+ height * oversample,
175
+ { uViewport: [width, height], ...cameraParams(cam), uTint: deps.tint() },
176
+ { textures: { uAtlas: deps.atlas }, clearColor: opts.clearColor ?? [0, 0, 0, 0], label, autoFree: false },
177
+ )
178
+ let order = ordered === null ? deps.order : undefined
179
+ let entry = entryFor(texture, order)
180
+ let thrash = thrashSentinel(`view "${label}"`)
181
+ let disposed = false
182
+ let listeners = new Set<LayerPointerListener>()
183
+ let record: ViewRecord = {
184
+ texture,
185
+ entry,
186
+ dispose() {
187
+ if (disposed) return
188
+ disposed = true
189
+ listeners.clear()
190
+ views.delete(record)
191
+ // The entry dies with its target, releasing the buffers' order.
192
+ destroyTexture(texture)
193
+ if (ordered === record) {
194
+ ordered = null
195
+ rehome()
196
+ }
197
+ },
198
+ }
199
+ if (order !== undefined) ordered = record
200
+ let view: ViewHandle = {
201
+ texture,
202
+ handlers: undefined as unknown as SpriteHandlers,
203
+ get width() {
204
+ return width
205
+ },
206
+ get height() {
207
+ return height
208
+ },
209
+ setSize(w, h) {
210
+ if (disposed || (w === width && h === height)) return
211
+ checkOversample("setSize", oversample, w, h)
212
+ width = w
213
+ height = h
214
+ setTargetSize(texture, w * oversample, h * oversample)
215
+ setTargetParams(texture, { uViewport: [w, h] })
216
+ },
217
+ listen(listener) {
218
+ listeners.add(listener)
219
+ return () => {
220
+ listeners.delete(listener)
221
+ }
222
+ },
223
+ get oversample() {
224
+ return oversample
225
+ },
226
+ setOversample(n) {
227
+ if (disposed || n === oversample) return
228
+ checkOversample("setOversample", n, width, height)
229
+ thrash()
230
+ oversample = n
231
+ setTargetSize(texture, width * n, height * n)
232
+ },
233
+ setCamera(update) {
234
+ if (disposed) return
235
+ checkCamera(update)
236
+ applyCamera(cam, update)
237
+ setTargetParams(texture, cameraParams(cam))
238
+ },
239
+ camera() {
240
+ return { ...cam }
241
+ },
242
+ project(x, y) {
243
+ return projectCamera(cam, x, y)
244
+ },
245
+ unproject(x, y) {
246
+ return unprojectCamera(cam, x, y)
247
+ },
248
+ handlersFor(layout) {
249
+ return dispatch(layout)
250
+ },
251
+ dispose: record.dispose,
252
+ }
253
+ let dispatch = spriteDispatch({
254
+ size: () => [width, height],
255
+ camera: () => cam,
256
+ pick: deps.pick,
257
+ root: view,
258
+ listeners,
259
+ })
260
+ view.handlers = dispatch(null)
261
+ views.add(record)
262
+ return view
263
+ },
264
+ setBuffers(update) {
265
+ for (let v of views) setDrawBuffers(v.texture, v.entry, update)
266
+ },
267
+ setCount(count) {
268
+ for (let v of views) setDrawRange(v.texture, v.entry, { instanceCount: count })
269
+ },
270
+ setTint(tint) {
271
+ for (let v of views) setTargetParams(v.texture, { uTint: tint })
272
+ },
273
+ dispose() {
274
+ for (let v of [...views]) v.dispose()
275
+ },
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Feed a pointer feed (createPointerFeed) from a view's root: the events
281
+ * the sprites let through - a sprite that claims its press
282
+ * (stopPropagation on its down) keeps a camera bound to the feed out of
283
+ * that drag, a drag on empty space or an unclaimed sprite pans, a wheel
284
+ * anywhere zooms. Returns the detach. The components do this for their
285
+ * `pointer` prop; an imperative view calls it directly.
286
+ */
287
+ export function feedPointer(view: ViewHandle, feed: PointerFeed): () => void {
288
+ return view.listen({
289
+ onPointerDown: e => feed.handlers.onPointerDown(e.native),
290
+ onPointerMove: e => feed.handlers.onPointerMove(e.native),
291
+ onPointerUp: e => feed.handlers.onPointerUp(e.native),
292
+ onWheel: e => feed.handlers.onWheel(e.native),
293
+ })
294
+ }