@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/layer.ts
ADDED
|
@@ -0,0 +1,788 @@
|
|
|
1
|
+
// The live sprite layer, re-founded on the spatial core: every sprite is a
|
|
2
|
+
// SPATIAL ARENA node (arena slot: local pose, parent, world matrix, index
|
|
3
|
+
// leaf, record sink - no layout, no paint, no rendertree element) whose
|
|
4
|
+
// Pose2D record sink writes [x, y, angle, sx, sy] into the sprite's slot of
|
|
5
|
+
// the pose instance buffer at the core's flush. Rendering stays one
|
|
6
|
+
// instanced draw into one pipeline target, composited as a single
|
|
7
|
+
// `<texture>` leaf; what changed is who owns the pose upstream of the
|
|
8
|
+
// instance buffer - the arena, so every core producer (native transitions,
|
|
9
|
+
// animation clips, physics) reaches sprites through `sprite.node`, and
|
|
10
|
+
// picking walks the core BVH instead of a JS loop.
|
|
11
|
+
//
|
|
12
|
+
// Two instance-buffer slots split ownership: slot 0 is the pose buffer,
|
|
13
|
+
// written ONLY by the core (one coalesced write per flush however many
|
|
14
|
+
// nodes moved); slot 1 is the style buffer [u0, v0, u1, v1, tint rgba],
|
|
15
|
+
// JS-owned and published through the zero-copy write lease. Never write
|
|
16
|
+
// the pose buffer from JS - the core's staging mirror is the owner and
|
|
17
|
+
// will overwrite. For motion only JS can compute at large populations, the
|
|
18
|
+
// records layer (records.ts) is the escape hatch.
|
|
19
|
+
//
|
|
20
|
+
// Sprites hold FIXED instance slots (freed slots recycle): draw order is
|
|
21
|
+
// slot order, so removal never shifts records and pose sinks never rebind.
|
|
22
|
+
// Layer space is pixels, top-left origin, y-down - the render tree's
|
|
23
|
+
// frame. The camera is a shared-params write (uCamera), never per-sprite.
|
|
24
|
+
import { getOwner, onCleanup } from "@solidrt/core"
|
|
25
|
+
import type { PointerEvent as ElementPointerEvent } from "@solidrt/core"
|
|
26
|
+
import {
|
|
27
|
+
beginBufferWrite,
|
|
28
|
+
createBuffer,
|
|
29
|
+
createPipelineTexture,
|
|
30
|
+
destroyBuffer,
|
|
31
|
+
destroyTexture,
|
|
32
|
+
endBufferWrite,
|
|
33
|
+
setDraw,
|
|
34
|
+
setTargetParams,
|
|
35
|
+
setTargetSize,
|
|
36
|
+
} from "@solidrt/core/gpu"
|
|
37
|
+
import type { BufferId, TextureId } from "@solidrt/core/gpu"
|
|
38
|
+
import * as spatial from "flux:spatial"
|
|
39
|
+
import type { NodeId, NodeTransition } from "flux:spatial"
|
|
40
|
+
import { on } from "srt:events"
|
|
41
|
+
import type { Frame } from "./frames.ts"
|
|
42
|
+
import { FULL_FRAME } from "./frames.ts"
|
|
43
|
+
import type { RecordLayer } from "./records.ts"
|
|
44
|
+
import { FRAGMENT, INSTANCE_ATTRIBUTES_SPLIT, VERTEX_SPLIT } from "./shaders.ts"
|
|
45
|
+
|
|
46
|
+
/** Floats per pose record (the core's Pose2D projection). */
|
|
47
|
+
export const POSE_FLOATS = 5
|
|
48
|
+
/** Floats per style record: [u0, v0, u1, v1, tintR, tintG, tintB, tintA]. */
|
|
49
|
+
export const STYLE_FLOATS = 8
|
|
50
|
+
|
|
51
|
+
const RESOLVED = Promise.resolve()
|
|
52
|
+
|
|
53
|
+
// Shared marshalling scratch (the bindings copy synchronously).
|
|
54
|
+
const TRANSFORM = new Float32Array(10)
|
|
55
|
+
const FLAT_BOUNDS = new Float32Array([-0.5, -0.5, 0, 0.5, 0.5, 0])
|
|
56
|
+
const RAY_ORIGIN = new Float32Array(3)
|
|
57
|
+
const RAY_DIR = new Float32Array([0, 0, 1])
|
|
58
|
+
const BOX = new Float32Array(6)
|
|
59
|
+
|
|
60
|
+
// Settle routing: the core's "spatialTransitionEnd" event carries the node
|
|
61
|
+
// id, so the handles with a transition DECLARED are indexed by node (only
|
|
62
|
+
// those can settle; adding a sprite costs nothing here) and one lazy
|
|
63
|
+
// subscription, started at the first declaration, routes to the handle's
|
|
64
|
+
// onTransitionEnd. Target-only, like the element transitions.
|
|
65
|
+
let declared = new Map<NodeId, Sprite | SpriteGroup>()
|
|
66
|
+
let subscribed = false
|
|
67
|
+
|
|
68
|
+
function declare(node: NodeId, handle: Sprite | SpriteGroup, transition: NodeTransition | string | null): void {
|
|
69
|
+
if (transition === null) {
|
|
70
|
+
declared.delete(node)
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
declared.set(node, handle)
|
|
74
|
+
if (subscribed) return
|
|
75
|
+
subscribed = true
|
|
76
|
+
on("spatialTransitionEnd", (event: { node: NodeId; component: TransitionEndEvent["component"] }) => {
|
|
77
|
+
let handle = declared.get(event.node)
|
|
78
|
+
if (!handle) return
|
|
79
|
+
try {
|
|
80
|
+
handle.onTransitionEnd?.({ component: event.component })
|
|
81
|
+
} catch (err) {
|
|
82
|
+
console.error("Error in onTransitionEnd handler:", err)
|
|
83
|
+
}
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* One sprite: a handle into its layer. Read via getSprite; write through
|
|
89
|
+
* setSprite so changes publish. The pointer handlers are plain assignable
|
|
90
|
+
* fields - they touch no GPU state (the scene-graph handler rule).
|
|
91
|
+
*/
|
|
92
|
+
export type Sprite = {
|
|
93
|
+
/** The owning layer, null after removeSprite. */
|
|
94
|
+
layer: SpriteLayer | RecordLayer | null
|
|
95
|
+
/**
|
|
96
|
+
* The sprite's SPATIAL ARENA node - the citizenship handle: bind core
|
|
97
|
+
* producers to it or reach it through flux:spatial directly (the layer
|
|
98
|
+
* still owns the node's life; destroy it only via removeSprite). Null
|
|
99
|
+
* on a record layer's sprites.
|
|
100
|
+
*/
|
|
101
|
+
node: NodeId | null
|
|
102
|
+
/** Instance slot: fixed for the sprite's life on the node layer, the
|
|
103
|
+
* shifting draw-order index on a record layer. */
|
|
104
|
+
_slot: number
|
|
105
|
+
/** Pose mirror (node layer): what setSprite composes transforms from. */
|
|
106
|
+
_x: number
|
|
107
|
+
_y: number
|
|
108
|
+
_w: number
|
|
109
|
+
_h: number
|
|
110
|
+
_rot: number
|
|
111
|
+
onPointerDown?: (event: SpritePointerEvent) => void
|
|
112
|
+
onPointerMove?: (event: SpritePointerEvent) => void
|
|
113
|
+
onPointerUp?: (event: SpritePointerEvent) => void
|
|
114
|
+
onPointerEnter?: (event: SpritePointerEvent) => void
|
|
115
|
+
onPointerLeave?: (event: SpritePointerEvent) => void
|
|
116
|
+
/** A declared transition (setSpriteTransition) settled naturally on
|
|
117
|
+
* one component; a cancel or snap never fires. */
|
|
118
|
+
onTransitionEnd?: (event: TransitionEndEvent) => void
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The settled component of a node transition: `position` is x/y,
|
|
122
|
+
* `scale` w/h (a group's uniform scale). */
|
|
123
|
+
export type TransitionEndEvent = {
|
|
124
|
+
component: "position" | "rotation" | "scale"
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Sprite fields, all optional at every call site: absent keys keep values. */
|
|
128
|
+
export type SpriteOptions = {
|
|
129
|
+
/** Center position in layer pixels (in the parent group's frame when the
|
|
130
|
+
* sprite is grouped). */
|
|
131
|
+
x?: number
|
|
132
|
+
y?: number
|
|
133
|
+
/** Drawn size in layer pixels. */
|
|
134
|
+
w?: number
|
|
135
|
+
h?: number
|
|
136
|
+
/** Atlas frame (normalized UVs); default the whole atlas. */
|
|
137
|
+
frame?: Frame
|
|
138
|
+
/** Rotation about the center, radians, clockwise (y-down space). */
|
|
139
|
+
rotation?: number
|
|
140
|
+
/** RGBA multiplier 0..1 each; default opaque white (the texture as-is). */
|
|
141
|
+
tint?: [number, number, number, number]
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export type AddSpriteOptions = SpriteOptions & {
|
|
145
|
+
/** Mount under this group (node layer only); pose fields are then local
|
|
146
|
+
* to it. Reparent later with setSpriteParent. */
|
|
147
|
+
parent?: SpriteGroup
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export type SpritePointerEvent = {
|
|
151
|
+
/** The sprite hit (the topmost at the point), constant while captured. */
|
|
152
|
+
sprite: Sprite
|
|
153
|
+
/** Pointer position in LAYER pixels (the camera mapping undone). */
|
|
154
|
+
x: number
|
|
155
|
+
y: number
|
|
156
|
+
pointerId: number
|
|
157
|
+
pointerType: string
|
|
158
|
+
button?: number
|
|
159
|
+
shiftKey: boolean
|
|
160
|
+
ctrlKey: boolean
|
|
161
|
+
altKey: boolean
|
|
162
|
+
metaKey: boolean
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export type SpriteHandlers = {
|
|
166
|
+
onPointerDown: (event: ElementPointerEvent) => void
|
|
167
|
+
onPointerMove: (event: ElementPointerEvent) => void
|
|
168
|
+
onPointerUp: (event: ElementPointerEvent) => void
|
|
169
|
+
onPointerLeave: (event: ElementPointerEvent) => void
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export type CameraUpdate = {
|
|
173
|
+
/** World pixel at the viewport's top-left corner. */
|
|
174
|
+
x?: number
|
|
175
|
+
y?: number
|
|
176
|
+
/** World-to-screen scale; 1 is pixel-for-pixel. */
|
|
177
|
+
zoom?: number
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export type SpriteLayerOptions = {
|
|
181
|
+
/**
|
|
182
|
+
* Initial slot reservation; default 1024. The layer grows past it on
|
|
183
|
+
* demand (doubling), so this is a hint that avoids regrowth copies, not a
|
|
184
|
+
* limit.
|
|
185
|
+
*/
|
|
186
|
+
capacity?: number
|
|
187
|
+
clearColor?: [number, number, number, number]
|
|
188
|
+
label?: string
|
|
189
|
+
/** Skip the owner-scoped auto-dispose (see createSpriteLayer). */
|
|
190
|
+
autoFree?: boolean
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* A transform group: a plain spatial arena node (position, rotation,
|
|
195
|
+
* uniform scale - never a sprite size) that sprites and other groups
|
|
196
|
+
* parent under, so a ship with turrets or a dragged stack moves as one
|
|
197
|
+
* subtree recomputed in native code. Groups render nothing and cannot be
|
|
198
|
+
* picked; sprites are always the leaves.
|
|
199
|
+
*/
|
|
200
|
+
export type SpriteGroup = {
|
|
201
|
+
/** The owning layer, null after removeGroup. */
|
|
202
|
+
layer: SpriteLayer | null
|
|
203
|
+
/** The group's spatial arena node. */
|
|
204
|
+
node: NodeId
|
|
205
|
+
_x: number
|
|
206
|
+
_y: number
|
|
207
|
+
_rot: number
|
|
208
|
+
_scale: number
|
|
209
|
+
/** See Sprite.onTransitionEnd. */
|
|
210
|
+
onTransitionEnd?: (event: TransitionEndEvent) => void
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Group fields, all optional: absent keys keep values. */
|
|
214
|
+
export type GroupOptions = {
|
|
215
|
+
/** Position in the parent frame (layer pixels at the root). */
|
|
216
|
+
x?: number
|
|
217
|
+
y?: number
|
|
218
|
+
/** Rotation, radians, clockwise (y-down space). */
|
|
219
|
+
rotation?: number
|
|
220
|
+
/** Uniform scale on the whole subtree (this one scales child sprites -
|
|
221
|
+
* a group is a frame, not a sprite size). */
|
|
222
|
+
scale?: number
|
|
223
|
+
/** Reparent (null = make the group a root). */
|
|
224
|
+
parent?: SpriteGroup | null
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** What both layer kinds share; the free sprite functions dispatch on it. */
|
|
228
|
+
export type LayerBase = {
|
|
229
|
+
/** The layer's output: an ordinary texture id (`<texture src>`). */
|
|
230
|
+
texture: TextureId
|
|
231
|
+
/** Element handlers wiring sprite pointer events; see handlersFor. */
|
|
232
|
+
handlers: SpriteHandlers
|
|
233
|
+
/** Live sprite count. */
|
|
234
|
+
readonly count: number
|
|
235
|
+
setSize(width: number, height: number): void
|
|
236
|
+
setCamera(update: CameraUpdate): void
|
|
237
|
+
/** Topmost sprite whose rotated rect contains the layer-pixel point. */
|
|
238
|
+
pick(x: number, y: number): Sprite | null
|
|
239
|
+
/**
|
|
240
|
+
* Handlers for a leaf whose LAYOUT size differs from the layer size
|
|
241
|
+
* (events scale by layer/layout; null layout means "already layer pixels",
|
|
242
|
+
* which is what the built-in leaf uses).
|
|
243
|
+
*/
|
|
244
|
+
handlersFor(layout: (() => { width: number; height: number } | null) | null): SpriteHandlers
|
|
245
|
+
dispose(): void
|
|
246
|
+
_add(opts?: AddSpriteOptions): Sprite
|
|
247
|
+
_write(sprite: Sprite, opts: SpriteOptions): void
|
|
248
|
+
_read(sprite: Sprite): Required<SpriteOptions>
|
|
249
|
+
_remove(sprite: Sprite): void
|
|
250
|
+
_schedule(): void
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export type SpriteLayer = LayerBase & {
|
|
254
|
+
/**
|
|
255
|
+
* Every sprite whose rotated rect overlaps the layer-pixel rect (the
|
|
256
|
+
* core BVH overlap query, exact for rotated sprites), unordered - the
|
|
257
|
+
* marquee query. Node layer only.
|
|
258
|
+
*/
|
|
259
|
+
pickRect(x: number, y: number, w: number, h: number): Sprite[]
|
|
260
|
+
_groups: Set<SpriteGroup>
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Pointer dispatch shared by both layer kinds: capture per pointer, hover
|
|
265
|
+
* pairing, no bubbling (the sprite list is flat). Layout null = the leaf is
|
|
266
|
+
* laid out at layer size, so localX/localY are layer pixels already (the
|
|
267
|
+
* element hit test undid every ancestor transform - never getBoundingBox
|
|
268
|
+
* here). Internal - layers expose the result as handlers/handlersFor.
|
|
269
|
+
*/
|
|
270
|
+
export function spriteDispatch(state: {
|
|
271
|
+
size: () => [number, number]
|
|
272
|
+
camera: () => [number, number, number]
|
|
273
|
+
pick: (x: number, y: number) => Sprite | null
|
|
274
|
+
}): (layout: (() => { width: number; height: number } | null) | null) => SpriteHandlers {
|
|
275
|
+
let capture = new Map<number, Sprite>()
|
|
276
|
+
let hover = new Map<number, Sprite>()
|
|
277
|
+
return layout => {
|
|
278
|
+
let toLayer = (e: ElementPointerEvent): [number, number] => {
|
|
279
|
+
let x = e.localX
|
|
280
|
+
let y = e.localY
|
|
281
|
+
let l = layout?.()
|
|
282
|
+
let [width, height] = state.size()
|
|
283
|
+
if (l && l.width > 0 && l.height > 0) {
|
|
284
|
+
x *= width / l.width
|
|
285
|
+
y *= height / l.height
|
|
286
|
+
}
|
|
287
|
+
// Undo the camera: screen -> world.
|
|
288
|
+
let [camX, camY, camZoom] = state.camera()
|
|
289
|
+
return [x / camZoom + camX, y / camZoom + camY]
|
|
290
|
+
}
|
|
291
|
+
let makeEvent = (sprite: Sprite, x: number, y: number, e: ElementPointerEvent): SpritePointerEvent => ({
|
|
292
|
+
sprite,
|
|
293
|
+
x,
|
|
294
|
+
y,
|
|
295
|
+
pointerId: e.pointerId,
|
|
296
|
+
pointerType: e.pointerType,
|
|
297
|
+
button: e.button,
|
|
298
|
+
shiftKey: e.shiftKey,
|
|
299
|
+
ctrlKey: e.ctrlKey,
|
|
300
|
+
altKey: e.altKey,
|
|
301
|
+
metaKey: e.metaKey,
|
|
302
|
+
})
|
|
303
|
+
return {
|
|
304
|
+
onPointerDown(e) {
|
|
305
|
+
let [x, y] = toLayer(e)
|
|
306
|
+
let hit = state.pick(x, y)
|
|
307
|
+
if (!hit) return
|
|
308
|
+
capture.set(e.pointerId, hit)
|
|
309
|
+
hit.onPointerDown?.(makeEvent(hit, x, y, e))
|
|
310
|
+
},
|
|
311
|
+
onPointerMove(e) {
|
|
312
|
+
let [x, y] = toLayer(e)
|
|
313
|
+
let captured = capture.get(e.pointerId)
|
|
314
|
+
if (captured) {
|
|
315
|
+
if (captured.layer) captured.onPointerMove?.(makeEvent(captured, x, y, e))
|
|
316
|
+
return
|
|
317
|
+
}
|
|
318
|
+
let hit = state.pick(x, y)
|
|
319
|
+
let prev = hover.get(e.pointerId) ?? null
|
|
320
|
+
if (prev !== hit) {
|
|
321
|
+
if (prev && prev.layer) prev.onPointerLeave?.(makeEvent(prev, x, y, e))
|
|
322
|
+
if (hit) hit.onPointerEnter?.(makeEvent(hit, x, y, e))
|
|
323
|
+
if (hit) hover.set(e.pointerId, hit)
|
|
324
|
+
else hover.delete(e.pointerId)
|
|
325
|
+
}
|
|
326
|
+
hit?.onPointerMove?.(makeEvent(hit, x, y, e))
|
|
327
|
+
},
|
|
328
|
+
onPointerUp(e) {
|
|
329
|
+
let [x, y] = toLayer(e)
|
|
330
|
+
let captured = capture.get(e.pointerId)
|
|
331
|
+
if (captured) {
|
|
332
|
+
capture.delete(e.pointerId)
|
|
333
|
+
if (captured.layer) captured.onPointerUp?.(makeEvent(captured, x, y, e))
|
|
334
|
+
return
|
|
335
|
+
}
|
|
336
|
+
let hit = state.pick(x, y)
|
|
337
|
+
hit?.onPointerUp?.(makeEvent(hit, x, y, e))
|
|
338
|
+
},
|
|
339
|
+
onPointerLeave(e) {
|
|
340
|
+
let [x, y] = toLayer(e)
|
|
341
|
+
let prev = hover.get(e.pointerId)
|
|
342
|
+
if (prev) {
|
|
343
|
+
hover.delete(e.pointerId)
|
|
344
|
+
if (prev.layer) prev.onPointerLeave?.(makeEvent(prev, x, y, e))
|
|
345
|
+
}
|
|
346
|
+
},
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Fill the shared transform scratch: xy translation, z rotation, xy scale
|
|
352
|
+
* (a sprite's scale is its w/h - every sprite is a scaled unit quad). */
|
|
353
|
+
function fillTransform(x: number, y: number, rot: number, sx: number, sy: number): void {
|
|
354
|
+
let half = rot / 2
|
|
355
|
+
TRANSFORM[0] = x
|
|
356
|
+
TRANSFORM[1] = y
|
|
357
|
+
TRANSFORM[2] = 0
|
|
358
|
+
TRANSFORM[3] = 0
|
|
359
|
+
TRANSFORM[4] = 0
|
|
360
|
+
TRANSFORM[5] = Math.sin(half)
|
|
361
|
+
TRANSFORM[6] = Math.cos(half)
|
|
362
|
+
TRANSFORM[7] = sx
|
|
363
|
+
TRANSFORM[8] = sy
|
|
364
|
+
TRANSFORM[9] = 1
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Compose and push a node-backed sprite's local transform - through the
|
|
368
|
+
* node's transition declaration, so with one set (setSpriteTransition)
|
|
369
|
+
* the write is a target the core animates toward. */
|
|
370
|
+
function writeTransform(sprite: Sprite): void {
|
|
371
|
+
fillTransform(sprite._x, sprite._y, sprite._rot, sprite._w, sprite._h)
|
|
372
|
+
spatial.writeTransform(sprite.node!, TRANSFORM)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Create a sprite layer rendering into a `width` x `height` texture from one
|
|
377
|
+
* atlas texture. Disposed automatically with the owning reactive scope (opt
|
|
378
|
+
* out with `{ autoFree: false }`); the atlas is NOT owned - dispose it
|
|
379
|
+
* yourself (it commonly outlives layers).
|
|
380
|
+
*/
|
|
381
|
+
export function createSpriteLayer(
|
|
382
|
+
width: number,
|
|
383
|
+
height: number,
|
|
384
|
+
atlas: TextureId,
|
|
385
|
+
opts?: SpriteLayerOptions,
|
|
386
|
+
): SpriteLayer {
|
|
387
|
+
let capacity = opts?.capacity ?? 1024
|
|
388
|
+
if (!(capacity > 0 && Number.isInteger(capacity))) {
|
|
389
|
+
throw new Error(`createSpriteLayer: capacity must be a positive integer, got ${capacity}`)
|
|
390
|
+
}
|
|
391
|
+
let label = opts?.label ?? "sprites"
|
|
392
|
+
// One unit quad (triangle strip), reused by every instance.
|
|
393
|
+
let quad = createBuffer(new Float32Array([-0.5, -0.5, 0.5, -0.5, -0.5, 0.5, 0.5, 0.5]), {
|
|
394
|
+
label: `${label}-quad`,
|
|
395
|
+
autoFree: false,
|
|
396
|
+
})
|
|
397
|
+
let pose: BufferId = createBuffer(capacity * POSE_FLOATS * 4, { label: `${label}-pose`, autoFree: false })
|
|
398
|
+
let style: BufferId = createBuffer(capacity * STYLE_FLOATS * 4, { label: `${label}-style`, autoFree: false })
|
|
399
|
+
let texture = createPipelineTexture(
|
|
400
|
+
VERTEX_SPLIT,
|
|
401
|
+
FRAGMENT,
|
|
402
|
+
width,
|
|
403
|
+
height,
|
|
404
|
+
{ uViewport: [width, height], uCamera: [0, 0, 1, 1] },
|
|
405
|
+
{
|
|
406
|
+
label,
|
|
407
|
+
topology: "triangle-strip",
|
|
408
|
+
vertexCount: 4,
|
|
409
|
+
attributes: [{ name: "aPos", format: "vec2" }],
|
|
410
|
+
buffer: quad,
|
|
411
|
+
instanceAttributes: INSTANCE_ATTRIBUTES_SPLIT,
|
|
412
|
+
instanceBuffers: [pose, style],
|
|
413
|
+
instanceCount: 0,
|
|
414
|
+
blend: "alpha",
|
|
415
|
+
textures: { uAtlas: atlas },
|
|
416
|
+
clearColor: opts?.clearColor ?? [0, 0, 0, 0],
|
|
417
|
+
autoFree: false,
|
|
418
|
+
},
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
// Camera state, mirrored for picking (the inverse mapping).
|
|
422
|
+
let camX = 0
|
|
423
|
+
let camY = 0
|
|
424
|
+
let camZoom = 1
|
|
425
|
+
let disposed = false
|
|
426
|
+
let scheduled = false
|
|
427
|
+
let styleDirty = false
|
|
428
|
+
let published = 0
|
|
429
|
+
|
|
430
|
+
// Slot allocation: freed slots recycle, the high-water mark is the
|
|
431
|
+
// published instance count (a freed slot's pose zeroes - zero scale
|
|
432
|
+
// collapses the instance - so holes draw nothing).
|
|
433
|
+
let highWater = 0
|
|
434
|
+
let freeSlots: number[] = []
|
|
435
|
+
let gpuCapacity = capacity
|
|
436
|
+
let styleData = new Float32Array(capacity * STYLE_FLOATS)
|
|
437
|
+
let byNode = new Map<NodeId, Sprite>()
|
|
438
|
+
|
|
439
|
+
let flush = () => {
|
|
440
|
+
scheduled = false
|
|
441
|
+
if (disposed) return
|
|
442
|
+
if (styleDirty) {
|
|
443
|
+
styleDirty = false
|
|
444
|
+
let out = beginBufferWrite(style)
|
|
445
|
+
out.set(styleData.subarray(0, highWater * STYLE_FLOATS))
|
|
446
|
+
endBufferWrite(style, highWater * STYLE_FLOATS * 4)
|
|
447
|
+
}
|
|
448
|
+
if (published !== highWater) {
|
|
449
|
+
setDraw(texture, { instanceCount: highWater })
|
|
450
|
+
published = highWater
|
|
451
|
+
}
|
|
452
|
+
// The core recomputes moved subtrees and publishes every dirty pose
|
|
453
|
+
// slot as one coalesced write per buffer.
|
|
454
|
+
spatial.flush()
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Grow both instance buffers to `next` slots: the pose sinks move in one
|
|
458
|
+
// retargetRecords call (the whole used range republishes at the next
|
|
459
|
+
// flush), the style mirror grows in JS and republishes through the lease.
|
|
460
|
+
// The entry holds the old buffers alive until the swap lands, so the
|
|
461
|
+
// destroys are safe to issue right after.
|
|
462
|
+
let grow = (next: number) => {
|
|
463
|
+
let newPose = createBuffer(next * POSE_FLOATS * 4, { label: `${label}-pose`, autoFree: false })
|
|
464
|
+
let newStyle = createBuffer(next * STYLE_FLOATS * 4, { label: `${label}-style`, autoFree: false })
|
|
465
|
+
spatial.retargetRecords(pose, newPose)
|
|
466
|
+
let grownStyle = new Float32Array(next * STYLE_FLOATS)
|
|
467
|
+
grownStyle.set(styleData)
|
|
468
|
+
styleData = grownStyle
|
|
469
|
+
setDraw(texture, { instanceBuffers: [newPose, newStyle] })
|
|
470
|
+
destroyBuffer(pose)
|
|
471
|
+
destroyBuffer(style)
|
|
472
|
+
pose = newPose
|
|
473
|
+
style = newStyle
|
|
474
|
+
gpuCapacity = next
|
|
475
|
+
styleDirty = true
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
let writeStyle = (slot: number, opts: SpriteOptions) => {
|
|
479
|
+
let at = slot * STYLE_FLOATS
|
|
480
|
+
if (opts.frame !== undefined) {
|
|
481
|
+
styleData[at] = opts.frame.u0
|
|
482
|
+
styleData[at + 1] = opts.frame.v0
|
|
483
|
+
styleData[at + 2] = opts.frame.u1
|
|
484
|
+
styleData[at + 3] = opts.frame.v1
|
|
485
|
+
}
|
|
486
|
+
if (opts.tint !== undefined) {
|
|
487
|
+
styleData[at + 4] = opts.tint[0]
|
|
488
|
+
styleData[at + 5] = opts.tint[1]
|
|
489
|
+
styleData[at + 6] = opts.tint[2]
|
|
490
|
+
styleData[at + 7] = opts.tint[3]
|
|
491
|
+
}
|
|
492
|
+
styleDirty = true
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
let dispatch = spriteDispatch({
|
|
496
|
+
size: () => [width, height],
|
|
497
|
+
camera: () => [camX, camY, camZoom],
|
|
498
|
+
pick: (x, y) => layer.pick(x, y),
|
|
499
|
+
})
|
|
500
|
+
|
|
501
|
+
let layer: SpriteLayer = {
|
|
502
|
+
texture,
|
|
503
|
+
handlers: undefined as unknown as SpriteHandlers,
|
|
504
|
+
get count() {
|
|
505
|
+
return byNode.size
|
|
506
|
+
},
|
|
507
|
+
setSize(w, h) {
|
|
508
|
+
if (disposed || (w === width && h === height)) return
|
|
509
|
+
width = w
|
|
510
|
+
height = h
|
|
511
|
+
setTargetSize(texture, w, h)
|
|
512
|
+
setTargetParams(texture, { uViewport: [w, h] })
|
|
513
|
+
},
|
|
514
|
+
setCamera(update) {
|
|
515
|
+
if (disposed) return
|
|
516
|
+
if (update.x !== undefined) camX = update.x
|
|
517
|
+
if (update.y !== undefined) camY = update.y
|
|
518
|
+
if (update.zoom !== undefined) {
|
|
519
|
+
if (!(update.zoom > 0)) throw new Error(`setCamera: zoom must be positive, got ${update.zoom}`)
|
|
520
|
+
camZoom = update.zoom
|
|
521
|
+
}
|
|
522
|
+
setTargetParams(texture, { uCamera: [camX, camY, camZoom, camZoom] })
|
|
523
|
+
},
|
|
524
|
+
pick(x, y) {
|
|
525
|
+
// The index reads as of the last core flush; run any pending batch
|
|
526
|
+
// first so a write followed by a pick sees the write.
|
|
527
|
+
if (scheduled) flush()
|
|
528
|
+
RAY_ORIGIN[0] = x
|
|
529
|
+
RAY_ORIGIN[1] = y
|
|
530
|
+
RAY_ORIGIN[2] = -1
|
|
531
|
+
let best: Sprite | null = null
|
|
532
|
+
for (let hit of spatial.raycast(RAY_ORIGIN, RAY_DIR)) {
|
|
533
|
+
// The arena is shared (a 3d scene lives in the same index): only
|
|
534
|
+
// this layer's nodes count. Topmost = highest slot (draw order).
|
|
535
|
+
let sprite = byNode.get(hit.node)
|
|
536
|
+
if (sprite && (best === null || sprite._slot > best._slot)) best = sprite
|
|
537
|
+
}
|
|
538
|
+
return best
|
|
539
|
+
},
|
|
540
|
+
pickRect(x, y, w, h) {
|
|
541
|
+
if (scheduled) flush()
|
|
542
|
+
BOX[0] = x
|
|
543
|
+
BOX[1] = y
|
|
544
|
+
BOX[2] = -1
|
|
545
|
+
BOX[3] = x + w
|
|
546
|
+
BOX[4] = y + h
|
|
547
|
+
BOX[5] = 1
|
|
548
|
+
let out: Sprite[] = []
|
|
549
|
+
for (let node of spatial.overlap(BOX)) {
|
|
550
|
+
let sprite = byNode.get(node)
|
|
551
|
+
if (sprite) out.push(sprite)
|
|
552
|
+
}
|
|
553
|
+
return out
|
|
554
|
+
},
|
|
555
|
+
handlersFor(layout) {
|
|
556
|
+
return dispatch(layout)
|
|
557
|
+
},
|
|
558
|
+
dispose() {
|
|
559
|
+
if (disposed) return
|
|
560
|
+
disposed = true
|
|
561
|
+
for (let sprite of byNode.values()) {
|
|
562
|
+
sprite.layer = null
|
|
563
|
+
declared.delete(sprite.node!)
|
|
564
|
+
spatial.destroyNode(sprite.node!)
|
|
565
|
+
}
|
|
566
|
+
byNode.clear()
|
|
567
|
+
for (let group of layer._groups) {
|
|
568
|
+
group.layer = null
|
|
569
|
+
declared.delete(group.node)
|
|
570
|
+
spatial.destroyNode(group.node)
|
|
571
|
+
}
|
|
572
|
+
layer._groups.clear()
|
|
573
|
+
// Let the core emit its final slot-zeroing writes while the pose
|
|
574
|
+
// buffer still exists, then free everything.
|
|
575
|
+
spatial.flush()
|
|
576
|
+
destroyTexture(texture)
|
|
577
|
+
destroyBuffer(pose)
|
|
578
|
+
destroyBuffer(style)
|
|
579
|
+
destroyBuffer(quad)
|
|
580
|
+
},
|
|
581
|
+
_add(opts) {
|
|
582
|
+
if (disposed) throw new Error("addSprite: layer is disposed")
|
|
583
|
+
let slot = freeSlots.pop() ?? highWater++
|
|
584
|
+
if (slot >= gpuCapacity) grow(gpuCapacity * 2)
|
|
585
|
+
let sprite: Sprite = {
|
|
586
|
+
layer,
|
|
587
|
+
node: null,
|
|
588
|
+
_slot: slot,
|
|
589
|
+
_x: opts?.x ?? 0,
|
|
590
|
+
_y: opts?.y ?? 0,
|
|
591
|
+
_w: opts?.w ?? 0,
|
|
592
|
+
_h: opts?.h ?? 0,
|
|
593
|
+
_rot: opts?.rotation ?? 0,
|
|
594
|
+
}
|
|
595
|
+
fillTransform(sprite._x, sprite._y, sprite._rot, sprite._w, sprite._h)
|
|
596
|
+
let node = spatial.createNode(TRANSFORM, true)
|
|
597
|
+
sprite.node = node
|
|
598
|
+
if (opts?.parent) {
|
|
599
|
+
if (opts.parent.layer !== layer) throw new Error("addSprite: parent group belongs to another layer")
|
|
600
|
+
spatial.setParent(node, opts.parent.node)
|
|
601
|
+
}
|
|
602
|
+
spatial.setBounds(node, FLAT_BOUNDS)
|
|
603
|
+
spatial.bindPoseRecord(node, pose, slot)
|
|
604
|
+
byNode.set(node, sprite)
|
|
605
|
+
writeStyle(slot, { frame: FULL_FRAME, tint: [1, 1, 1, 1], ...opts })
|
|
606
|
+
layer._schedule()
|
|
607
|
+
return sprite
|
|
608
|
+
},
|
|
609
|
+
_write(sprite, opts) {
|
|
610
|
+
let moved = false
|
|
611
|
+
if (opts.x !== undefined && opts.x !== sprite._x) (sprite._x = opts.x), (moved = true)
|
|
612
|
+
if (opts.y !== undefined && opts.y !== sprite._y) (sprite._y = opts.y), (moved = true)
|
|
613
|
+
if (opts.w !== undefined && opts.w !== sprite._w) (sprite._w = opts.w), (moved = true)
|
|
614
|
+
if (opts.h !== undefined && opts.h !== sprite._h) (sprite._h = opts.h), (moved = true)
|
|
615
|
+
if (opts.rotation !== undefined && opts.rotation !== sprite._rot) (sprite._rot = opts.rotation), (moved = true)
|
|
616
|
+
if (moved) writeTransform(sprite)
|
|
617
|
+
if (opts.frame !== undefined || opts.tint !== undefined) writeStyle(sprite._slot, opts)
|
|
618
|
+
if (moved || styleDirty) layer._schedule()
|
|
619
|
+
},
|
|
620
|
+
_read(sprite) {
|
|
621
|
+
let at = sprite._slot * STYLE_FLOATS
|
|
622
|
+
return {
|
|
623
|
+
x: sprite._x,
|
|
624
|
+
y: sprite._y,
|
|
625
|
+
w: sprite._w,
|
|
626
|
+
h: sprite._h,
|
|
627
|
+
frame: { u0: styleData[at]!, v0: styleData[at + 1]!, u1: styleData[at + 2]!, v1: styleData[at + 3]! },
|
|
628
|
+
rotation: sprite._rot,
|
|
629
|
+
tint: [styleData[at + 4]!, styleData[at + 5]!, styleData[at + 6]!, styleData[at + 7]!],
|
|
630
|
+
}
|
|
631
|
+
},
|
|
632
|
+
_remove(sprite) {
|
|
633
|
+
sprite.layer = null
|
|
634
|
+
// Destroying the node zeroes its pose slot at the next core flush
|
|
635
|
+
// (zero scale = nothing drawn); the slot then recycles.
|
|
636
|
+
byNode.delete(sprite.node!)
|
|
637
|
+
declared.delete(sprite.node!)
|
|
638
|
+
spatial.destroyNode(sprite.node!)
|
|
639
|
+
freeSlots.push(sprite._slot)
|
|
640
|
+
layer._schedule()
|
|
641
|
+
},
|
|
642
|
+
_schedule() {
|
|
643
|
+
if (disposed || scheduled) return
|
|
644
|
+
scheduled = true
|
|
645
|
+
RESOLVED.then(flush)
|
|
646
|
+
},
|
|
647
|
+
_groups: new Set(),
|
|
648
|
+
}
|
|
649
|
+
layer.handlers = dispatch(null)
|
|
650
|
+
|
|
651
|
+
if (opts?.autoFree !== false && getOwner()) onCleanup(() => layer.dispose())
|
|
652
|
+
return layer
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Add a sprite. Its instance slot is fixed for its life: draw order is slot
|
|
657
|
+
* order, and a removed sprite's slot recycles to the next add - so unlike
|
|
658
|
+
* the record layer there is no painter's-insertion-order guarantee across
|
|
659
|
+
* removals. Opaque-or-transparent pixel art (the overwhelming case) never
|
|
660
|
+
* notices; z-ordered translucency is the sort-key backlog item. Past the
|
|
661
|
+
* layer's reservation both instance buffers double (pose sinks move in one
|
|
662
|
+
* core retarget); reserve with `capacity` to avoid the copies.
|
|
663
|
+
*/
|
|
664
|
+
export function addSprite(layer: SpriteLayer | RecordLayer, opts?: AddSpriteOptions): Sprite {
|
|
665
|
+
return layer._add(opts)
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/** The one write path: absent keys keep their values (the params rule). */
|
|
669
|
+
export function setSprite(sprite: Sprite, opts: SpriteOptions): void {
|
|
670
|
+
sprite.layer?._write(sprite, opts)
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/** Read a sprite's current fields (a fresh object; mutating it does nothing). */
|
|
674
|
+
export function getSprite(sprite: Sprite): Required<SpriteOptions> | null {
|
|
675
|
+
return sprite.layer ? sprite.layer._read(sprite) : null
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* Remove a sprite. The handle goes inert (layer null); further setSprite
|
|
680
|
+
* calls are no-ops.
|
|
681
|
+
*/
|
|
682
|
+
export function removeSprite(sprite: Sprite): void {
|
|
683
|
+
sprite.layer?._remove(sprite)
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* Re-parent a sprite under a group (null = back to the layer root); its
|
|
688
|
+
* pose fields then read in the new parent's frame, where the sprite keeps
|
|
689
|
+
* them (it holds its local pose, not its world pose). Node layer only.
|
|
690
|
+
*/
|
|
691
|
+
export function setSpriteParent(sprite: Sprite, parent: SpriteGroup | null): void {
|
|
692
|
+
let layer = sprite.layer
|
|
693
|
+
if (!layer) return
|
|
694
|
+
if (sprite.node === null) throw new Error("setSpriteParent: record layers have no groups")
|
|
695
|
+
if (parent && parent.layer !== layer) throw new Error("setSpriteParent: group belongs to another layer")
|
|
696
|
+
spatial.setParent(sprite.node, parent ? parent.node : null)
|
|
697
|
+
layer._schedule()
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Declare (or with null clear) how the sprite's pose writes animate: once
|
|
702
|
+
* set, setSprite writes are TARGETS the core animates toward - JS writes
|
|
703
|
+
* once per target change, the core interpolates every frame, and a
|
|
704
|
+
* settled sprite costs nothing. The spatial vocabulary: a spec per
|
|
705
|
+
* component plus `all`, where `position` is x/y, `rotation` the sprite's
|
|
706
|
+
* rotation (always the short arc) and `scale` its w/h; each spec is
|
|
707
|
+
* `{ duration, bounce? }` (a spring, the retargeting-safe default) /
|
|
708
|
+
* `{ duration, curve }` (a tween) / a shorthand string like
|
|
709
|
+
* "300ms ease-out". Clearing cancels running tracks in place (the sprite
|
|
710
|
+
* keeps its mid-flight pose) and later writes snap. Each natural settle
|
|
711
|
+
* calls the sprite's `onTransitionEnd` with the component. Node layer
|
|
712
|
+
* only.
|
|
713
|
+
*/
|
|
714
|
+
export function setSpriteTransition(sprite: Sprite, transition: NodeTransition | string | null): void {
|
|
715
|
+
if (sprite.layer === null) return
|
|
716
|
+
if (sprite.node === null) throw new Error("setSpriteTransition: record sprites have no node transitions")
|
|
717
|
+
spatial.setTransition(sprite.node, transition)
|
|
718
|
+
declare(sprite.node, sprite, transition)
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/** The group counterpart of setSpriteTransition (`scale` is the group's
|
|
722
|
+
* uniform scale). */
|
|
723
|
+
export function setGroupTransition(group: SpriteGroup, transition: NodeTransition | string | null): void {
|
|
724
|
+
if (group.layer === null) return
|
|
725
|
+
spatial.setTransition(group.node, transition)
|
|
726
|
+
declare(group.node, group, transition)
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/** Add a transform group (see SpriteGroup). */
|
|
730
|
+
export function addGroup(layer: SpriteLayer, opts?: GroupOptions): SpriteGroup {
|
|
731
|
+
let group = {
|
|
732
|
+
layer,
|
|
733
|
+
_x: opts?.x ?? 0,
|
|
734
|
+
_y: opts?.y ?? 0,
|
|
735
|
+
_rot: opts?.rotation ?? 0,
|
|
736
|
+
_scale: opts?.scale ?? 1,
|
|
737
|
+
} as SpriteGroup
|
|
738
|
+
writeGroupTransform(group)
|
|
739
|
+
group.node = spatial.createNode(TRANSFORM, true)
|
|
740
|
+
if (opts?.parent) {
|
|
741
|
+
if (opts.parent.layer !== layer) throw new Error("addGroup: parent group belongs to another layer")
|
|
742
|
+
spatial.setParent(group.node, opts.parent.node)
|
|
743
|
+
}
|
|
744
|
+
layer._groups.add(group)
|
|
745
|
+
layer._schedule()
|
|
746
|
+
return group
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function writeGroupTransform(group: SpriteGroup): void {
|
|
750
|
+
fillTransform(group._x, group._y, group._rot, group._scale, group._scale)
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/** Update a group: absent keys keep values (the params rule). */
|
|
754
|
+
export function setGroup(group: SpriteGroup, opts: GroupOptions): void {
|
|
755
|
+
let layer = group.layer
|
|
756
|
+
if (!layer) return
|
|
757
|
+
let moved = false
|
|
758
|
+
if (opts.x !== undefined && opts.x !== group._x) (group._x = opts.x), (moved = true)
|
|
759
|
+
if (opts.y !== undefined && opts.y !== group._y) (group._y = opts.y), (moved = true)
|
|
760
|
+
if (opts.rotation !== undefined && opts.rotation !== group._rot) (group._rot = opts.rotation), (moved = true)
|
|
761
|
+
if (opts.scale !== undefined && opts.scale !== group._scale) (group._scale = opts.scale), (moved = true)
|
|
762
|
+
if (moved) {
|
|
763
|
+
writeGroupTransform(group)
|
|
764
|
+
spatial.writeTransform(group.node, TRANSFORM)
|
|
765
|
+
}
|
|
766
|
+
if (opts.parent !== undefined) {
|
|
767
|
+
if (opts.parent && opts.parent.layer !== layer) throw new Error("setGroup: parent group belongs to another layer")
|
|
768
|
+
spatial.setParent(group.node, opts.parent ? opts.parent.node : null)
|
|
769
|
+
moved = true
|
|
770
|
+
}
|
|
771
|
+
if (moved) layer._schedule()
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* Remove a group: its children (sprites and groups) become layer roots and
|
|
776
|
+
* KEEP THEIR LOCAL POSE, so they jump to root frame unless the caller
|
|
777
|
+
* removes or re-parents them too (a component tree unmounts children first
|
|
778
|
+
* and never sees this). The handle goes inert.
|
|
779
|
+
*/
|
|
780
|
+
export function removeGroup(group: SpriteGroup): void {
|
|
781
|
+
let layer = group.layer
|
|
782
|
+
if (!layer) return
|
|
783
|
+
group.layer = null
|
|
784
|
+
layer._groups.delete(group)
|
|
785
|
+
declared.delete(group.node)
|
|
786
|
+
spatial.destroyNode(group.node)
|
|
787
|
+
layer._schedule()
|
|
788
|
+
}
|