@solidrt/2d 0.0.67 → 0.0.69

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/text.ts ADDED
@@ -0,0 +1,240 @@
1
+ // Text runs in a sprite layer: a string drawn as one group of glyph
2
+ // sprites, one per glyph, from a sprite font's atlas. The engine shapes
3
+ // the text (kerning is its own), `layoutText` places the glyphs, and each
4
+ // becomes an ordinary sprite under the run's group - so camera, tint,
5
+ // `orderBy`, picking, transitions and bubbled pointer events already work,
6
+ // and a run moves as one handle. A glyph whose cell is not in the atlas
7
+ // yet is a hidden sprite at the right place; the font re-frames the run
8
+ // when the cell lands (or every run when the atlas grew).
9
+ //
10
+ // The node layer only: a run is a group. Three verbs like the sprites':
11
+ // addText, setText (re-laid out, the sprite pool reused), destroyText.
12
+ import { addGroup, addSprite, destroyGroup, destroySprite, setGroup, setSprite } from "./layer.ts"
13
+ import type { Sprite, SpriteGroup, SpriteLayer } from "./layer.ts"
14
+ import type { SpriteFont } from "./font.ts"
15
+ import { layoutText } from "./text-layout.ts"
16
+ import type { GlyphPlacement, TextAlign, TextAnchor, TextAnchorY, TextLayout } from "./text-layout.ts"
17
+
18
+ /** The run's own fields: what `addText` takes beyond the group's pose and
19
+ * what `setText` changes (re-laying the run out). */
20
+ export type TextStyle = {
21
+ text: string
22
+ /** Pixels per em, layer pixels; default the font's `fontSize`. */
23
+ fontSize?: number
24
+ /** Baseline to baseline in layer pixels; default each line's own box. */
25
+ lineHeight?: number
26
+ /** Added after every glyph, layer pixels; default 0. */
27
+ letterSpacing?: number
28
+ /** Wrap at this width; unset, only `\n` breaks. */
29
+ maxWidth?: number
30
+ /** Line alignment inside the run's width (default "left"). */
31
+ align?: TextAlign
32
+ /** Where the run's x sits on its lines (default "start"). */
33
+ anchor?: TextAnchor
34
+ /** Where the run's y sits on its box (default "top"). */
35
+ anchorY?: TextAnchorY
36
+ /** The fill, RGBA 0..1 (default opaque white). */
37
+ tint?: [number, number, number, number]
38
+ /**
39
+ * An outline under the glyphs (an msdf font only; a mask font ignores
40
+ * it): its colour and width in layer pixels at the run's size. Null for
41
+ * none.
42
+ */
43
+ outline?: { color: [number, number, number]; width: number } | null
44
+ /** The draw-order key of every glyph (see SpriteOptions.renderOrder). */
45
+ renderOrder?: number
46
+ }
47
+
48
+ /** The run's pose: the group's. */
49
+ export type TextPose = {
50
+ x?: number
51
+ y?: number
52
+ /** Radians, clockwise, about the anchor point. */
53
+ rotation?: number
54
+ visible?: boolean
55
+ }
56
+
57
+ export type AddTextOptions = TextStyle &
58
+ TextPose & {
59
+ font: SpriteFont
60
+ /** Mount under this group (null = the layer root). */
61
+ parent?: SpriteGroup | null
62
+ }
63
+
64
+ export type TextRun = {
65
+ readonly layer: SpriteLayer
66
+ readonly font: SpriteFont
67
+ /** The run's group: its pose, and where glyph pointer events bubble. */
68
+ readonly group: SpriteGroup
69
+ /** The glyph sprites, in text order (one per glyph; a whitespace glyph
70
+ * has none). */
71
+ readonly sprites: readonly Sprite[]
72
+ /** The run's box in layer pixels at the current style. */
73
+ readonly width: number
74
+ readonly height: number
75
+ /** The first baseline below the box's top. */
76
+ readonly ascent: number
77
+ /** Line count. */
78
+ readonly lines: number
79
+ readonly text: string
80
+ _style: TextStyle
81
+ _layout: TextLayout
82
+ /** Glyph ids with no cell yet: what a landing re-frames. */
83
+ _pending: Set<number>
84
+ _reframe(grew: boolean, arrived: ReadonlySet<number>): void
85
+ }
86
+
87
+ /** The internal mutable view (the readonly fields are the public face). */
88
+ type RunState = { -readonly [K in keyof TextRun]: TextRun[K] } & { sprites: Sprite[] }
89
+
90
+ /** Add a run to a node layer, drawn from `font`'s atlas, which the layer
91
+ * must declare. Returns the run; its group carries the pose. */
92
+ export function addText(layer: SpriteLayer, opts: AddTextOptions): TextRun {
93
+ let { font, parent, x, y, rotation, visible, ...style } = opts
94
+ if (!layer.atlases.includes(font.atlas)) {
95
+ throw new Error("addText: the layer does not declare the font's atlas; create it with the atlas in its list")
96
+ }
97
+ let group = addGroup(layer, { x, y, rotation, visible, parent })
98
+ let run: RunState = {
99
+ layer,
100
+ font,
101
+ group,
102
+ sprites: [],
103
+ width: 0,
104
+ height: 0,
105
+ ascent: 0,
106
+ lines: 0,
107
+ text: style.text,
108
+ _style: style,
109
+ _layout: { glyphs: [], lines: [], width: 0, height: 0, ascent: 0 },
110
+ _pending: new Set(),
111
+ _reframe(grew, arrived) {
112
+ if (!grew && ![...run._pending].some(id => arrived.has(id))) return
113
+ place(run)
114
+ },
115
+ }
116
+ relayout(run, style)
117
+ font._listeners.add(run)
118
+ return run
119
+ }
120
+
121
+ /** Change a run: the style fields re-lay it out (the sprite pool is
122
+ * reused), the pose fields move its group. Absent keys keep values. */
123
+ export function setText(run: TextRun, opts: Partial<TextStyle> & TextPose): void {
124
+ let state = run as RunState
125
+ if (state.group.layer === null) return
126
+ let { x, y, rotation, visible, ...style } = opts
127
+ if (x !== undefined || y !== undefined || rotation !== undefined || visible !== undefined) {
128
+ setGroup(state.group, { x, y, rotation, visible })
129
+ }
130
+ let keys = Object.keys(style) as (keyof TextStyle)[]
131
+ if (keys.some(k => style[k] !== undefined)) {
132
+ let next: TextStyle = { ...state._style }
133
+ for (let k of keys) if (style[k] !== undefined) (next as Record<string, unknown>)[k] = style[k]
134
+ relayout(state, next)
135
+ }
136
+ }
137
+
138
+ /** Remove the run: its group and every glyph sprite. */
139
+ export function destroyText(run: TextRun): void {
140
+ let state = run as RunState
141
+ state.font._listeners.delete(state)
142
+ if (state.group.layer !== null) destroyGroup(state.group)
143
+ state.sprites = []
144
+ }
145
+
146
+ // Shape, break and place: the layout from the style, then the sprites.
147
+ function relayout(run: RunState, style: TextStyle): void {
148
+ checkStyle(style)
149
+ run._style = style
150
+ run.text = style.text
151
+ let fontSize = style.fontSize ?? run.font.fontSize
152
+ let prepared = run.font.prepare(style.text, { fontSize, lineHeight: 0 })
153
+ run._layout = layoutText(prepared, {
154
+ maxWidth: style.maxWidth,
155
+ align: style.align,
156
+ anchor: style.anchor,
157
+ anchorY: style.anchorY,
158
+ letterSpacing: style.letterSpacing,
159
+ lineHeight: style.lineHeight,
160
+ })
161
+ run.width = run._layout.width
162
+ run.height = run._layout.height
163
+ run.ascent = run._layout.ascent
164
+ run.lines = run._layout.lines.length
165
+ place(run)
166
+ }
167
+
168
+ // Write every glyph sprite from the layout and the font's cells: a cell
169
+ // the atlas lacks leaves its sprite hidden and asks the font for it.
170
+ function place(run: RunState): void {
171
+ let { font, layer, group } = run
172
+ let style = run._style
173
+ let fontSize = style.fontSize ?? font.fontSize
174
+ // Cells are made at `font.size` texels per em; the run draws at fontSize.
175
+ let scale = fontSize / font.size
176
+ let tint = style.tint ?? [1, 1, 1, 1]
177
+ let outline = style.outline ?? null
178
+ let renderOrder = style.renderOrder ?? 0
179
+ let placements = run._layout.glyphs
180
+ run._pending.clear()
181
+ let missing: number[] = []
182
+ // The pool: reuse sprites in order, add what is short, drop the rest.
183
+ let sprites = run.sprites
184
+ let used = 0
185
+ for (let p of placements) {
186
+ let glyph = font.glyph(p.id)
187
+ if (glyph === null) {
188
+ run._pending.add(p.id)
189
+ missing.push(p.id)
190
+ }
191
+ // A glyph with no cell yet, or an empty one (a space), draws nothing:
192
+ // its sprite stays hidden at a point, so a landing cell re-frames in
193
+ // place.
194
+ let drawn = glyph !== null && glyph.width > 0 && glyph.height > 0
195
+ let w = drawn ? glyph!.width * scale : 0
196
+ let h = drawn ? glyph!.height * scale : 0
197
+ let fields = {
198
+ x: p.x + (drawn ? glyph!.left * scale + w / 2 : 0),
199
+ y: p.y + (drawn ? -glyph!.top * scale + h / 2 : 0),
200
+ w,
201
+ h,
202
+ frame: drawn ? glyph!.frame : undefined,
203
+ tint,
204
+ renderOrder,
205
+ visible: drawn,
206
+ }
207
+ let sprite = sprites[used]
208
+ if (sprite === undefined) {
209
+ sprite = addSprite(layer, { ...fields, parent: group })
210
+ sprites.push(sprite)
211
+ } else {
212
+ setSprite(sprite, fields)
213
+ }
214
+ if (outline !== null) layer._outline(sprite, outline.color[0], outline.color[1], outline.color[2], outline.width)
215
+ else layer._outline(sprite, 0, 0, 0, 0)
216
+ used++
217
+ }
218
+ for (let i = used; i < sprites.length; i++) destroySprite(sprites[i]!)
219
+ sprites.length = used
220
+ if (missing.length > 0) font.request(missing)
221
+ }
222
+
223
+ function checkStyle(style: TextStyle): void {
224
+ if (typeof style.text !== "string") throw new Error(`text: text must be a string, got ${typeof style.text}`)
225
+ if (style.fontSize !== undefined && !(style.fontSize > 0 && Number.isFinite(style.fontSize))) {
226
+ throw new Error(`text: fontSize must be a positive number, got ${style.fontSize}`)
227
+ }
228
+ if (style.outline != null) {
229
+ let { color, width } = style.outline
230
+ if (!(Array.isArray(color) && color.length === 3 && color.every(c => c >= 0 && c <= 1))) {
231
+ throw new Error(`text: outline.color must be [r, g, b] in 0..1, got ${JSON.stringify(color)}`)
232
+ }
233
+ if (!(width >= 0 && Number.isFinite(width))) throw new Error(`text: outline.width must be >= 0, got ${width}`)
234
+ }
235
+ if (style.tint !== undefined && !(style.tint.length === 4 && style.tint.every(c => c >= 0 && c <= 1))) {
236
+ throw new Error(`text: tint must be [r, g, b, a] in 0..1, got ${JSON.stringify(style.tint)}`)
237
+ }
238
+ }
239
+
240
+ export type { GlyphPlacement }
package/src/tiles.ts CHANGED
@@ -5,26 +5,30 @@
5
5
  // chunk textures, and scrolling is a transform on the composited world
6
6
  // (see <TileLayer> in components/tile-layer.tsx), never a repaint.
7
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).
8
+ // Each chunk is a manual target over the layer's ONE sprite pipeline
9
+ // (shaders.ts, compiled once per layer for its atlases) with fixed record
10
+ // slots - record localRow * chunkTiles + localCol IS that tile, an empty
11
+ // tile is a zero-size quad, instance count is constant per chunk. Records
12
+ // hold WORLD pixel coordinates; the chunk target's uCamera is its pixel
13
+ // origin, so the shared vertex stage does the chunk-local mapping (the
14
+ // same mechanism a camera pass uses, pointed at a chunk rect). A cell's
15
+ // frame names its atlas like a sprite's (the record's atlas index), so a
16
+ // tile world draws from several tilesets in one bake.
14
17
  //
15
18
  // Chunks allocate lazily on the first setTile that gives them content: an
16
19
  // empty chunk costs nothing - no records, no buffer, no texture - so a
17
20
  // sparse world is bounded by its content, and world size is bounded by
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
21
+ // memory, not maxTextureSize. setTile and setTiles batch; the flush (ahead
22
+ // of the frame's paint, or at a microtask outside a frame) publishes and
23
+ // re-bakes ONLY dirty chunks. A layer nobody edits
20
24
  // publishes nothing, renders nothing, and costs nothing per frame.
21
25
  // Camera-driven residency (bake far chunks on approach, evict them) is
22
26
  // deliberately not here yet - see okf/backlog/2d-baked-layers.md.
23
- import { getOwner, onCleanup } from "@solidrt/core"
27
+ import { getOwner, onBeforeRender, onCleanup, runWithOwner } from "@solidrt/core"
24
28
  import {
25
29
  beginBufferWrite,
26
30
  createBuffer,
27
- createPipelineTexture,
31
+ createShaderTarget,
28
32
  destroyBuffer,
29
33
  destroyTexture,
30
34
  endBufferWrite,
@@ -33,12 +37,15 @@ import {
33
37
  setTargetParams,
34
38
  setTargetSize,
35
39
  } from "@solidrt/core/gpu"
36
- import type { BufferId, FilterMode, TextureId } from "@solidrt/core/gpu"
40
+ import type { BlendMode, BufferId, FilterMode, TextureId } from "@solidrt/core/gpu"
41
+ import { checkAtlases, frameIndex } from "./atlas.ts"
42
+ import type { Atlas } from "./atlas.ts"
43
+ import { isFrame } from "./frames.ts"
37
44
  import type { Frame } from "./frames.ts"
38
45
  import { checkTint } from "./layer.ts"
39
46
  import { checkOversample, thrashSentinel } from "./oversample.ts"
40
- import { FLOATS_PER_SPRITE } from "./records.ts"
41
- import { FRAGMENT, INSTANCE_ATTRIBUTES, VERTEX } from "./shaders.ts"
47
+ import { INSTANCE_FLOATS } from "./records.ts"
48
+ import { atlasBindings, createSpritePipeline, INSTANCE_ATTRIBUTES, VERTEX } from "./shaders.ts"
42
49
  import { checkCell, checkRect, chunkOf, eachChunkSlice, slotOf } from "./tiles-math.ts"
43
50
 
44
51
  const RESOLVED = Promise.resolve()
@@ -52,12 +59,10 @@ const CLEAR_INDEX = 0xffff
52
59
 
53
60
  export type Tint = [number, number, number, number]
54
61
 
55
- // Whether a value is a frame: four numeric UVs (the validation both write
56
- // paths run before touching a record).
57
- let isFrame = (f: unknown): f is Frame => {
58
- let o = f as Frame | null
59
- return typeof o === "object" && o !== null && typeof o.u0 === "number" && typeof o.v0 === "number" && typeof o.u1 === "number" && typeof o.v1 === "number"
60
- }
62
+ // Floats per frames-table entry: the four UVs plus the atlas index.
63
+ const TABLE_FLOATS = 5
64
+ // Float offset of the atlas sampler index in a record (records.ts layout).
65
+ const ATLAS_FIELD_OFFSET = 15
61
66
 
62
67
  export type TileLayerOptions = {
63
68
  /** Per-chunk clear color (the name says the scope: chunks that never
@@ -71,6 +76,13 @@ export type TileLayerOptions = {
71
76
  * widths at a fractional scale.
72
77
  */
73
78
  filter?: FilterMode
79
+ /**
80
+ * How the cells blend into their chunk bakes, core gpu's BlendMode;
81
+ * default "alpha". "add" for a glow or light map baked over the
82
+ * transparent chunk clear, "multiply" to darken. Pipeline state, fixed
83
+ * at creation.
84
+ */
85
+ blend?: BlendMode
74
86
  /**
75
87
  * Target texels per world pixel in the baked chunks (positive integer,
76
88
  * default 1); see TileLayer.setOversample. `<TileLayer>` picks it from
@@ -87,9 +99,10 @@ export type TileLayerOptions = {
87
99
  tint?: [number, number, number, number]
88
100
  /**
89
101
  * The frames table setTiles' index cells name - a tileset: `grid()`'s
90
- * array, or any Frame[] (up to 65535 entries), so a generated world is
91
- * a Uint16Array of indices and never an array of frame objects. Fixed
92
- * at creation; setTile takes frames directly either way.
102
+ * array, or any Frame[] (up to 65535 entries, from any of the layer's
103
+ * atlases), so a generated world is a Uint16Array of indices and never
104
+ * an array of frame objects. Fixed at creation; setTile takes frames
105
+ * directly either way.
93
106
  */
94
107
  frames?: Frame[]
95
108
  label?: string
@@ -108,6 +121,9 @@ export type TileChunk = {
108
121
  }
109
122
 
110
123
  export type TileLayer = {
124
+ /** The atlases the layer bakes from, as declared at creation: every
125
+ * frame set names one of their textures (see LayerBase.atlases). */
126
+ readonly atlases: readonly Atlas[]
111
127
  /** Grid shape, fixed at creation. */
112
128
  cols: number
113
129
  rows: number
@@ -144,8 +160,9 @@ export type TileLayer = {
144
160
  * an absent cell has no tint, it draws nothing). `tint` multiplies the
145
161
  * cell's sampled texels like a sprite's tint; absent it keeps the cell's
146
162
  * current tint (a cell being set from empty starts at [1, 1, 1, 1]).
147
- * Batched; the microtask flush publishes and re-bakes ONLY the chunks
148
- * that changed, however many tiles did.
163
+ * Batched; the flush ahead of the frame's paint (the microtask, outside
164
+ * a frame) publishes and re-bakes ONLY the chunks that changed, however
165
+ * many tiles did.
149
166
  */
150
167
  setTile(col: number, row: number, frame: Frame | null, opts?: { tint?: Tint }): void
151
168
  /**
@@ -193,18 +210,21 @@ type Chunk = TileChunk & {
193
210
  * sized at creation - that is the contract, not a provisional limit:
194
211
  * recreate the layer to resize, and for a huge sparse world just pick big
195
212
  * numbers, since a chunk nobody writes costs nothing (no records, no
196
- * texture) and the grid bound is bookkeeping, not allocation. Disposed
197
- * automatically with the owning reactive scope (opt out with `{ autoFree:
198
- * false }`); the atlas is NOT owned - dispose it yourself.
213
+ * texture) and the grid bound is bookkeeping, not allocation. `atlases`
214
+ * is the list of tilesets the cells draw from, bound together in every
215
+ * chunk bake (see createSpriteLayer). Disposed automatically with the
216
+ * owning reactive scope (opt out with `{ autoFree: false }`); the atlases
217
+ * are NOT owned - dispose them yourself.
199
218
  */
200
219
  export function createTileLayer(
201
220
  cols: number,
202
221
  rows: number,
203
222
  tileW: number,
204
223
  tileH: number,
205
- atlas: TextureId,
224
+ atlases: Atlas[],
206
225
  opts?: TileLayerOptions,
207
226
  ): TileLayer {
227
+ let atlasIndex = checkAtlases("createTileLayer", atlases)
208
228
  if (!(cols > 0 && rows > 0 && Number.isInteger(cols) && Number.isInteger(rows))) {
209
229
  throw new Error(`createTileLayer: cols and rows must be positive integers, got ${cols} x ${rows}`)
210
230
  }
@@ -223,25 +243,28 @@ export function createTileLayer(
223
243
  )
224
244
  }
225
245
  let label = opts?.label ?? "tiles"
246
+ let blend: BlendMode = opts?.blend ?? "alpha"
226
247
  let tint: Tint = opts?.tint ?? [1, 1, 1, 1]
227
248
  checkTint("createTileLayer", tint)
228
249
  // The frames table, copied (the caller's array may move on) and checked
229
- // once, plus its UVs as four floats per entry: an index write copies by
230
- // offset instead of reading a frame object per cell.
250
+ // once, plus its UVs and atlas index as five floats per entry: an index
251
+ // write copies by offset instead of reading a frame object per cell.
231
252
  let frames: Frame[] | null = opts?.frames ? opts.frames.slice() : null
232
- let tableUv: Float32Array | null = null
253
+ let table: Float32Array | null = null
233
254
  if (frames !== null) {
234
255
  if (frames.length >= CLEAR_INDEX) {
235
256
  throw new Error(`createTileLayer: a frames table holds at most ${CLEAR_INDEX - 1} frames, got ${frames.length}`)
236
257
  }
237
- tableUv = new Float32Array(frames.length * 4)
258
+ table = new Float32Array(frames.length * TABLE_FLOATS)
238
259
  for (let i = 0; i < frames.length; i++) {
239
260
  let f = frames[i]!
240
261
  if (!isFrame(f)) throw new Error(`createTileLayer: frames[${i}] is not a frame, got ${JSON.stringify(f)}`)
241
- tableUv[i * 4] = f.u0
242
- tableUv[i * 4 + 1] = f.v0
243
- tableUv[i * 4 + 2] = f.u1
244
- tableUv[i * 4 + 3] = f.v1
262
+ let at = i * TABLE_FLOATS
263
+ table[at] = f.u0
264
+ table[at + 1] = f.v0
265
+ table[at + 2] = f.u1
266
+ table[at + 3] = f.v1
267
+ table[at + 4] = frameIndex(`createTileLayer: frames[${i}]`, atlasIndex, f)
245
268
  }
246
269
  }
247
270
  let oversample = opts?.oversample ?? 1
@@ -249,14 +272,26 @@ export function createTileLayer(
249
272
  let thrash = thrashSentinel(`tile layer "${label}"`)
250
273
  let chunkCols = Math.ceil(cols / chunkTiles)
251
274
  let perChunk = chunkTiles * chunkTiles
252
- // One unit quad (triangle strip), shared by every chunk's pipeline.
253
- let quad = createBuffer(new Float32Array([-0.5, -0.5, 0.5, -0.5, -0.5, 0.5, 0.5, 0.5]), {
254
- label: `${label}-quad`,
255
- autoFree: false,
256
- })
275
+ // The one pipeline (and unit quad) every chunk target draws with:
276
+ // compiled once per layer, however many chunks allocate.
277
+ let gpu = createSpritePipeline(label, VERTEX, [INSTANCE_ATTRIBUTES], blend, atlases)
278
+ let textures = atlasBindings(atlases.map(a => a.texture))
257
279
 
258
280
  let disposed = false
259
281
  let scheduled = false
282
+ // The publish pass of every frame runs the pending flush ahead of the
283
+ // paint, so a tile set anywhere in the frame's JS is baked for that
284
+ // frame's picture; the microtask stays for writes made outside a frame.
285
+ // Registered outside any owner: dispose unhooks it (an autoFree: false
286
+ // layer outlives the owner it was created in).
287
+ let unhook = runWithOwner(null, () =>
288
+ onBeforeRender(
289
+ () => {
290
+ if (scheduled) flush()
291
+ },
292
+ { publish: true },
293
+ ),
294
+ )
260
295
  // Chunk index (chunkRow * chunkCols + chunkCol) -> resident chunk.
261
296
  let resident = new Map<number, Chunk>()
262
297
  let dirtyChunks: Chunk[] = []
@@ -291,11 +326,10 @@ export function createTileLayer(
291
326
  let allocate = (index: number): Chunk => {
292
327
  let x = (index % chunkCols) * chunkW
293
328
  let y = Math.floor(index / chunkCols) * chunkH
294
- let records = new Float32Array(perChunk * FLOATS_PER_SPRITE)
329
+ let records = new Float32Array(perChunk * INSTANCE_FLOATS)
295
330
  let buffer = createBuffer(records.byteLength, { label: `${label}-chunk-records`, autoFree: false })
296
- let texture = createPipelineTexture(
297
- VERTEX,
298
- FRAGMENT,
331
+ let texture = createShaderTarget(
332
+ gpu.pipeline,
299
333
  chunkW * oversample,
300
334
  chunkH * oversample,
301
335
  // Bake targets pin the camera rotation to identity: the tile camera
@@ -303,15 +337,10 @@ export function createTileLayer(
303
337
  { uViewport: [chunkW, chunkH], uCamera: [x, y, 1, 1], uCameraRot: [1, 0, 0, 0], uTint: tint },
304
338
  {
305
339
  label: `${label}-chunk`,
306
- topology: "triangle-strip",
340
+ buffers: [gpu.quad, buffer],
307
341
  vertexCount: 4,
308
- buffers: [
309
- { attributes: [{ name: "aPos", format: "float32x2" }], buffer: quad },
310
- { stepMode: "instance", attributes: INSTANCE_ATTRIBUTES, buffer },
311
- ],
312
342
  instanceCount: perChunk,
313
- blend: "alpha",
314
- textures: { uAtlas: atlas },
343
+ textures,
315
344
  clearColor: opts?.chunkClearColor ?? [0, 0, 0, 0],
316
345
  filter: opts?.filter,
317
346
  render: "manual",
@@ -328,12 +357,12 @@ export function createTileLayer(
328
357
  // A cell's chunk index, and its record offset inside that chunk
329
358
  // (tiles-math.ts, bound to this layer's grid).
330
359
  let chunkAt = (col: number, row: number): number => chunkOf(col, row, chunkTiles, chunkCols)
331
- let slot = (col: number, row: number): number => slotOf(col, row, chunkTiles, FLOATS_PER_SPRITE)
332
- // Write one cell's record: the quad at the cell, the frame's UVs, and
333
- // the tint - the one given, else the default when the cell comes up from
334
- // empty (a re-set keeps its tint: absent keys keep their values, like
335
- // every options object here).
336
- let writeCell = (r: Float32Array, at: number, col: number, row: number, u0: number, v0: number, u1: number, v1: number, cellTint: Tint | undefined): void => {
360
+ let slot = (col: number, row: number): number => slotOf(col, row, chunkTiles, INSTANCE_FLOATS)
361
+ // Write one cell's record: the quad at the cell, the frame's UVs and
362
+ // atlas index, and the tint - the one given, else the default when the
363
+ // cell comes up from empty (a re-set keeps its tint: absent keys keep
364
+ // their values, like every options object here).
365
+ let writeCell = (r: Float32Array, at: number, col: number, row: number, u0: number, v0: number, u1: number, v1: number, atlas: number, cellTint: Tint | undefined): void => {
337
366
  let fresh = r[at + 2] === 0
338
367
  r[at] = (col + 0.5) * tileW
339
368
  r[at + 1] = (row + 0.5) * tileH
@@ -343,6 +372,7 @@ export function createTileLayer(
343
372
  r[at + 5] = v0
344
373
  r[at + 6] = u1
345
374
  r[at + 7] = v1
375
+ r[at + ATLAS_FIELD_OFFSET] = atlas
346
376
  if (cellTint !== undefined) {
347
377
  r[at + 9] = cellTint[0]
348
378
  r[at + 10] = cellTint[1]
@@ -363,6 +393,7 @@ export function createTileLayer(
363
393
  }
364
394
 
365
395
  let layer: TileLayer = {
396
+ atlases,
366
397
  cols,
367
398
  rows,
368
399
  tileW,
@@ -401,8 +432,9 @@ export function createTileLayer(
401
432
  } else {
402
433
  if (!isFrame(frame)) throw new Error(`setTile: not a frame, got ${JSON.stringify(frame)}`)
403
434
  if (opts?.tint !== undefined) checkTint("setTile", opts.tint)
435
+ let atlas = frameIndex("setTile", atlasIndex, frame)
404
436
  chunk ??= allocate(index)
405
- writeCell(chunk.records, at, col, row, frame.u0, frame.v0, frame.u1, frame.v1, opts?.tint)
437
+ writeCell(chunk.records, at, col, row, frame.u0, frame.v0, frame.u1, frame.v1, atlas, opts?.tint)
406
438
  }
407
439
  touch(chunk)
408
440
  },
@@ -413,9 +445,11 @@ export function createTileLayer(
413
445
  if (opts?.tint !== undefined) checkTint("setTiles", opts.tint)
414
446
  let cellTint = opts?.tint
415
447
  // The two forms: indices into the table, or frames. Every entry is
416
- // checked before the first write, so a bad one changes nothing.
448
+ // checked (and a frame resolved to its atlas) before the first write,
449
+ // so a bad one changes nothing.
417
450
  let idx = typeof cells[0] === "number" ? (cells as ArrayLike<number>) : null
418
451
  let objs = idx === null ? (cells as ArrayLike<Frame | null>) : null
452
+ let objAtlas: Float32Array | null = null
419
453
  if (idx !== null) {
420
454
  if (frames === null) throw new Error("setTiles: index cells need a frames table (createTileLayer's frames option)")
421
455
  let count = frames.length
@@ -425,12 +459,14 @@ export function createTileLayer(
425
459
  if (!(Number.isInteger(k) && k >= 0 && k < count)) throw new Error(`setTiles: index ${k} at cell ${i} outside the ${count}-frame table`)
426
460
  }
427
461
  } else {
462
+ objAtlas = new Float32Array(objs!.length)
428
463
  for (let i = 0; i < objs!.length; i++) {
429
464
  let f = objs![i]
430
- if (f !== null && f !== undefined && !isFrame(f)) throw new Error(`setTiles: cell ${i} is neither a frame nor null, got ${JSON.stringify(f)}`)
465
+ if (f === null || f === undefined) continue
466
+ if (!isFrame(f)) throw new Error(`setTiles: cell ${i} is neither a frame nor null, got ${JSON.stringify(f)}`)
467
+ objAtlas[i] = frameIndex(`setTiles: cell ${i}`, atlasIndex, f)
431
468
  }
432
469
  }
433
- let uv = tableUv
434
470
  // Chunk by chunk over the rect: each chunk's slice of the rect is a
435
471
  // sub-rect walked row by row with the record offset advancing, one
436
472
  // dirty mark at the end. A chunk the slice only clears is never
@@ -441,22 +477,24 @@ export function createTileLayer(
441
477
  for (let y = rowA; y < rowB; y++) {
442
478
  let at = slot(colA, y)
443
479
  let i = (y - row) * w + (colA - col)
444
- for (let x = colA; x < colB; x++, at += FLOATS_PER_SPRITE, i++) {
480
+ for (let x = colA; x < colB; x++, at += INSTANCE_FLOATS, i++) {
445
481
  let u0: number
446
482
  let v0: number
447
483
  let u1: number
448
484
  let v1: number
485
+ let atlas: number
449
486
  if (idx !== null) {
450
487
  let k = idx[i]!
451
488
  if (k === -1 || k === CLEAR_INDEX) {
452
489
  if (r !== null) clearCell(r, at)
453
490
  continue
454
491
  }
455
- let b = k * 4
456
- u0 = uv![b]!
457
- v0 = uv![b + 1]!
458
- u1 = uv![b + 2]!
459
- v1 = uv![b + 3]!
492
+ let b = k * TABLE_FLOATS
493
+ u0 = table![b]!
494
+ v0 = table![b + 1]!
495
+ u1 = table![b + 2]!
496
+ v1 = table![b + 3]!
497
+ atlas = table![b + 4]!
460
498
  } else {
461
499
  let f = objs![i]
462
500
  if (f === null || f === undefined) {
@@ -467,12 +505,13 @@ export function createTileLayer(
467
505
  v0 = f.v0
468
506
  u1 = f.u1
469
507
  v1 = f.v1
508
+ atlas = objAtlas![i]!
470
509
  }
471
510
  if (r === null) {
472
511
  chunk = allocate(index)
473
512
  r = chunk.records
474
513
  }
475
- writeCell(r, at, x, y, u0, v0, u1, v1, cellTint)
514
+ writeCell(r, at, x, y, u0, v0, u1, v1, atlas, cellTint)
476
515
  }
477
516
  }
478
517
  if (chunk) touch(chunk)
@@ -484,7 +523,7 @@ export function createTileLayer(
484
523
  let at = slot(col, row)
485
524
  if (!chunk || chunk.records[at + 2] === 0) return null
486
525
  let r = chunk.records
487
- return { u0: r[at + 4]!, v0: r[at + 5]!, u1: r[at + 6]!, v1: r[at + 7]! }
526
+ return { texture: atlases[r[at + ATLAS_FIELD_OFFSET]!]!.texture, u0: r[at + 4]!, v0: r[at + 5]!, u1: r[at + 6]!, v1: r[at + 7]! }
488
527
  },
489
528
  frames,
490
529
  setTint(next) {
@@ -500,13 +539,14 @@ export function createTileLayer(
500
539
  dispose() {
501
540
  if (disposed) return
502
541
  disposed = true
542
+ unhook()
503
543
  for (let chunk of resident.values()) {
504
544
  destroyTexture(chunk.texture)
505
545
  destroyBuffer(chunk.buffer)
506
546
  }
507
547
  resident.clear()
508
548
  layer.chunks.length = 0
509
- destroyBuffer(quad)
549
+ gpu.dispose()
510
550
  },
511
551
  }
512
552