@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/AGENTS.md +560 -267
- package/README.md +3 -3
- package/examples/README.md +38 -13
- package/examples/anim.tsx +6 -8
- package/examples/camera-probe.tsx +16 -14
- package/examples/camera.tsx +21 -15
- package/examples/parity.tsx +11 -9
- package/examples/pick.tsx +43 -7
- package/examples/split-screen.tsx +111 -0
- package/examples/springs.tsx +6 -9
- package/examples/sprites.tsx +9 -12
- package/examples/tiles.tsx +24 -9
- package/examples/views.tsx +177 -0
- package/package.json +2 -2
- package/src/atlas.ts +35 -33
- package/src/camera.ts +26 -1
- package/src/camera2d.ts +692 -119
- package/src/components/auto-oversample.ts +27 -0
- package/src/components/camera2d.tsx +82 -0
- package/src/components/context.ts +48 -0
- package/src/components/group.tsx +68 -0
- package/src/components/index.ts +28 -0
- package/src/components/sprite-layer.tsx +312 -0
- package/src/components/sprite.tsx +72 -0
- package/src/components/tile-layer.tsx +160 -0
- package/src/components/view2d.tsx +151 -0
- package/src/dispatch.ts +7 -7
- package/src/frames.ts +63 -14
- package/src/index.ts +27 -11
- package/src/input.ts +50 -0
- package/src/layer.ts +384 -245
- package/src/oversample-math.ts +1 -1
- package/src/records.ts +33 -138
- package/src/shaders.ts +65 -3
- package/src/tiles.ts +195 -42
- package/src/views.ts +294 -0
- package/src/camera-motion.ts +0 -532
- package/src/components.tsx +0 -611
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
|
|
19
|
-
// publishes and re-bakes ONLY dirty chunks. A layer nobody edits
|
|
20
|
-
// nothing, renders nothing, and costs nothing per frame.
|
|
21
|
-
// residency (bake far chunks on approach, evict them) is
|
|
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?:
|
|
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:
|
|
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
|
|
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
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
348
|
-
let chunk = resident.get(
|
|
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
|
+
}
|