@solidrt/2d 0.0.54 → 0.0.56

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
@@ -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
 
@@ -44,8 +46,9 @@ const RESOLVED = Promise.resolve()
44
46
  const CHUNK_TARGET_PX = 512
45
47
 
46
48
  export type TileLayerOptions = {
47
- /** Per-chunk clear color; empty (never-written) chunks render nothing. */
48
- clearColor?: [number, number, number, number]
49
+ /** Per-chunk clear color (the name says the scope: chunks that never
50
+ * allocate render nothing, so this is not a layer background). */
51
+ chunkClearColor?: [number, number, number, number]
49
52
  /**
50
53
  * Sampler filter for the baked chunk textures at composite time; default
51
54
  * "linear", which with `oversample` is the proper resample at any scale.
@@ -66,6 +69,8 @@ export type TileLayerOptions = {
66
69
  * chunk count (larger = fewer textures and leaves).
67
70
  */
68
71
  chunkTiles?: number
72
+ /** Layer tint, [r, g, b, a] in 0..1; see TileLayer.setTint. */
73
+ tint?: [number, number, number, number]
69
74
  label?: string
70
75
  /** Skip the owner-scoped auto-dispose (see createTileLayer). */
71
76
  autoFree?: boolean
@@ -114,13 +119,24 @@ export type TileLayer = {
114
119
  /** Called after a chunk allocates - the composition hook. Assignable. */
115
120
  onChunk?: (chunk: TileChunk) => void
116
121
  /**
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.
122
+ * Set one cell: a frame draws it, null clears it (a clear ignores `opts` -
123
+ * an absent cell has no tint, it draws nothing). `tint` multiplies the
124
+ * cell's sampled texels like a sprite's tint; absent it keeps the cell's
125
+ * current tint (a cell being set from empty starts at [1, 1, 1, 1]).
126
+ * Batched; the microtask flush publishes and re-bakes ONLY the chunks
127
+ * that changed, however many tiles did.
120
128
  */
121
- setTile(col: number, row: number, frame: Frame | null): void
129
+ setTile(col: number, row: number, frame: Frame | null, opts?: { tint?: [number, number, number, number] }): void
122
130
  /** The frame at a cell, or null when empty. */
123
131
  getTile(col: number, row: number): Frame | null
132
+ /**
133
+ * Tint the whole layer, [r, g, b, a] in 0..1: a uniform multiplied over
134
+ * every cell's own tint (day/night, a dimmed parallax plane). Not a
135
+ * record write - resident chunks re-render GPU-side with nothing
136
+ * re-uploaded - but each write does re-render every resident chunk, so
137
+ * animate it coarsely, not per frame.
138
+ */
139
+ setTint(tint: [number, number, number, number]): void
124
140
  dispose(): void
125
141
  }
126
142
 
@@ -128,15 +144,21 @@ type Chunk = TileChunk & {
128
144
  records: Float32Array
129
145
  buffer: BufferId
130
146
  dirty: boolean
147
+ /** Records changed since the last flush (a tint-only or resize re-bake
148
+ * renders without re-uploading them). */
149
+ wrote: boolean
131
150
  }
132
151
 
133
152
  /**
134
153
  * Create a baked tile layer: `cols` x `rows` cells of `tileW` x `tileH`
135
154
  * 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.
155
+ * chunk textures and composited as a few quads. A tile world is FINITE and
156
+ * sized at creation - that is the contract, not a provisional limit:
157
+ * recreate the layer to resize, and for a huge sparse world just pick big
158
+ * numbers, since a chunk nobody writes costs nothing (no records, no
159
+ * texture) and the grid bound is bookkeeping, not allocation. Disposed
160
+ * automatically with the owning reactive scope (opt out with `{ autoFree:
161
+ * false }`); the atlas is NOT owned - dispose it yourself.
140
162
  */
141
163
  export function createTileLayer(
142
164
  cols: number,
@@ -164,8 +186,11 @@ export function createTileLayer(
164
186
  )
165
187
  }
166
188
  let label = opts?.label ?? "tiles"
189
+ let tint: [number, number, number, number] = opts?.tint ?? [1, 1, 1, 1]
190
+ checkTint("createTileLayer", tint)
167
191
  let oversample = opts?.oversample ?? 1
168
192
  checkOversample("createTileLayer", oversample, chunkW, chunkH)
193
+ let thrash = thrashSentinel(`tile layer "${label}"`)
169
194
  let chunkCols = Math.ceil(cols / chunkTiles)
170
195
  let perChunk = chunkTiles * chunkTiles
171
196
  // One unit quad (triangle strip), shared by every chunk's pipeline.
@@ -188,13 +213,17 @@ export function createTileLayer(
188
213
  for (let chunk of baked) {
189
214
  if (!chunk.dirty) continue
190
215
  chunk.dirty = false
191
- let out = beginBufferWrite(chunk.buffer)
192
- out.set(chunk.records)
193
- endBufferWrite(chunk.buffer, chunk.records.byteLength)
216
+ if (chunk.wrote) {
217
+ chunk.wrote = false
218
+ let out = beginBufferWrite(chunk.buffer)
219
+ out.set(chunk.records)
220
+ endBufferWrite(chunk.buffer, chunk.records.byteLength)
221
+ }
194
222
  renderTarget(chunk.texture)
195
223
  }
196
224
  }
197
- let touch = (chunk: Chunk) => {
225
+ let touch = (chunk: Chunk, wroteRecords = true) => {
226
+ if (wroteRecords) chunk.wrote = true
198
227
  if (chunk.dirty) return
199
228
  chunk.dirty = true
200
229
  dirtyChunks.push(chunk)
@@ -213,7 +242,9 @@ export function createTileLayer(
213
242
  FRAGMENT,
214
243
  chunkW * oversample,
215
244
  chunkH * oversample,
216
- { uViewport: [chunkW, chunkH], uCamera: [x, y, 1, 1] },
245
+ // Bake targets pin the camera rotation to identity: the tile camera
246
+ // rotates the COMPOSITED world (<TileLayer>'s view), never the bake.
247
+ { uViewport: [chunkW, chunkH], uCamera: [x, y, 1, 1], uCameraRot: [1, 0, 0, 0], uTint: tint },
217
248
  {
218
249
  label: `${label}-chunk`,
219
250
  topology: "triangle-strip",
@@ -225,13 +256,13 @@ export function createTileLayer(
225
256
  instanceCount: perChunk,
226
257
  blend: "alpha",
227
258
  textures: { uAtlas: atlas },
228
- clearColor: opts?.clearColor ?? [0, 0, 0, 0],
259
+ clearColor: opts?.chunkClearColor ?? [0, 0, 0, 0],
229
260
  filter: opts?.filter,
230
261
  render: "manual",
231
262
  autoFree: false,
232
263
  },
233
264
  )
234
- let chunk: Chunk = { texture, x, y, width: chunkW, height: chunkH, records, buffer, dirty: false }
265
+ let chunk: Chunk = { texture, x, y, width: chunkW, height: chunkH, records, buffer, dirty: false, wrote: false }
235
266
  resident.set(index, chunk)
236
267
  layer.chunks.push(chunk)
237
268
  layer.onChunk?.(chunk)
@@ -263,15 +294,17 @@ export function createTileLayer(
263
294
  setOversample(n) {
264
295
  if (disposed || n === oversample) return
265
296
  checkOversample("setOversample", n, chunkW, chunkH)
297
+ thrash()
266
298
  oversample = n
267
299
  // Resizing a target re-renders it, but a manual target's content is
268
300
  // its last bake: mark every chunk so the flush bakes it at the new size.
269
301
  for (let chunk of resident.values()) {
270
302
  setTargetSize(chunk.texture, chunkW * n, chunkH * n)
271
- touch(chunk)
303
+ // The records are unchanged; the resized target just needs a bake.
304
+ touch(chunk, false)
272
305
  }
273
306
  },
274
- setTile(col, row, frame) {
307
+ setTile(col, row, frame, opts) {
275
308
  if (disposed) return
276
309
  let [index, at] = locate(col, row, "setTile")
277
310
  let chunk = resident.get(index)
@@ -283,6 +316,10 @@ export function createTileLayer(
283
316
  } else {
284
317
  chunk ??= allocate(index)
285
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
286
323
  r[at] = (col + 0.5) * tileW
287
324
  r[at + 1] = (row + 0.5) * tileH
288
325
  r[at + 2] = tileW
@@ -291,10 +328,18 @@ export function createTileLayer(
291
328
  r[at + 5] = frame.v0
292
329
  r[at + 6] = frame.u1
293
330
  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
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
+ }
298
343
  }
299
344
  touch(chunk)
300
345
  },
@@ -305,6 +350,16 @@ export function createTileLayer(
305
350
  let r = chunk.records
306
351
  return { u0: r[at + 4]!, v0: r[at + 5]!, u1: r[at + 6]!, v1: r[at + 7]! }
307
352
  },
353
+ setTint(next) {
354
+ if (disposed) return
355
+ checkTint("setTint", next)
356
+ if (next[0] === tint[0] && next[1] === tint[1] && next[2] === tint[2] && next[3] === tint[3]) return
357
+ tint = next
358
+ for (let chunk of resident.values()) {
359
+ setTargetParams(chunk.texture, { uTint: tint })
360
+ touch(chunk, false)
361
+ }
362
+ },
308
363
  dispose() {
309
364
  if (disposed) return
310
365
  disposed = true