@solidrt/2d 0.0.66 → 0.0.68
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 +246 -69
- package/README.md +33 -18
- package/examples/README.md +29 -0
- package/examples/anim.tsx +1 -1
- package/examples/atlases.tsx +151 -0
- package/examples/camera-probe.tsx +7 -7
- package/examples/camera.tsx +2 -3
- package/examples/markers.tsx +185 -0
- package/examples/parity.tsx +2 -2
- package/examples/pick.tsx +1 -1
- package/examples/split-screen.tsx +1 -1
- package/examples/springs.tsx +1 -1
- package/examples/sprites.tsx +1 -1
- package/examples/text.tsx +143 -0
- package/examples/tiles.tsx +1 -1
- package/examples/views.tsx +3 -3
- package/package.json +3 -3
- package/src/atlas.ts +75 -12
- package/src/camera2d.ts +47 -34
- package/src/collision.ts +20 -0
- package/src/components/camera2d.tsx +6 -13
- package/src/components/group.tsx +8 -11
- package/src/components/index.ts +3 -1
- package/src/components/sprite-layer.tsx +51 -14
- package/src/components/sprite.tsx +19 -9
- package/src/components/text2d.tsx +77 -0
- package/src/components/tile-layer.tsx +11 -5
- package/src/components/view2d.tsx +2 -0
- package/src/dispatch.ts +16 -4
- package/src/extrude.ts +156 -0
- package/src/font.ts +207 -0
- package/src/frames.ts +65 -55
- package/src/index.ts +37 -18
- package/src/layer.ts +273 -45
- package/src/pack.ts +70 -0
- package/src/pick.ts +25 -1
- package/src/records.ts +286 -121
- package/src/shaders.ts +224 -77
- package/src/shots.ts +23 -9
- package/src/text-layout.ts +163 -0
- package/src/text.ts +240 -0
- package/src/tiles.ts +111 -71
- package/src/views.ts +21 -13
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
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// pixel
|
|
13
|
-
//
|
|
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
|
|
19
|
-
// the
|
|
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
|
-
|
|
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 {
|
|
41
|
-
import {
|
|
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
|
-
//
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
91
|
-
*
|
|
92
|
-
* at creation; setTile takes frames
|
|
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
|
|
148
|
-
* that changed, however
|
|
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.
|
|
197
|
-
*
|
|
198
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
//
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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 *
|
|
329
|
+
let records = new Float32Array(perChunk * INSTANCE_FLOATS)
|
|
295
330
|
let buffer = createBuffer(records.byteLength, { label: `${label}-chunk-records`, autoFree: false })
|
|
296
|
-
let texture =
|
|
297
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
332
|
-
// Write one cell's record: the quad at the cell, the frame's UVs
|
|
333
|
-
// the tint - the one given, else the default when the
|
|
334
|
-
// empty (a re-set keeps its tint: absent keys keep
|
|
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
|
|
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
|
|
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 +=
|
|
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 *
|
|
456
|
-
u0 =
|
|
457
|
-
v0 =
|
|
458
|
-
u1 =
|
|
459
|
-
v1 =
|
|
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
|
-
|
|
549
|
+
gpu.dispose()
|
|
510
550
|
},
|
|
511
551
|
}
|
|
512
552
|
|