@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/AGENTS.md +219 -0
- package/README.md +51 -0
- package/examples/README.md +21 -0
- package/examples/logo.png +0 -0
- package/examples/parity.tsx +204 -0
- package/examples/pick.tsx +90 -0
- package/examples/springs.tsx +69 -0
- package/examples/sprites.tsx +73 -0
- package/examples/tiles.tsx +102 -0
- package/package.json +21 -0
- package/src/atlas.ts +52 -0
- package/src/components.tsx +287 -0
- package/src/frames.ts +78 -0
- package/src/index.ts +29 -0
- package/src/layer.ts +788 -0
- package/src/pick.ts +22 -0
- package/src/records.ts +294 -0
- package/src/shaders.ts +93 -0
- package/src/tiles.ts +281 -0
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
|
+
}
|