@solidrt/2d 0.0.52

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 ADDED
@@ -0,0 +1,22 @@
1
+ // Picking narrowphase, pure: no GPU or GUI imports, so the checks rig
2
+ // (checks/pick-check.ts) exercises this module headless on the flux binary.
3
+ // layer.ts walks its draw order calling this per sprite (topmost first).
4
+
5
+ /** Exact containment test against a rotated rect (center, size, rotation). */
6
+ export function pointInSprite(
7
+ px: number,
8
+ py: number,
9
+ cx: number,
10
+ cy: number,
11
+ w: number,
12
+ h: number,
13
+ rotation: number,
14
+ ): boolean {
15
+ let dx = px - cx
16
+ let dy = py - cy
17
+ let c = Math.cos(-rotation)
18
+ let s = Math.sin(-rotation)
19
+ let lx = dx * c - dy * s
20
+ let ly = dx * s + dy * c
21
+ return Math.abs(lx) <= w / 2 && Math.abs(ly) <= h / 2
22
+ }
package/src/records.ts ADDED
@@ -0,0 +1,294 @@
1
+ // The records layer: the raw escape hatch for motion only JS can compute
2
+ // (bespoke flocking, per-frame gameplay logic over every entity at large
3
+ // populations). Sprites are 13 JS-owned floats in one canonical
4
+ // Float32Array ordered by draw order (insertion order - painter's
5
+ // algorithm, later over earlier); mutations batch to a microtask whose
6
+ // flush publishes the live prefix through the zero-copy buffer write lease.
7
+ // A moved sprite is 13 float stores plus one bulk memcpy per dirty frame; a
8
+ // static layer publishes nothing and therefore costs nothing.
9
+ //
10
+ // This is NOT the default live layer - that is layer.ts, where sprites are
11
+ // spatial arena nodes core producers can reach. Use this when a JS loop
12
+ // writes every record every frame anyway: `layer.records` + `touch()` is
13
+ // ~2.4x faster than setSprite at 30k sprites (measured 12.9ms raw vs
14
+ // 30.8ms via setSprite, purely call overhead). It shrinks as producers
15
+ // land; it is the where-motion-is-computed axis, not a "game tier".
16
+ //
17
+ // Layer space, camera, pointer dispatch and the sprite functions
18
+ // (addSprite/setSprite/...) are shared with the node layer; picking here is
19
+ // the JS reverse walk (pointInSprite), since records have no nodes.
20
+ import { getOwner, onCleanup } from "@solidrt/core"
21
+ import {
22
+ beginBufferWrite,
23
+ createBuffer,
24
+ createPipelineTexture,
25
+ destroyBuffer,
26
+ destroyTexture,
27
+ endBufferWrite,
28
+ setDraw,
29
+ setTargetParams,
30
+ setTargetSize,
31
+ } from "@solidrt/core/gpu"
32
+ import type { BufferId, TextureId } from "@solidrt/core/gpu"
33
+ import { FULL_FRAME } from "./frames.ts"
34
+ import { spriteDispatch } from "./layer.ts"
35
+ import type { LayerBase, Sprite, SpriteHandlers, SpriteLayerOptions, SpriteOptions } from "./layer.ts"
36
+ import { pointInSprite } from "./pick.ts"
37
+ import { FRAGMENT, INSTANCE_ATTRIBUTES, VERTEX } from "./shaders.ts"
38
+
39
+ // Floats per instance record:
40
+ // [cx, cy, w, h, u0, v0, u1, v1, rot, tintR, tintG, tintB, tintA]
41
+ export const FLOATS_PER_SPRITE = 13
42
+
43
+ const RESOLVED = Promise.resolve()
44
+
45
+ export type RecordLayer = LayerBase & {
46
+ /**
47
+ * The canonical record array - the raw power path. Layout per sprite is
48
+ * FLOATS_PER_SPRITE floats: [cx, cy, w, h, u0, v0, u1, v1, rot, tintR,
49
+ * tintG, tintB, tintA], record i at i * FLOATS_PER_SPRITE in draw order.
50
+ * Write fields directly for large per-frame populations, then call
51
+ * touch() once. Do not cache indices across removeSprite - records
52
+ * shift - and do not cache the array across addSprite - growth replaces
53
+ * it.
54
+ */
55
+ records: Float32Array
56
+ /** Mark the records dirty and schedule the publish (the raw-path commit). */
57
+ touch(): void
58
+ _order: Sprite[]
59
+ }
60
+
61
+ /**
62
+ * Create a records layer rendering into a `width` x `height` texture from
63
+ * one atlas texture (same target shape as createSpriteLayer; the atlas is
64
+ * NOT owned). Disposed automatically with the owning reactive scope (opt
65
+ * out with `{ autoFree: false }`).
66
+ */
67
+ export function createRecordLayer(
68
+ width: number,
69
+ height: number,
70
+ atlas: TextureId,
71
+ opts?: SpriteLayerOptions,
72
+ ): RecordLayer {
73
+ let capacity = opts?.capacity ?? 1024
74
+ if (!(capacity > 0 && Number.isInteger(capacity))) {
75
+ throw new Error(`createRecordLayer: capacity must be a positive integer, got ${capacity}`)
76
+ }
77
+ let label = opts?.label ?? "sprites"
78
+ // One unit quad (triangle strip), reused by every instance.
79
+ let quad = createBuffer(new Float32Array([-0.5, -0.5, 0.5, -0.5, -0.5, 0.5, 0.5, 0.5]), {
80
+ label: `${label}-quad`,
81
+ autoFree: false,
82
+ })
83
+ let records: BufferId = createBuffer(capacity * FLOATS_PER_SPRITE * 4, {
84
+ label: `${label}-records`,
85
+ autoFree: false,
86
+ })
87
+ let texture = createPipelineTexture(
88
+ VERTEX,
89
+ FRAGMENT,
90
+ width,
91
+ height,
92
+ { uViewport: [width, height], uCamera: [0, 0, 1, 1] },
93
+ {
94
+ label,
95
+ topology: "triangle-strip",
96
+ vertexCount: 4,
97
+ attributes: [{ name: "aPos", format: "vec2" }],
98
+ buffer: quad,
99
+ instanceAttributes: INSTANCE_ATTRIBUTES,
100
+ instanceBuffer: records,
101
+ instanceCount: 0,
102
+ blend: "alpha",
103
+ textures: { uAtlas: atlas },
104
+ clearColor: opts?.clearColor ?? [0, 0, 0, 0],
105
+ autoFree: false,
106
+ },
107
+ )
108
+
109
+ // Camera state, mirrored for picking (the inverse mapping).
110
+ let camX = 0
111
+ let camY = 0
112
+ let camZoom = 1
113
+ let disposed = false
114
+ let dirty = false
115
+ let scheduled = false
116
+ let published = 0
117
+
118
+ // The GPU buffer's record capacity; the canonical array grows ahead of it
119
+ // (addSprite) and the publish catches the buffer up: a larger buffer is
120
+ // created, written in full, swapped in, and the old one destroyed. The
121
+ // entry holds the old buffer alive until the swap lands, so the destroy
122
+ // is safe to issue right after.
123
+ let gpuCapacity = capacity
124
+ let flush = () => {
125
+ scheduled = false
126
+ if (disposed || !dirty) return
127
+ dirty = false
128
+ let count = layer._order.length
129
+ let grown: BufferId | null = null
130
+ if (layer.records.length > gpuCapacity * FLOATS_PER_SPRITE) {
131
+ gpuCapacity = layer.records.length / FLOATS_PER_SPRITE
132
+ grown = createBuffer(layer.records.length * 4, { label: `${label}-records`, autoFree: false })
133
+ }
134
+ let target = grown ?? records
135
+ let out = beginBufferWrite(target)
136
+ out.set(layer.records.subarray(0, count * FLOATS_PER_SPRITE))
137
+ endBufferWrite(target, count * FLOATS_PER_SPRITE * 4)
138
+ if (grown !== null) {
139
+ setDraw(texture, { instanceBuffer: grown, instanceCount: count })
140
+ destroyBuffer(records)
141
+ records = grown
142
+ published = count
143
+ } else if (count !== published) {
144
+ setDraw(texture, { instanceCount: count })
145
+ published = count
146
+ }
147
+ }
148
+
149
+ // Record layout: [cx, cy, w, h, u0, v0, u1, v1, rot, tintR, tintG, tintB, tintA]
150
+ let writeRecord = (sprite: Sprite, opts: SpriteOptions) => {
151
+ let at = sprite._slot * FLOATS_PER_SPRITE
152
+ let r = layer.records
153
+ if (opts.x !== undefined) r[at] = opts.x
154
+ if (opts.y !== undefined) r[at + 1] = opts.y
155
+ if (opts.w !== undefined) r[at + 2] = opts.w
156
+ if (opts.h !== undefined) r[at + 3] = opts.h
157
+ if (opts.frame !== undefined) {
158
+ r[at + 4] = opts.frame.u0
159
+ r[at + 5] = opts.frame.v0
160
+ r[at + 6] = opts.frame.u1
161
+ r[at + 7] = opts.frame.v1
162
+ }
163
+ if (opts.rotation !== undefined) r[at + 8] = opts.rotation
164
+ if (opts.tint !== undefined) {
165
+ r[at + 9] = opts.tint[0]
166
+ r[at + 10] = opts.tint[1]
167
+ r[at + 11] = opts.tint[2]
168
+ r[at + 12] = opts.tint[3]
169
+ }
170
+ }
171
+
172
+ let dispatch = spriteDispatch({
173
+ size: () => [width, height],
174
+ camera: () => [camX, camY, camZoom],
175
+ pick: (x, y) => layer.pick(x, y),
176
+ })
177
+
178
+ let layer: RecordLayer = {
179
+ texture,
180
+ handlers: undefined as unknown as SpriteHandlers,
181
+ get count() {
182
+ return layer._order.length
183
+ },
184
+ setSize(w, h) {
185
+ if (disposed || (w === width && h === height)) return
186
+ width = w
187
+ height = h
188
+ setTargetSize(texture, w, h)
189
+ setTargetParams(texture, { uViewport: [w, h] })
190
+ },
191
+ setCamera(update) {
192
+ if (disposed) return
193
+ if (update.x !== undefined) camX = update.x
194
+ if (update.y !== undefined) camY = update.y
195
+ if (update.zoom !== undefined) {
196
+ if (!(update.zoom > 0)) throw new Error(`setCamera: zoom must be positive, got ${update.zoom}`)
197
+ camZoom = update.zoom
198
+ }
199
+ setTargetParams(texture, { uCamera: [camX, camY, camZoom, camZoom] })
200
+ },
201
+ pick(x, y) {
202
+ // Topmost first: reverse draw order, exact rotated-rect containment.
203
+ let r = layer.records
204
+ for (let i = layer._order.length - 1; i >= 0; i--) {
205
+ let at = i * FLOATS_PER_SPRITE
206
+ if (pointInSprite(x, y, r[at]!, r[at + 1]!, r[at + 2]!, r[at + 3]!, r[at + 8]!)) {
207
+ return layer._order[i]!
208
+ }
209
+ }
210
+ return null
211
+ },
212
+ handlersFor(layout) {
213
+ return dispatch(layout)
214
+ },
215
+ dispose() {
216
+ if (disposed) return
217
+ disposed = true
218
+ for (let sprite of layer._order) sprite.layer = null
219
+ layer._order.length = 0
220
+ destroyTexture(texture)
221
+ destroyBuffer(records)
222
+ destroyBuffer(quad)
223
+ },
224
+ _add(opts) {
225
+ if (opts?.parent) {
226
+ throw new Error("addSprite: record layers have no groups (parent is the node layer's)")
227
+ }
228
+ let index = layer._order.length
229
+ if ((index + 1) * FLOATS_PER_SPRITE > layer.records.length) {
230
+ let next = new Float32Array(layer.records.length * 2)
231
+ next.set(layer.records)
232
+ layer.records = next
233
+ }
234
+ let sprite: Sprite = { layer, node: null, _slot: index, _x: 0, _y: 0, _w: 0, _h: 0, _rot: 0 }
235
+ layer._order.push(sprite)
236
+ writeRecord(sprite, {
237
+ x: 0,
238
+ y: 0,
239
+ w: 0,
240
+ h: 0,
241
+ frame: FULL_FRAME,
242
+ rotation: 0,
243
+ tint: [1, 1, 1, 1],
244
+ ...opts,
245
+ })
246
+ layer._schedule()
247
+ return sprite
248
+ },
249
+ _write(sprite, opts) {
250
+ writeRecord(sprite, opts)
251
+ layer._schedule()
252
+ },
253
+ _read(sprite) {
254
+ let at = sprite._slot * FLOATS_PER_SPRITE
255
+ let r = layer.records
256
+ return {
257
+ x: r[at]!,
258
+ y: r[at + 1]!,
259
+ w: r[at + 2]!,
260
+ h: r[at + 3]!,
261
+ frame: { u0: r[at + 4]!, v0: r[at + 5]!, u1: r[at + 6]!, v1: r[at + 7]! },
262
+ rotation: r[at + 8]!,
263
+ tint: [r[at + 9]!, r[at + 10]!, r[at + 11]!, r[at + 12]!],
264
+ }
265
+ },
266
+ _remove(sprite) {
267
+ // Later sprites shift down one draw slot (order preserved).
268
+ sprite.layer = null
269
+ let index = sprite._slot
270
+ let order = layer._order
271
+ let r = layer.records
272
+ r.copyWithin(index * FLOATS_PER_SPRITE, (index + 1) * FLOATS_PER_SPRITE, order.length * FLOATS_PER_SPRITE)
273
+ order.splice(index, 1)
274
+ for (let i = index; i < order.length; i++) order[i]!._slot = i
275
+ layer._schedule()
276
+ },
277
+ _schedule() {
278
+ if (disposed) return
279
+ dirty = true
280
+ if (scheduled) return
281
+ scheduled = true
282
+ RESOLVED.then(flush)
283
+ },
284
+ records: new Float32Array(capacity * FLOATS_PER_SPRITE),
285
+ touch() {
286
+ layer._schedule()
287
+ },
288
+ _order: [],
289
+ }
290
+ layer.handlers = dispatch(null)
291
+
292
+ if (opts?.autoFree !== false && getOwner()) onCleanup(() => layer.dispose())
293
+ return layer
294
+ }
package/src/shaders.ts ADDED
@@ -0,0 +1,93 @@
1
+ // The sprite pipelines: a unit quad instanced over one atlas, mapped
2
+ // world -> clip through uCamera (offset + zoom) and uViewport. World and
3
+ // clip space are both y-down (core gpu.ts pixel contract), so the mapping
4
+ // carries NO flip anywhere - do not add one. Two record layouts share the
5
+ // fragment stage:
6
+ // - VERTEX + INSTANCE_ATTRIBUTES: the 13-float interleaved record
7
+ // [cx, cy, w, h, u0, v0, u1, v1, rot, tintR, tintG, tintB, tintA],
8
+ // used by the records layer (records.ts) and the tile layer (tiles.ts).
9
+ // - VERTEX_SPLIT + INSTANCE_ATTRIBUTES_SPLIT: the node-backed live layer
10
+ // (layer.ts), pose and style in separate instance-buffer slots - slot 0
11
+ // is the core-written Pose2D record [x, y, angle, sx, sy], slot 1 the
12
+ // JS-written style record [u0, v0, u1, v1, tintR, tintG, tintB, tintA].
13
+ // The rotation here (clockwise, y-down) and pointInSprite in pick.ts must
14
+ // agree; the differential check guards pick.ts against an oracle but NOT
15
+ // against these shaders - if you touch one rotation, touch all.
16
+ import { glsl } from "@solidrt/core/gpu"
17
+ import type { InstanceAttribute, VertexAttribute } from "@solidrt/core/gpu"
18
+
19
+ export let VERTEX = glsl`
20
+ in vec2 aPos;
21
+ in vec2 iCenter;
22
+ in vec2 iSize;
23
+ in vec4 iUv;
24
+ in float iRot;
25
+ in vec4 iTint;
26
+ out vec2 vUv;
27
+ out vec4 vTint;
28
+ uniform vec2 uViewport;
29
+ uniform vec4 uCamera;
30
+
31
+ void main() {
32
+ vec2 corner = aPos * iSize;
33
+ float c = cos(iRot), s = sin(iRot);
34
+ vec2 world = iCenter + vec2(corner.x * c - corner.y * s, corner.x * s + corner.y * c);
35
+ vec2 screen = (world - uCamera.xy) * uCamera.zw;
36
+ // World and clip are both y-down, so the mapping carries no flip.
37
+ gl_Position = vec4(screen / uViewport * 2.0 - 1.0, 0.0, 1.0);
38
+ vUv = mix(iUv.xy, iUv.zw, aPos + 0.5);
39
+ vTint = iTint;
40
+ }
41
+ `
42
+
43
+ export let FRAGMENT = glsl`
44
+ in vec2 vUv;
45
+ in vec4 vTint;
46
+ uniform sampler2D uAtlas;
47
+
48
+ void main() {
49
+ fragColor = texture(uAtlas, vUv) * vTint;
50
+ }
51
+ `
52
+
53
+ /** The instance attribute list matching the 13-float record layout. */
54
+ export const INSTANCE_ATTRIBUTES: VertexAttribute[] = [
55
+ { name: "iCenter", format: "vec2" },
56
+ { name: "iSize", format: "vec2" },
57
+ { name: "iUv", format: "vec4" },
58
+ { name: "iRot", format: "f32" },
59
+ { name: "iTint", format: "vec4" },
60
+ ]
61
+
62
+ export let VERTEX_SPLIT = glsl`
63
+ in vec2 aPos;
64
+ in vec2 iPos;
65
+ in float iRot;
66
+ in vec2 iScale;
67
+ in vec4 iUv;
68
+ in vec4 iTint;
69
+ out vec2 vUv;
70
+ out vec4 vTint;
71
+ uniform vec2 uViewport;
72
+ uniform vec4 uCamera;
73
+
74
+ void main() {
75
+ vec2 corner = aPos * iScale;
76
+ float c = cos(iRot), s = sin(iRot);
77
+ vec2 world = iPos + vec2(corner.x * c - corner.y * s, corner.x * s + corner.y * c);
78
+ vec2 screen = (world - uCamera.xy) * uCamera.zw;
79
+ // World and clip are both y-down, so the mapping carries no flip.
80
+ gl_Position = vec4(screen / uViewport * 2.0 - 1.0, 0.0, 1.0);
81
+ vUv = mix(iUv.xy, iUv.zw, aPos + 0.5);
82
+ vTint = iTint;
83
+ }
84
+ `
85
+
86
+ /** The split layout: slot 0 the Pose2D record, slot 1 the style record. */
87
+ export const INSTANCE_ATTRIBUTES_SPLIT: InstanceAttribute[] = [
88
+ { name: "iPos", format: "vec2" },
89
+ { name: "iRot", format: "f32" },
90
+ { name: "iScale", format: "vec2" },
91
+ { name: "iUv", format: "vec4", slot: 1 },
92
+ { name: "iTint", format: "vec4", slot: 1 },
93
+ ]
package/src/tiles.ts ADDED
@@ -0,0 +1,281 @@
1
+ // The baked tile layer: a cols x rows grid of atlas frames rendered into
2
+ // CHUNKED `render: "manual"` targets and composited as a handful of quads -
3
+ // never a quad per tile. On tiled GPUs the budget is primitive count, so a
4
+ // 100x100 world must not be 10,000 quads per frame; baked, it is a few
5
+ // chunk textures, and scrolling is a transform on the composited world
6
+ // (see <TileLayer> in components.tsx), never a repaint.
7
+ //
8
+ // Each chunk is a small copy of the sprite pipeline (shaders.ts) with fixed
9
+ // record slots - record localRow * chunkTiles + localCol IS that tile, an
10
+ // empty tile is a zero-size quad, instance count is constant per chunk.
11
+ // Records hold WORLD pixel coordinates; the chunk target's uCamera is its
12
+ // pixel origin, so the shared vertex stage does the chunk-local mapping
13
+ // (the same mechanism a camera pass uses, pointed at a chunk rect).
14
+ //
15
+ // Chunks allocate lazily on the first setTile that gives them content: an
16
+ // empty chunk costs nothing - no records, no buffer, no texture - so a
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.
23
+ import { getOwner, onCleanup } from "@solidrt/core"
24
+ import {
25
+ beginBufferWrite,
26
+ createBuffer,
27
+ createPipelineTexture,
28
+ destroyBuffer,
29
+ destroyTexture,
30
+ endBufferWrite,
31
+ limits,
32
+ renderTarget,
33
+ } from "@solidrt/core/gpu"
34
+ import type { BufferId, FilterMode, TextureId } from "@solidrt/core/gpu"
35
+ import type { Frame } from "./frames.ts"
36
+ import { FLOATS_PER_SPRITE } from "./records.ts"
37
+ import { FRAGMENT, INSTANCE_ATTRIBUTES, VERTEX } from "./shaders.ts"
38
+
39
+ const RESOLVED = Promise.resolve()
40
+
41
+ // Default chunk edge in pixels; the tile count per chunk derives from it.
42
+ const CHUNK_TARGET_PX = 512
43
+
44
+ export type TileLayerOptions = {
45
+ /** Per-chunk clear color; empty (never-written) chunks render nothing. */
46
+ clearColor?: [number, number, number, number]
47
+ /**
48
+ * Sampler filter for the baked chunk textures (what the camera zoom
49
+ * scales at composite time); default "linear". Pixel art wants "nearest"
50
+ * - hard pixels under integer upscales.
51
+ */
52
+ filter?: FilterMode
53
+ /**
54
+ * Chunk edge in TILES; default sized so a chunk is ~512px. The tuning
55
+ * knob between re-bake granularity (smaller = finer dirty regions) and
56
+ * chunk count (larger = fewer textures and leaves).
57
+ */
58
+ chunkTiles?: number
59
+ label?: string
60
+ /** Skip the owner-scoped auto-dispose (see createTileLayer). */
61
+ autoFree?: boolean
62
+ }
63
+
64
+ /** One resident chunk: a baked texture at a world-pixel rect. */
65
+ export type TileChunk = {
66
+ texture: TextureId
67
+ /** World-pixel origin and size of the chunk's rect. */
68
+ x: number
69
+ y: number
70
+ width: number
71
+ height: number
72
+ }
73
+
74
+ export type TileLayer = {
75
+ /** Grid shape, fixed at creation. */
76
+ cols: number
77
+ rows: number
78
+ tileW: number
79
+ tileH: number
80
+ /** World size in pixels: cols * tileW x rows * tileH. */
81
+ width: number
82
+ height: number
83
+ /**
84
+ * The resident chunks, in allocation order - the layer's output.
85
+ * Composite each as a texture leaf at its world rect (`<TileLayer>` does
86
+ * this). The array grows as content reaches new chunks; entries never
87
+ * move or leave (no eviction yet). Do not mutate.
88
+ */
89
+ chunks: TileChunk[]
90
+ /** Called after a chunk allocates - the composition hook. Assignable. */
91
+ onChunk?: (chunk: TileChunk) => void
92
+ /**
93
+ * Set one cell: a frame draws it, null clears it. Batched; the microtask
94
+ * flush publishes and re-bakes ONLY the chunks that changed, however
95
+ * many tiles did.
96
+ */
97
+ setTile(col: number, row: number, frame: Frame | null): void
98
+ /** The frame at a cell, or null when empty. */
99
+ getTile(col: number, row: number): Frame | null
100
+ dispose(): void
101
+ }
102
+
103
+ type Chunk = TileChunk & {
104
+ records: Float32Array
105
+ buffer: BufferId
106
+ dirty: boolean
107
+ }
108
+
109
+ /**
110
+ * Create a baked tile layer: `cols` x `rows` cells of `tileW` x `tileH`
111
+ * pixels, every tile drawing one atlas frame, baked into lazily-allocated
112
+ * chunk textures and composited as a few quads. The grid shape is fixed at
113
+ * creation - recreate the layer to resize. Disposed automatically with the
114
+ * owning reactive scope (opt out with `{ autoFree: false }`); the atlas is
115
+ * NOT owned - dispose it yourself.
116
+ */
117
+ export function createTileLayer(
118
+ cols: number,
119
+ rows: number,
120
+ tileW: number,
121
+ tileH: number,
122
+ atlas: TextureId,
123
+ opts?: TileLayerOptions,
124
+ ): TileLayer {
125
+ if (!(cols > 0 && rows > 0 && Number.isInteger(cols) && Number.isInteger(rows))) {
126
+ throw new Error(`createTileLayer: cols and rows must be positive integers, got ${cols} x ${rows}`)
127
+ }
128
+ if (!(tileW > 0 && tileH > 0)) {
129
+ throw new Error(`createTileLayer: tile size must be positive, got ${tileW} x ${tileH}`)
130
+ }
131
+ let chunkTiles = opts?.chunkTiles ?? Math.max(1, Math.floor(CHUNK_TARGET_PX / Math.max(tileW, tileH)))
132
+ if (!(chunkTiles > 0 && Number.isInteger(chunkTiles))) {
133
+ throw new Error(`createTileLayer: chunkTiles must be a positive integer, got ${chunkTiles}`)
134
+ }
135
+ let chunkW = chunkTiles * tileW
136
+ let chunkH = chunkTiles * tileH
137
+ if (chunkW > limits.maxTextureSize || chunkH > limits.maxTextureSize) {
138
+ throw new Error(
139
+ `createTileLayer: chunk size ${chunkW} x ${chunkH} exceeds maxTextureSize ${limits.maxTextureSize}; lower chunkTiles`,
140
+ )
141
+ }
142
+ let label = opts?.label ?? "tiles"
143
+ let chunkCols = Math.ceil(cols / chunkTiles)
144
+ let perChunk = chunkTiles * chunkTiles
145
+ // One unit quad (triangle strip), shared by every chunk's pipeline.
146
+ let quad = createBuffer(new Float32Array([-0.5, -0.5, 0.5, -0.5, -0.5, 0.5, 0.5, 0.5]), {
147
+ label: `${label}-quad`,
148
+ autoFree: false,
149
+ })
150
+
151
+ let disposed = false
152
+ let scheduled = false
153
+ // Chunk index (chunkRow * chunkCols + chunkCol) -> resident chunk.
154
+ let resident = new Map<number, Chunk>()
155
+ let dirtyChunks: Chunk[] = []
156
+
157
+ let flush = () => {
158
+ scheduled = false
159
+ if (disposed) return
160
+ let baked = dirtyChunks
161
+ dirtyChunks = []
162
+ for (let chunk of baked) {
163
+ if (!chunk.dirty) continue
164
+ chunk.dirty = false
165
+ let out = beginBufferWrite(chunk.buffer)
166
+ out.set(chunk.records)
167
+ endBufferWrite(chunk.buffer, chunk.records.byteLength)
168
+ renderTarget(chunk.texture)
169
+ }
170
+ }
171
+ let touch = (chunk: Chunk) => {
172
+ if (chunk.dirty) return
173
+ chunk.dirty = true
174
+ dirtyChunks.push(chunk)
175
+ if (scheduled) return
176
+ scheduled = true
177
+ RESOLVED.then(flush)
178
+ }
179
+
180
+ let allocate = (index: number): Chunk => {
181
+ let x = (index % chunkCols) * chunkW
182
+ let y = Math.floor(index / chunkCols) * chunkH
183
+ let records = new Float32Array(perChunk * FLOATS_PER_SPRITE)
184
+ let buffer = createBuffer(records.byteLength, { label: `${label}-chunk-records`, autoFree: false })
185
+ let texture = createPipelineTexture(
186
+ VERTEX,
187
+ FRAGMENT,
188
+ chunkW,
189
+ chunkH,
190
+ { uViewport: [chunkW, chunkH], uCamera: [x, y, 1, 1] },
191
+ {
192
+ label: `${label}-chunk`,
193
+ topology: "triangle-strip",
194
+ vertexCount: 4,
195
+ attributes: [{ name: "aPos", format: "vec2" }],
196
+ buffer: quad,
197
+ instanceAttributes: INSTANCE_ATTRIBUTES,
198
+ instanceBuffer: buffer,
199
+ instanceCount: perChunk,
200
+ blend: "alpha",
201
+ textures: { uAtlas: atlas },
202
+ clearColor: opts?.clearColor ?? [0, 0, 0, 0],
203
+ filter: opts?.filter,
204
+ render: "manual",
205
+ autoFree: false,
206
+ },
207
+ )
208
+ let chunk: Chunk = { texture, x, y, width: chunkW, height: chunkH, records, buffer, dirty: false }
209
+ resident.set(index, chunk)
210
+ layer.chunks.push(chunk)
211
+ layer.onChunk?.(chunk)
212
+ return chunk
213
+ }
214
+
215
+ let locate = (col: number, row: number, verb: string): [number, number] => {
216
+ if (!(Number.isInteger(col) && Number.isInteger(row) && col >= 0 && col < cols && row >= 0 && row < rows)) {
217
+ throw new Error(`${verb}: cell ${col}, ${row} outside the ${cols} x ${rows} grid`)
218
+ }
219
+ let index = Math.floor(row / chunkTiles) * chunkCols + Math.floor(col / chunkTiles)
220
+ let at = ((row % chunkTiles) * chunkTiles + (col % chunkTiles)) * FLOATS_PER_SPRITE
221
+ return [index, at]
222
+ }
223
+
224
+ let layer: TileLayer = {
225
+ cols,
226
+ rows,
227
+ tileW,
228
+ tileH,
229
+ width: cols * tileW,
230
+ height: rows * tileH,
231
+ chunks: [],
232
+ setTile(col, row, frame) {
233
+ if (disposed) return
234
+ let [index, at] = locate(col, row, "setTile")
235
+ let chunk = resident.get(index)
236
+ if (frame === null) {
237
+ // Clearing a cell no chunk holds is a no-op, not an allocation.
238
+ if (!chunk) return
239
+ chunk.records[at + 2] = 0
240
+ chunk.records[at + 3] = 0
241
+ } else {
242
+ chunk ??= allocate(index)
243
+ let r = chunk.records
244
+ r[at] = (col + 0.5) * tileW
245
+ r[at + 1] = (row + 0.5) * tileH
246
+ r[at + 2] = tileW
247
+ r[at + 3] = tileH
248
+ r[at + 4] = frame.u0
249
+ r[at + 5] = frame.v0
250
+ r[at + 6] = frame.u1
251
+ r[at + 7] = frame.v1
252
+ r[at + 9] = 1
253
+ r[at + 10] = 1
254
+ r[at + 11] = 1
255
+ r[at + 12] = 1
256
+ }
257
+ touch(chunk)
258
+ },
259
+ getTile(col, row) {
260
+ let [index, at] = locate(col, row, "getTile")
261
+ let chunk = resident.get(index)
262
+ if (!chunk || chunk.records[at + 2] === 0) return null
263
+ let r = chunk.records
264
+ return { u0: r[at + 4]!, v0: r[at + 5]!, u1: r[at + 6]!, v1: r[at + 7]! }
265
+ },
266
+ dispose() {
267
+ if (disposed) return
268
+ disposed = true
269
+ for (let chunk of resident.values()) {
270
+ destroyTexture(chunk.texture)
271
+ destroyBuffer(chunk.buffer)
272
+ }
273
+ resident.clear()
274
+ layer.chunks.length = 0
275
+ destroyBuffer(quad)
276
+ },
277
+ }
278
+
279
+ if (opts?.autoFree !== false && getOwner()) onCleanup(() => layer.dispose())
280
+ return layer
281
+ }