@solidrt/2d 0.0.55 → 0.0.57

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,
@@ -30,11 +30,13 @@ import {
30
30
  endBufferWrite,
31
31
  limits,
32
32
  renderTarget,
33
+ setTargetParams,
33
34
  setTargetSize,
34
35
  } from "@solidrt/core/gpu"
35
36
  import type { BufferId, FilterMode, TextureId } from "@solidrt/core/gpu"
36
37
  import type { Frame } from "./frames.ts"
37
- import { checkOversample } from "./oversample.ts"
38
+ import { checkTint } from "./layer.ts"
39
+ import { checkOversample, thrashSentinel } from "./oversample.ts"
38
40
  import { FLOATS_PER_SPRITE } from "./records.ts"
39
41
  import { FRAGMENT, INSTANCE_ATTRIBUTES, VERTEX } from "./shaders.ts"
40
42
 
@@ -42,10 +44,24 @@ const RESOLVED = Promise.resolve()
42
44
 
43
45
  // Default chunk edge in pixels; the tile count per chunk derives from it.
44
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
+ }
45
60
 
46
61
  export type TileLayerOptions = {
47
- /** Per-chunk clear color; empty (never-written) chunks render nothing. */
48
- clearColor?: [number, number, number, number]
62
+ /** Per-chunk clear color (the name says the scope: chunks that never
63
+ * allocate render nothing, so this is not a layer background). */
64
+ chunkClearColor?: [number, number, number, number]
49
65
  /**
50
66
  * Sampler filter for the baked chunk textures at composite time; default
51
67
  * "linear", which with `oversample` is the proper resample at any scale.
@@ -66,6 +82,15 @@ export type TileLayerOptions = {
66
82
  * chunk count (larger = fewer textures and leaves).
67
83
  */
68
84
  chunkTiles?: number
85
+ /** Layer tint, [r, g, b, a] in 0..1; see TileLayer.setTint. */
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[]
69
94
  label?: string
70
95
  /** Skip the owner-scoped auto-dispose (see createTileLayer). */
71
96
  autoFree?: boolean
@@ -114,13 +139,40 @@ export type TileLayer = {
114
139
  /** Called after a chunk allocates - the composition hook. Assignable. */
115
140
  onChunk?: (chunk: TileChunk) => void
116
141
  /**
117
- * Set one cell: a frame draws it, null clears it. Batched; the microtask
118
- * flush publishes and re-bakes ONLY the chunks that changed, however
119
- * many tiles did.
142
+ * Set one cell: a frame draws it, null clears it (a clear ignores `opts` -
143
+ * an absent cell has no tint, it draws nothing). `tint` multiplies the
144
+ * cell's sampled texels like a sprite's tint; absent it keeps the cell's
145
+ * current tint (a cell being set from empty starts at [1, 1, 1, 1]).
146
+ * Batched; the microtask flush publishes and re-bakes ONLY the chunks
147
+ * that changed, however many tiles did.
148
+ */
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.
120
161
  */
121
- setTile(col: number, row: number, frame: Frame | null): void
162
+ setTiles(col: number, row: number, cols: number, rows: number, cells: ArrayLike<Frame | null> | ArrayLike<number>, opts?: { tint?: Tint }): void
122
163
  /** The frame at a cell, or null when empty. */
123
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
168
+ /**
169
+ * Tint the whole layer, [r, g, b, a] in 0..1: a uniform multiplied over
170
+ * every cell's own tint (day/night, a dimmed parallax plane). Not a
171
+ * record write - resident chunks re-render GPU-side with nothing
172
+ * re-uploaded - but each write does re-render every resident chunk, so
173
+ * animate it coarsely, not per frame.
174
+ */
175
+ setTint(tint: [number, number, number, number]): void
124
176
  dispose(): void
125
177
  }
126
178
 
@@ -128,15 +180,21 @@ type Chunk = TileChunk & {
128
180
  records: Float32Array
129
181
  buffer: BufferId
130
182
  dirty: boolean
183
+ /** Records changed since the last flush (a tint-only or resize re-bake
184
+ * renders without re-uploading them). */
185
+ wrote: boolean
131
186
  }
132
187
 
133
188
  /**
134
189
  * Create a baked tile layer: `cols` x `rows` cells of `tileW` x `tileH`
135
190
  * pixels, every tile drawing one atlas frame, baked into lazily-allocated
136
- * chunk textures and composited as a few quads. The grid shape is fixed at
137
- * creation - recreate the layer to resize. Disposed automatically with the
138
- * owning reactive scope (opt out with `{ autoFree: false }`); the atlas is
139
- * NOT owned - dispose it yourself.
191
+ * chunk textures and composited as a few quads. A tile world is FINITE and
192
+ * sized at creation - that is the contract, not a provisional limit:
193
+ * recreate the layer to resize, and for a huge sparse world just pick big
194
+ * numbers, since a chunk nobody writes costs nothing (no records, no
195
+ * texture) and the grid bound is bookkeeping, not allocation. Disposed
196
+ * automatically with the owning reactive scope (opt out with `{ autoFree:
197
+ * false }`); the atlas is NOT owned - dispose it yourself.
140
198
  */
141
199
  export function createTileLayer(
142
200
  cols: number,
@@ -164,8 +222,30 @@ export function createTileLayer(
164
222
  )
165
223
  }
166
224
  let label = opts?.label ?? "tiles"
225
+ let tint: Tint = opts?.tint ?? [1, 1, 1, 1]
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
+ }
167
246
  let oversample = opts?.oversample ?? 1
168
247
  checkOversample("createTileLayer", oversample, chunkW, chunkH)
248
+ let thrash = thrashSentinel(`tile layer "${label}"`)
169
249
  let chunkCols = Math.ceil(cols / chunkTiles)
170
250
  let perChunk = chunkTiles * chunkTiles
171
251
  // One unit quad (triangle strip), shared by every chunk's pipeline.
@@ -188,13 +268,17 @@ export function createTileLayer(
188
268
  for (let chunk of baked) {
189
269
  if (!chunk.dirty) continue
190
270
  chunk.dirty = false
191
- let out = beginBufferWrite(chunk.buffer)
192
- out.set(chunk.records)
193
- endBufferWrite(chunk.buffer, chunk.records.byteLength)
271
+ if (chunk.wrote) {
272
+ chunk.wrote = false
273
+ let out = beginBufferWrite(chunk.buffer)
274
+ out.set(chunk.records)
275
+ endBufferWrite(chunk.buffer, chunk.records.byteLength)
276
+ }
194
277
  renderTarget(chunk.texture)
195
278
  }
196
279
  }
197
- let touch = (chunk: Chunk) => {
280
+ let touch = (chunk: Chunk, wroteRecords = true) => {
281
+ if (wroteRecords) chunk.wrote = true
198
282
  if (chunk.dirty) return
199
283
  chunk.dirty = true
200
284
  dirtyChunks.push(chunk)
@@ -213,7 +297,9 @@ export function createTileLayer(
213
297
  FRAGMENT,
214
298
  chunkW * oversample,
215
299
  chunkH * oversample,
216
- { uViewport: [chunkW, chunkH], uCamera: [x, y, 1, 1] },
300
+ // Bake targets pin the camera rotation to identity: the tile camera
301
+ // rotates the COMPOSITED world (<TileLayer>'s view), never the bake.
302
+ { uViewport: [chunkW, chunkH], uCamera: [x, y, 1, 1], uCameraRot: [1, 0, 0, 0], uTint: tint },
217
303
  {
218
304
  label: `${label}-chunk`,
219
305
  topology: "triangle-strip",
@@ -225,26 +311,58 @@ export function createTileLayer(
225
311
  instanceCount: perChunk,
226
312
  blend: "alpha",
227
313
  textures: { uAtlas: atlas },
228
- clearColor: opts?.clearColor ?? [0, 0, 0, 0],
314
+ clearColor: opts?.chunkClearColor ?? [0, 0, 0, 0],
229
315
  filter: opts?.filter,
230
316
  render: "manual",
231
317
  autoFree: false,
232
318
  },
233
319
  )
234
- let chunk: Chunk = { texture, x, y, width: chunkW, height: chunkH, records, buffer, dirty: false }
320
+ let chunk: Chunk = { texture, x, y, width: chunkW, height: chunkH, records, buffer, dirty: false, wrote: false }
235
321
  resident.set(index, chunk)
236
322
  layer.chunks.push(chunk)
237
323
  layer.onChunk?.(chunk)
238
324
  return chunk
239
325
  }
240
326
 
241
- let locate = (col: number, row: number, verb: string): [number, number] => {
327
+ let checkCell = (col: number, row: number, verb: string): void => {
242
328
  if (!(Number.isInteger(col) && Number.isInteger(row) && col >= 0 && col < cols && row >= 0 && row < rows)) {
243
329
  throw new Error(`${verb}: cell ${col}, ${row} outside the ${cols} x ${rows} grid`)
244
330
  }
245
- let index = Math.floor(row / chunkTiles) * chunkCols + Math.floor(col / chunkTiles)
246
- let at = ((row % chunkTiles) * chunkTiles + (col % chunkTiles)) * FLOATS_PER_SPRITE
247
- 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
248
366
  }
249
367
 
250
368
  let layer: TileLayer = {
@@ -263,48 +381,138 @@ export function createTileLayer(
263
381
  setOversample(n) {
264
382
  if (disposed || n === oversample) return
265
383
  checkOversample("setOversample", n, chunkW, chunkH)
384
+ thrash()
266
385
  oversample = n
267
386
  // Resizing a target re-renders it, but a manual target's content is
268
387
  // its last bake: mark every chunk so the flush bakes it at the new size.
269
388
  for (let chunk of resident.values()) {
270
389
  setTargetSize(chunk.texture, chunkW * n, chunkH * n)
271
- touch(chunk)
390
+ // The records are unchanged; the resized target just needs a bake.
391
+ touch(chunk, false)
272
392
  }
273
393
  },
274
- setTile(col, row, frame) {
394
+ setTile(col, row, frame, opts) {
275
395
  if (disposed) return
276
- let [index, at] = locate(col, row, "setTile")
396
+ checkCell(col, row, "setTile")
397
+ let index = chunkOf(col, row)
398
+ let at = slot(col, row)
277
399
  let chunk = resident.get(index)
278
400
  if (frame === null) {
279
401
  // Clearing a cell no chunk holds is a no-op, not an allocation.
280
402
  if (!chunk) return
281
- chunk.records[at + 2] = 0
282
- chunk.records[at + 3] = 0
403
+ clearCell(chunk.records, at)
283
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)
284
407
  chunk ??= allocate(index)
285
- let r = chunk.records
286
- r[at] = (col + 0.5) * tileW
287
- r[at + 1] = (row + 0.5) * tileH
288
- r[at + 2] = tileW
289
- r[at + 3] = tileH
290
- r[at + 4] = frame.u0
291
- r[at + 5] = frame.v0
292
- r[at + 6] = frame.u1
293
- r[at + 7] = frame.v1
294
- r[at + 9] = 1
295
- r[at + 10] = 1
296
- r[at + 11] = 1
297
- r[at + 12] = 1
408
+ writeCell(chunk.records, at, col, row, frame.u0, frame.v0, frame.u1, frame.v1, opts?.tint)
298
409
  }
299
410
  touch(chunk)
300
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
+ },
301
497
  getTile(col, row) {
302
- let [index, at] = locate(col, row, "getTile")
303
- let chunk = resident.get(index)
498
+ checkCell(col, row, "getTile")
499
+ let chunk = resident.get(chunkOf(col, row))
500
+ let at = slot(col, row)
304
501
  if (!chunk || chunk.records[at + 2] === 0) return null
305
502
  let r = chunk.records
306
503
  return { u0: r[at + 4]!, v0: r[at + 5]!, u1: r[at + 6]!, v1: r[at + 7]! }
307
504
  },
505
+ frames,
506
+ setTint(next) {
507
+ if (disposed) return
508
+ checkTint("setTint", next)
509
+ if (next[0] === tint[0] && next[1] === tint[1] && next[2] === tint[2] && next[3] === tint[3]) return
510
+ tint = next
511
+ for (let chunk of resident.values()) {
512
+ setTargetParams(chunk.texture, { uTint: tint })
513
+ touch(chunk, false)
514
+ }
515
+ },
308
516
  dispose() {
309
517
  if (disposed) return
310
518
  disposed = true