@solidrt/2d 0.0.55 → 0.0.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/layer.ts CHANGED
@@ -2,17 +2,18 @@
2
2
  // SPATIAL ARENA node (arena slot: local pose, parent, world matrix, index
3
3
  // leaf, record sink - no layout, no paint, no rendertree element) whose
4
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.
5
+ // the pose instance buffer at the core's flush. Rendering is one
6
+ // instanced draw per VIEW (views.ts: the layer shows through its views
7
+ // only, each a target with a camera of its own); what changed is who owns
8
+ // the pose upstream of the instance buffer - the arena, so every core
9
+ // producer (native transitions, animation clips, physics) reaches sprites
10
+ // through `sprite.node`, and picking walks the core BVH instead of a JS
11
+ // loop.
11
12
  //
12
13
  // Two instance-buffer slots split ownership: slot 0 is the pose buffer,
13
14
  // 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
15
+ // nodes moved); slot 1 is the style buffer [u0, v0, u1, v1, tint rgba,
16
+ // renderOrder], JS-owned and published through the zero-copy write lease. Never write
16
17
  // the pose buffer from JS - the core's staging mirror is the owner and
17
18
  // will overwrite. For motion only JS can compute at large populations, the
18
19
  // records layer (records.ts) is the escape hatch.
@@ -20,43 +21,69 @@
20
21
  // Sprites hold FIXED instance slots (freed slots recycle): draw order is
21
22
  // slot order, so removal never shifts records and pose sinks never rebind.
22
23
  // 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
+ // frame. A view's camera (offset, zoom, rotation about a pivot -
25
+ // CameraUpdate in camera.ts) is a shared-params write (uCamera +
26
+ // uCameraRot) on that view's target, never per-sprite; pointer dispatch
27
+ // (dispatch.ts) undoes it with unprojectCamera.
24
28
  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"
29
+ import type { PointerEvent as ElementPointerEvent, WheelEvent as ElementWheelEvent } from "@solidrt/core"
30
+ import { beginBufferWrite, createBuffer, destroyBuffer, endBufferWrite } from "@solidrt/core/gpu"
37
31
  import type { BufferId, TextureId } from "@solidrt/core/gpu"
38
32
  import * as spatial from "flux:spatial"
39
- import type { NodeId, NodeTransition } from "flux:spatial"
33
+ import type { Impact as CoreImpact, MoveOptions as CoreMoveOptions, NodeId, NodeTransition, QueryFilter } from "flux:spatial"
40
34
  import { on } from "srt:events"
41
- import { checkOversample } from "./oversample.ts"
35
+ import type { CameraState, CameraUpdate } from "./camera.ts"
42
36
  import type { Frame } from "./frames.ts"
43
37
  import { FULL_FRAME, writeFrame } from "./frames.ts"
44
38
  import type { RecordLayer } from "./records.ts"
45
- import { FRAGMENT, INSTANCE_ATTRIBUTES_SPLIT, VERTEX_SPLIT } from "./shaders.ts"
39
+ import { createSpritePipeline, INSTANCE_ATTRIBUTES_SPLIT, VERTEX_SPLIT } from "./shaders.ts"
40
+ import { createViews } from "./views.ts"
41
+ import type { ViewHandle, ViewOptions } from "./views.ts"
46
42
 
47
43
  /** Floats per pose record (the core's Pose2D projection). */
48
44
  export const POSE_FLOATS = 5
49
- /** Floats per style record: [u0, v0, u1, v1, tintR, tintG, tintB, tintA]. */
50
- export const STYLE_FLOATS = 8
45
+
46
+ // Float offset of world y in a pose record - what `orderBy: "y"` keys on.
47
+ const POSE_Y_FIELD = 1
48
+ /** Floats per style record:
49
+ * [u0, v0, u1, v1, tintR, tintG, tintB, tintA, renderOrder]. */
50
+ export const STYLE_FLOATS = 9
51
+
52
+ // Float offset of renderOrder in a style record - what `orderBy: "renderOrder"`
53
+ // keys on.
54
+ const STYLE_KEY_FIELD = 8
51
55
 
52
56
  const RESOLVED = Promise.resolve()
53
57
 
54
58
  // Shared marshalling scratch (the bindings copy synchronously).
55
59
  const TRANSFORM = new Float32Array(10)
56
- const FLAT_BOUNDS = new Float32Array([-0.5, -0.5, 0, 0.5, 0.5, 0])
60
+ // Half depth of a sprite's index box along z, node units (a sprite's z
61
+ // scale is 1, so world units too). A sprite is a COLUMN in the index, not
62
+ // a flat quad: a volume query at z = 0 then only ever meets its side
63
+ // faces, so every contact normal and depth is in the plane, and a circle
64
+ // whose center lies over a sprite pushes out sideways instead of along z
65
+ // (the core's contact against a flat triangle is the plane normal). Far
66
+ // larger than any volume's reach, so the end faces never take part.
67
+ const SPRITE_DEPTH = 1e4
68
+ const COLUMN_BOUNDS = new Float32Array([-0.5, -0.5, -SPRITE_DEPTH, 0.5, 0.5, SPRITE_DEPTH])
57
69
  const RAY_ORIGIN = new Float32Array(3)
58
- const RAY_DIR = new Float32Array([0, 0, 1])
59
- const BOX = new Float32Array(6)
70
+ const RAY_DIR = new Float32Array(3)
71
+ // Volume scratch: a "capsule" is a, b, radius (7 floats), a "box" is
72
+ // center, half extents, quaternion (10); both packed at z = 0.
73
+ const CAPSULE = new Float32Array(7)
74
+ const BOX = new Float32Array(10)
75
+ const MOTION = new Float32Array(3)
76
+ // A box volume's half depth: any positive value inside the columns.
77
+ const BOX_HALF_DEPTH = 1
78
+ // The pick ray starts this far in front of the plane; it crosses every
79
+ // column at z = 0 whatever the start, but a start inside the column meets
80
+ // only its far end face, which is fine for pick (draw order sorts, not
81
+ // distance).
82
+ const PICK_RAY_START = -1
83
+ // The direction floors face in a y-down world.
84
+ const UP_2D: [number, number] = [0, -1]
85
+ // worldPosition's world-matrix scratch (column-major; translation at 12, 13).
86
+ const WORLD = new Float32Array(16)
60
87
 
61
88
  // Settle routing: the core's "spatialTransitionEnd" event carries the node
62
89
  // id, so the handles with a transition DECLARED are indexed by node (only
@@ -91,37 +118,60 @@ function declareTransition(node: NodeId, handle: Sprite | SpriteGroup, transitio
91
118
  * fields - they touch no GPU state (the scene-graph handler rule).
92
119
  */
93
120
  export type Sprite = {
94
- /** The owning layer, null after removeSprite. */
95
- layer: SpriteLayer | RecordLayer | null
121
+ /** The owning layer, null after removeSprite (which owns the write). */
122
+ readonly layer: SpriteLayer | RecordLayer | null
96
123
  /**
97
124
  * The sprite's SPATIAL ARENA node - the citizenship handle: bind core
98
125
  * producers to it or reach it through flux:spatial directly (the layer
99
126
  * still owns the node's life; destroy it only via removeSprite). Null
100
127
  * on a record layer's sprites.
101
128
  */
102
- node: NodeId | null
129
+ readonly node: NodeId | null
103
130
  /** Instance slot: fixed for the sprite's life on the node layer, the
104
- * shifting draw-order index on a record layer. */
105
- _slot: number
106
- /** Pose mirror (node layer): what setSprite composes transforms from. */
107
- _x: number
108
- _y: number
109
- _w: number
110
- _h: number
111
- _rot: number
112
- /** Mirror flags (both layer kinds): re-applied to every frame write. */
113
- _flipX: boolean
114
- _flipY: boolean
131
+ * shifting draw-order index on a record layer. Readable (readonly, like
132
+ * the other underscore fields: cheap reads without getSprite's
133
+ * allocation); writes go through setSprite. */
134
+ readonly _slot: number
135
+ /** Pose mirror (node layer): what setSprite composes transforms from.
136
+ * On a RECORD layer this mirror goes stale once records are written
137
+ * raw - there the records array is the truth. */
138
+ readonly _x: number
139
+ readonly _y: number
140
+ readonly _w: number
141
+ readonly _h: number
142
+ readonly _rot: number
143
+ /** Mirror flags (both layer kinds): re-applied to every frame write.
144
+ * The cheap way to read flip state. */
145
+ readonly _flipX: boolean
146
+ readonly _flipY: boolean
147
+ /** Visibility mirror (node layer; always true on a record layer's
148
+ * sprites). The cheap read; writes go through setSprite. */
149
+ readonly _visible: boolean
150
+ /** The enclosing group (null at the layer root, always null on a record
151
+ * layer's sprites) - the bubble path; writes go through
152
+ * setSpriteParent/addSprite's `parent`. */
153
+ readonly _parent: SpriteGroup | null
115
154
  onPointerDown?: (event: SpritePointerEvent) => void
116
155
  onPointerMove?: (event: SpritePointerEvent) => void
117
156
  onPointerUp?: (event: SpritePointerEvent) => void
118
157
  onPointerEnter?: (event: SpritePointerEvent) => void
119
158
  onPointerLeave?: (event: SpritePointerEvent) => void
159
+ /** A wheel notch over the sprite; bubbles like down/move/up. */
160
+ onWheel?: (event: SpriteWheelEvent) => void
161
+ /** A press that released on this sprite without dragging (see
162
+ * SpriteTapEvent); bubbles like down/move/up. */
163
+ onTap?: (event: SpriteTapEvent) => void
120
164
  /** A declared transition (setSpriteTransition) settled naturally on
121
165
  * one component; a cancel or snap never fires. */
122
166
  onTransitionEnd?: (event: TransitionEndEvent) => void
123
167
  }
124
168
 
169
+ /** The layer-internal mutable view of a Sprite: the readonly on the
170
+ * underscore fields is for the public surface; internal write sites
171
+ * annotate with this (readonly does not affect assignability, so the two
172
+ * types flow into each other freely). */
173
+ export type SpriteState = { -readonly [K in keyof Sprite]: Sprite[K] }
174
+
125
175
  /** The settled component of a node transition: `position` is x/y,
126
176
  * `scale` w/h (a group's uniform scale). */
127
177
  export type TransitionEndEvent = {
@@ -148,17 +198,41 @@ export type SpriteOptions = {
148
198
  rotation?: number
149
199
  /** RGBA multiplier 0..1 each; default opaque white (the texture as-is). */
150
200
  tint?: [number, number, number, number]
201
+ /**
202
+ * Explicit draw-order key, read only by a layer created with `orderBy:
203
+ * "renderOrder"` (default 0; ties keep slot order, so untouched sprites draw
204
+ * as without one). The raise idiom: `setSprite(hit, { renderOrder: ++top })`
205
+ * on interaction, back to 0 to restore. Node layer only - a record
206
+ * layer's 13-float record has no key field (order it by one of its own
207
+ * fields with `orderBy: { field }`); setting this there throws.
208
+ */
209
+ renderOrder?: number
210
+ /**
211
+ * Show or hide the sprite (default true), @solidrt/3d's setVisible in
212
+ * the setSprite bag: hidden, its pose slot zeroes (nothing drawn) and
213
+ * pick/pickRect skip it; the handle, slot, style records and any
214
+ * running transition state stay, so showing again restores the sprite
215
+ * as it was. Node layer only - a record sprite has no node (hide it by
216
+ * zeroing w or h); setting this there throws.
217
+ */
218
+ visible?: boolean
151
219
  }
152
220
 
221
+ /**
222
+ * addSprite's bag: the sprite fields plus the mount point. The pool idiom
223
+ * for a game: reserve `capacity`, add every sprite at mount with `visible:
224
+ * false`, then show/hide - a hidden sprite keeps its slot, so draw order
225
+ * stays stable and a spawn is one setSprite, never an add.
226
+ */
153
227
  export type AddSpriteOptions = SpriteOptions & {
154
- /** Mount under this group (node layer only); pose fields are then local
155
- * to it. Reparent later with setSpriteParent. */
156
- parent?: SpriteGroup
228
+ /** Mount under this group (node layer only; null = the layer root, the
229
+ * default); pose fields are then local to it. Reparent later with
230
+ * setSpriteParent. */
231
+ parent?: SpriteGroup | null
157
232
  }
158
233
 
159
- export type SpritePointerEvent = {
160
- /** The sprite hit (the topmost at the point), constant while captured. */
161
- sprite: Sprite
234
+ /** What every layer pointer event carries, whichever handler sees it. */
235
+ export type LayerEventBase = {
162
236
  /** Pointer position in LAYER pixels (the camera mapping undone). */
163
237
  x: number
164
238
  y: number
@@ -169,6 +243,74 @@ export type SpritePointerEvent = {
169
243
  ctrlKey: boolean
170
244
  altKey: boolean
171
245
  metaKey: boolean
246
+ /**
247
+ * The element event the layer's leaf received, in the leaf's own frame
248
+ * (localX/localY, clientX/clientY, movementX/Y): what core's
249
+ * recognizers consume, so a sprite drags itself through `createPan` and
250
+ * a camera rides `createTransform` on the same events the walk carries.
251
+ */
252
+ native: ElementPointerEvent
253
+ /**
254
+ * Stops the walk after the current handler: no enclosing group and none
255
+ * of the layer's listeners see the event. Stopping a DOWN claims the
256
+ * whole press - that pointer's move, up and tap never reach the layer
257
+ * either, so a sprite that drags itself stops its down once and a
258
+ * camera attached at the root never pans it.
259
+ */
260
+ stopPropagation(): void
261
+ }
262
+
263
+ /** The event as a sprite's or group's handler sees it. */
264
+ export type SpritePointerEvent = LayerEventBase & {
265
+ /** The sprite the event is about (the topmost hit at the point, or the
266
+ * captured sprite during a drag) - constant while the event bubbles. */
267
+ sprite: Sprite
268
+ /** The sprite or group whose handler is running; changes as the event
269
+ * bubbles from the hit sprite through its enclosing groups. */
270
+ currentTarget: Sprite | SpriteGroup
271
+ }
272
+
273
+ /**
274
+ * The event as the VIEW's listeners see it (ViewHandle.listen), the last
275
+ * stop of the walk: `sprite` is the hit sprite it bubbled from, or null
276
+ * over empty space, where the view is the only target.
277
+ */
278
+ export type LayerPointerEvent = LayerEventBase & {
279
+ sprite: Sprite | null
280
+ currentTarget: ViewHandle
281
+ }
282
+
283
+ type WheelFields = {
284
+ /** The wheel delta as the element event reports it. */
285
+ deltaX: number
286
+ deltaY: number
287
+ native: ElementWheelEvent
288
+ }
289
+ type TapFields = {
290
+ /** 1 for a tap, 2 for the second of a double tap (the same target,
291
+ * within the repeat interval and distance), and so on - DOM's `detail`,
292
+ * Unity's `clickCount`. */
293
+ tapCount: number
294
+ }
295
+
296
+ export type SpriteWheelEvent = SpritePointerEvent & WheelFields
297
+ export type LayerWheelEvent = LayerPointerEvent & WheelFields
298
+ /**
299
+ * A press that released on the target it pressed without travelling past
300
+ * the slop, the only pointer down for its whole press (a pinch never taps).
301
+ * Dispatched after the up, bubbling the same way; `x`/`y` are the release
302
+ * point.
303
+ */
304
+ export type SpriteTapEvent = SpritePointerEvent & TapFields
305
+ export type LayerTapEvent = LayerPointerEvent & TapFields
306
+
307
+ /** A listener at a view's root; see ViewHandle.listen. */
308
+ export type LayerPointerListener = {
309
+ onPointerDown?: (event: LayerPointerEvent) => void
310
+ onPointerMove?: (event: LayerPointerEvent) => void
311
+ onPointerUp?: (event: LayerPointerEvent) => void
312
+ onWheel?: (event: LayerWheelEvent) => void
313
+ onTap?: (event: LayerTapEvent) => void
172
314
  }
173
315
 
174
316
  export type SpriteHandlers = {
@@ -176,14 +318,15 @@ export type SpriteHandlers = {
176
318
  onPointerMove: (event: ElementPointerEvent) => void
177
319
  onPointerUp: (event: ElementPointerEvent) => void
178
320
  onPointerLeave: (event: ElementPointerEvent) => void
321
+ onWheel: (event: ElementWheelEvent) => void
179
322
  }
180
323
 
181
- export type CameraUpdate = {
182
- /** World pixel at the viewport's top-left corner. */
183
- x?: number
184
- y?: number
185
- /** World-to-screen scale; 1 is pixel-for-pixel. */
186
- zoom?: number
324
+ /** Validate an [r, g, b, a] tint (throws - the dev validation policy).
325
+ * Internal - every layer kind (sprite, record, tile) calls it. */
326
+ export function checkTint(verb: string, tint: [number, number, number, number]): void {
327
+ if (!(Array.isArray(tint) && tint.length === 4 && tint.every(Number.isFinite))) {
328
+ throw new Error(`${verb}: tint must be [r, g, b, a], got ${JSON.stringify(tint)}`)
329
+ }
187
330
  }
188
331
 
189
332
  export type SpriteLayerOptions = {
@@ -193,19 +336,39 @@ export type SpriteLayerOptions = {
193
336
  * limit.
194
337
  */
195
338
  capacity?: number
196
- clearColor?: [number, number, number, number]
339
+ /** Layer tint, [r, g, b, a] in 0..1, multiplied over every sprite's own
340
+ * tint in every view; default opaque white (sprites as-is). See
341
+ * setTint. */
342
+ tint?: [number, number, number, number]
343
+ /** Names the GPU resources (buffers, pipeline; views default to
344
+ * `<label>-view`); default "sprites". */
197
345
  label?: string
198
- /**
199
- * Target texels per layer pixel (positive integer, default 1). The layer
200
- * renders at `oversample` times its size and is composited down, so a
201
- * fractional or HiDPI display scale resamples properly instead of snapping
202
- * (nearest) or smearing (linear); see setOversample. The components pick
203
- * it from the leaf's on-screen size - set it here when composing the
204
- * output yourself.
205
- */
206
- oversample?: number
207
346
  /** Skip the owner-scoped auto-dispose (see createSpriteLayer). */
208
347
  autoFree?: boolean
348
+ /**
349
+ * Draw sprites in KEY order instead of slot order, produced by core at
350
+ * every publish (the gpu `instanceOrder` primitive across the pose/style
351
+ * buffer pair - no per-sprite JS anywhere): `"y"` keys on the sprite's
352
+ * WORLD y, the pose the core itself writes, so a perspective crowd
353
+ * paints back to front (smaller y = further up the screen = drawn
354
+ * first) - and because the key is core-owned, sprites moved by native
355
+ * transitions or any other core producer re-sort with zero JS per
356
+ * frame. Slots stay fixed - handles, picking and the style records are
357
+ * untouched, only the draw order changes - and ties keep slot order, so
358
+ * sprites at equal y draw exactly as without it.
359
+ *
360
+ * `"renderOrder"` keys on the app-owned per-sprite `renderOrder` field instead
361
+ * (setSprite, default 0): explicit layering for painter-order scenes -
362
+ * raise a dragged piece, click-to-front, hover emphasis - with stable
363
+ * handles and no record churn.
364
+ *
365
+ * Known limitation for both keys: pick() resolves overlapping sprites by
366
+ * slot order, not visual order, when a key is set. (The record layer's
367
+ * RecordLayerOptions instead takes `"y"` or a raw `{ field }` offset
368
+ * into its own records; pose records are core-owned, so the node layer
369
+ * names its keys.)
370
+ */
371
+ orderBy?: "y" | "renderOrder"
209
372
  }
210
373
 
211
374
  /**
@@ -213,21 +376,44 @@ export type SpriteLayerOptions = {
213
376
  * uniform scale - never a sprite size) that sprites and other groups
214
377
  * parent under, so a ship with turrets or a dragged stack moves as one
215
378
  * subtree recomputed in native code. Groups render nothing and cannot be
216
- * picked; sprites are always the leaves.
379
+ * picked - sprites are always the leaves - but down/move/up events bubble
380
+ * from a hit child sprite through its enclosing groups (enter/leave pair
381
+ * on the sprite alone), so one group handler covers a whole assembly.
217
382
  */
218
383
  export type SpriteGroup = {
219
- /** The owning layer, null after removeGroup. */
220
- layer: SpriteLayer | null
384
+ /** The owning layer, null after removeGroup (which owns the write). */
385
+ readonly layer: SpriteLayer | null
221
386
  /** The group's spatial arena node. */
222
- node: NodeId
223
- _x: number
224
- _y: number
225
- _rot: number
226
- _scale: number
387
+ readonly node: NodeId
388
+ /** Pose mirror (readonly, like Sprite's underscore fields: cheap reads);
389
+ * writes go through setGroup. */
390
+ readonly _x: number
391
+ readonly _y: number
392
+ readonly _rot: number
393
+ readonly _scale: number
394
+ /** Visibility mirror; writes go through setGroup. */
395
+ readonly _visible: boolean
396
+ /** See Sprite._parent. */
397
+ readonly _parent: SpriteGroup | null
398
+ /** The handles parented here (removeGroup removes them with it);
399
+ * internal -
400
+ * membership writes go through addSprite/addGroup/setSpriteParent/
401
+ * setGroup. */
402
+ readonly _children: Set<SpriteState | GroupState>
403
+ /** Bubbled from a hit child sprite; see SpritePointerEvent. A group
404
+ * never receives enter/leave (hover pairs on the sprite alone). */
405
+ onPointerDown?: (event: SpritePointerEvent) => void
406
+ onPointerMove?: (event: SpritePointerEvent) => void
407
+ onPointerUp?: (event: SpritePointerEvent) => void
408
+ onWheel?: (event: SpriteWheelEvent) => void
409
+ onTap?: (event: SpriteTapEvent) => void
227
410
  /** See Sprite.onTransitionEnd. */
228
411
  onTransitionEnd?: (event: TransitionEndEvent) => void
229
412
  }
230
413
 
414
+ /** The layer-internal mutable view of a SpriteGroup (see SpriteState). */
415
+ export type GroupState = { -readonly [K in keyof SpriteGroup]: SpriteGroup[K] }
416
+
231
417
  /** Group fields, all optional: absent keys keep values. */
232
418
  export type GroupOptions = {
233
419
  /** Position in the parent frame (layer pixels at the root). */
@@ -238,142 +424,185 @@ export type GroupOptions = {
238
424
  /** Uniform scale on the whole subtree (this one scales child sprites -
239
425
  * a group is a frame, not a sprite size). */
240
426
  scale?: number
427
+ /** Show or hide the group's WHOLE subtree (default true); each child
428
+ * keeps its own `visible`, so showing the group again restores the
429
+ * subtree as it was. See SpriteOptions.visible. */
430
+ visible?: boolean
241
431
  /** Reparent (null = make the group a root). */
242
432
  parent?: SpriteGroup | null
243
433
  }
244
434
 
245
435
  /** What both layer kinds share; the free sprite functions dispatch on it. */
436
+ /**
437
+ * What both layer kinds share: the sprites and what applies to all of
438
+ * them. A layer has no output of its own - it shows through its views
439
+ * (createView), each a target with a camera, a size and pointer dispatch
440
+ * of its own (ViewHandle), as a Unity scene shows only through Cameras
441
+ * and a Godot World2D only through Viewports.
442
+ */
246
443
  export type LayerBase = {
247
- /** The layer's output: an ordinary texture id (`<texture src>`). */
248
- texture: TextureId
249
- /** Element handlers wiring sprite pointer events; see handlersFor. */
250
- handlers: SpriteHandlers
251
444
  /** Live sprite count. */
252
445
  readonly count: number
253
- setSize(width: number, height: number): void
254
- /** Target texels per layer pixel; see setOversample. */
255
- readonly oversample: number
256
446
  /**
257
- * Re-render at `n` target texels per layer pixel (positive integer): the
258
- * target resizes in place at its stable id, layer pixels, records, camera
259
- * and picking are untouched. Pick `n` as the ceiling of the device pixels
260
- * one layer pixel covers on screen (display scale times any designSize
261
- * fit or layout scaling), which the components do in onLayout.
447
+ * Tint the whole layer, [r, g, b, a] in 0..1: a uniform multiplied over
448
+ * every sprite's own tint (day/night, a dimmed parallax plane, a
449
+ * fade-in) in every view. One shared-params write per view - no record
450
+ * touches, cheap to animate - and the same contract as
451
+ * TileLayer.setTint, so one signal drives a whole scene across layer
452
+ * kinds.
262
453
  */
263
- setOversample(n: number): void
264
- setCamera(update: CameraUpdate): void
265
- /** Topmost sprite whose rotated rect contains the layer-pixel point. */
266
- pick(x: number, y: number): Sprite | null
454
+ setTint(tint: [number, number, number, number]): void
455
+ /** Every shown sprite whose rotated rect contains the layer-pixel point
456
+ * (a hidden sprite or subtree is never hit), topmost first - the
457
+ * all-hits shape of @solidrt/3d's pick; `pick(x, y)[0]` is the topmost.
458
+ * Topmost means draw order (highest slot on the node layer, last added
459
+ * on a record layer); an `orderBy` key is not consulted (see
460
+ * SpriteLayerOptions.orderBy's known limitation). World space: a view
461
+ * undoes its camera before asking. */
462
+ pick(x: number, y: number): Sprite[]
267
463
  /**
268
- * Handlers for a leaf whose LAYOUT size differs from the layer size
269
- * (events scale by layer/layout; null layout means "already layer pixels",
270
- * which is what the built-in leaf uses).
464
+ * A rendering of the layer's world from a camera of its own - the main
465
+ * view, a minimap, a zoomed inset, one split-screen pane -
466
+ * @solidrt/3d's scene.createView one dimension down. One more target
467
+ * drawing the SAME instance buffers (no sprite is mirrored, no per-frame
468
+ * JS beyond the view's camera writes), key order included; the layer's
469
+ * tint fans out to it. The view carries the viewport contract (texture,
470
+ * setSize, setOversample, setCamera, listen and handlers with sprite
471
+ * events walking to the view as root); sprites stay the layer's. Views
472
+ * die with the layer.
271
473
  */
272
- handlersFor(layout: (() => { width: number; height: number } | null) | null): SpriteHandlers
474
+ createView(opts: ViewOptions): ViewHandle
273
475
  dispose(): void
274
476
  _add(opts?: AddSpriteOptions): Sprite
275
- _write(sprite: Sprite, opts: SpriteOptions): void
477
+ _write(sprite: SpriteState, opts: SpriteOptions): void
276
478
  _read(sprite: Sprite): Required<SpriteOptions>
277
- _remove(sprite: Sprite): void
479
+ _remove(sprite: SpriteState): void
278
480
  _schedule(): void
279
481
  }
280
482
 
483
+ /** A query volume for overlap/sweep/moveAndSlide, in layer pixels: a
484
+ * circle, a capsule (the radius swept along the segment a-b: a character)
485
+ * or a rect (`rotation` radians about its center, 0 when absent) - Godot's
486
+ * CircleShape2D/CapsuleShape2D/RectangleShape2D, @solidrt/3d's Volume one
487
+ * dimension down. */
488
+ export type Circle = { x: number; y: number; radius: number }
489
+ export type Capsule = { ax: number; ay: number; bx: number; by: number; radius: number }
490
+ export type Rect = { x: number; y: number; width: number; height: number; rotation?: number }
491
+ export type Volume = Circle | Capsule | Rect
492
+
493
+ /** Filters for one raycast/overlap/sweep/moveAndSlide query. */
494
+ export type QueryOptions = {
495
+ /** Only these sprites report hits (an include-list); every shown sprite
496
+ * of the layer otherwise. */
497
+ sprites?: Sprite[]
498
+ }
499
+
500
+ /** One sprite a raycast() ray strikes: the distance along the ray, the
501
+ * point on the sprite's edge and the unit edge normal facing the ray. */
502
+ export type RayHit = { sprite: Sprite; distance: number; point: [number, number]; normal: [number, number] }
503
+
504
+ /** One sprite an overlap() volume touches: its deepest contact - the
505
+ * point on the sprite's edge, the unit direction out of the sprite, and
506
+ * the depth along that direction that clears the contact. */
507
+ export type Overlap = { sprite: Sprite; point: [number, number]; normal: [number, number]; depth: number }
508
+
509
+ /** One sprite a sweep() volume touches on its way: `time` is the fraction
510
+ * of the motion at first touch (0 for a volume already in contact and
511
+ * moving in), `point` the touch point on the sprite's edge, `normal` the
512
+ * unit normal there facing the volume. */
513
+ export type Impact = { sprite: Sprite; time: number; point: [number, number]; normal: [number, number] }
514
+
515
+ export type MoveOptions = QueryOptions & {
516
+ /** The direction floors face (default [0, -1]: y-down, floors face up
517
+ * the screen). */
518
+ up?: [number, number]
519
+ /** Largest angle (radians) between a contact normal and `up` that still
520
+ * counts as floor (default 45 degrees). Steeper contacts are walls, and
521
+ * the body slides down them. */
522
+ floorMaxAngle?: number
523
+ /** Most contacts one call slides along (default 6). */
524
+ maxSlides?: number
525
+ /** Gap kept from every surface, layer pixels (default 0.01). */
526
+ skin?: number
527
+ /** How far below the body a floor is pulled to when the motion does not
528
+ * rise (default 0.1; 0 disables): what keeps a walker on a ramp going
529
+ * down, and what decides `floor` at the end of the move - a body that
530
+ * ends higher than this above its floor is airborne, whatever it
531
+ * touched on the way. A rising motion (a jump) never snaps. */
532
+ floorSnap?: number
533
+ }
534
+
535
+ export type MoveResult = {
536
+ /** The displacement the body gets: add it to the body's position. */
537
+ motion: [number, number]
538
+ /** The unit normal of the floor the body ends the move on - within
539
+ * `floorSnap` below it, snapped onto - else null (airborne, or on a
540
+ * slope too steep to stand on). With `floorSnap: 0` it is a floor met
541
+ * during the move instead. */
542
+ floor: [number, number] | null
543
+ /** Whether a wall (a contact steeper than a floor and flatter than a
544
+ * ceiling) or a ceiling was met. */
545
+ wall: boolean
546
+ ceiling: boolean
547
+ /** Every contact met, in order, the floor snap's last. */
548
+ hits: Impact[]
549
+ }
550
+
281
551
  export type SpriteLayer = LayerBase & {
282
552
  /**
283
- * Every sprite whose rotated rect overlaps the layer-pixel rect (the
284
- * core BVH overlap query, exact for rotated sprites), unordered - the
285
- * marquee query. Node layer only.
553
+ * Every shown sprite whose rotated rect overlaps the layer-pixel rect
554
+ * (the core BVH overlap query, exact for rotated sprites), unordered -
555
+ * the marquee query: `overlap` over an unrotated rect, sprites only.
556
+ * Node layer only.
286
557
  */
287
- pickRect(x: number, y: number, w: number, h: number): Sprite[]
288
- _groups: Set<SpriteGroup>
289
- }
290
-
291
- /**
292
- * Pointer dispatch shared by both layer kinds: capture per pointer, hover
293
- * pairing, no bubbling (the sprite list is flat). Layout null = the leaf is
294
- * laid out at layer size, so localX/localY are layer pixels already (the
295
- * element hit test undid every ancestor transform - never getBoundingBox
296
- * here). Internal - layers expose the result as handlers/handlersFor.
297
- */
298
- export function spriteDispatch(state: {
299
- size: () => [number, number]
300
- camera: () => [number, number, number]
301
- pick: (x: number, y: number) => Sprite | null
302
- }): (layout: (() => { width: number; height: number } | null) | null) => SpriteHandlers {
303
- let capture = new Map<number, Sprite>()
304
- let hover = new Map<number, Sprite>()
305
- return layout => {
306
- let toLayer = (e: ElementPointerEvent): [number, number] => {
307
- let x = e.localX
308
- let y = e.localY
309
- let l = layout?.()
310
- let [width, height] = state.size()
311
- if (l && l.width > 0 && l.height > 0) {
312
- x *= width / l.width
313
- y *= height / l.height
314
- }
315
- // Undo the camera: screen -> world.
316
- let [camX, camY, camZoom] = state.camera()
317
- return [x / camZoom + camX, y / camZoom + camY]
318
- }
319
- let makeEvent = (sprite: Sprite, x: number, y: number, e: ElementPointerEvent): SpritePointerEvent => ({
320
- sprite,
321
- x,
322
- y,
323
- pointerId: e.pointerId,
324
- pointerType: e.pointerType,
325
- button: e.button,
326
- shiftKey: e.shiftKey,
327
- ctrlKey: e.ctrlKey,
328
- altKey: e.altKey,
329
- metaKey: e.metaKey,
330
- })
331
- return {
332
- onPointerDown(e) {
333
- let [x, y] = toLayer(e)
334
- let hit = state.pick(x, y)
335
- if (!hit) return
336
- capture.set(e.pointerId, hit)
337
- hit.onPointerDown?.(makeEvent(hit, x, y, e))
338
- },
339
- onPointerMove(e) {
340
- let [x, y] = toLayer(e)
341
- let captured = capture.get(e.pointerId)
342
- if (captured) {
343
- if (captured.layer) captured.onPointerMove?.(makeEvent(captured, x, y, e))
344
- return
345
- }
346
- let hit = state.pick(x, y)
347
- let prev = hover.get(e.pointerId) ?? null
348
- if (prev !== hit) {
349
- if (prev && prev.layer) prev.onPointerLeave?.(makeEvent(prev, x, y, e))
350
- if (hit) hit.onPointerEnter?.(makeEvent(hit, x, y, e))
351
- if (hit) hover.set(e.pointerId, hit)
352
- else hover.delete(e.pointerId)
353
- }
354
- hit?.onPointerMove?.(makeEvent(hit, x, y, e))
355
- },
356
- onPointerUp(e) {
357
- let [x, y] = toLayer(e)
358
- let captured = capture.get(e.pointerId)
359
- if (captured) {
360
- capture.delete(e.pointerId)
361
- if (captured.layer) captured.onPointerUp?.(makeEvent(captured, x, y, e))
362
- return
363
- }
364
- let hit = state.pick(x, y)
365
- hit?.onPointerUp?.(makeEvent(hit, x, y, e))
366
- },
367
- onPointerLeave(e) {
368
- let [x, y] = toLayer(e)
369
- let prev = hover.get(e.pointerId)
370
- if (prev) {
371
- hover.delete(e.pointerId)
372
- if (prev.layer) prev.onPointerLeave?.(makeEvent(prev, x, y, e))
373
- }
374
- },
375
- }
376
- }
558
+ pickRect(x: number, y: number, width: number, height: number): Sprite[]
559
+ /**
560
+ * Every shown sprite the ray from (x, y) along (dx, dy) strikes, nearest
561
+ * first, with the distance (layer pixels along the normalized
562
+ * direction), the point on the sprite's edge and the edge normal.
563
+ * `pick` is the point form; this is the shot and the line of sight.
564
+ * Reads the index as of the last flush, the pending batch run first,
565
+ * like pick.
566
+ */
567
+ raycast(x: number, y: number, dx: number, dy: number, opts?: QueryOptions): RayHit[]
568
+ /**
569
+ * Every shown sprite the volume touches, each with its deepest contact
570
+ * (Godot's intersect_shape, Unity's OverlapCircle/Box/Capsule: a blast
571
+ * radius, a pickup range, a melee arc), unordered. Sprites are tested
572
+ * as their rotated rects, so any rotation holds; a volume wholly inside
573
+ * a sprite touches it (a sprite is a solid rect to a volume, its
574
+ * push-out the nearest edge's). Same index contract as raycast.
575
+ */
576
+ overlap(volume: Volume, opts?: QueryOptions): Overlap[]
577
+ /**
578
+ * The volume moved by (dx, dy): every shown sprite it touches on the
579
+ * way, at its first touch, earliest first (Godot's cast_motion, Unity's
580
+ * CircleCast/BoxCast: a bullet, a dash). A volume already in contact
581
+ * reports time 0 while the motion closes in, and nothing while it leaves
582
+ * or slides along the contact, which is what lets a slide along a wall
583
+ * proceed. A zero motion touches nothing.
584
+ */
585
+ sweep(volume: Volume, dx: number, dy: number, opts?: QueryOptions): Impact[]
586
+ /**
587
+ * Move a body by (dx, dy) through the layer's sprites, sliding along
588
+ * what it hits (Godot's CharacterBody2D.move_and_slide, the platformer
589
+ * and top-down character mover), as one pure call: no sprite, no
590
+ * velocity state - it takes a volume where the body IS and the motion
591
+ * it WANTS, and returns the motion it gets plus what it touched. The
592
+ * body first pushes out of anything it starts inside, then sweeps and
593
+ * slides up to `maxSlides` times, then, unless the motion rises, snaps
594
+ * down onto a floor within `floorSnap` - the floor it reports is the
595
+ * one it ends on. The loop runs in the spatial core: one call per body
596
+ * per frame. Gravity is the caller's: fold the fall into the motion
597
+ * each frame and zero it while `floor` is set; a walkable floor absorbs
598
+ * the vertical part, so a body never creeps down a slope it can stand
599
+ * on. Colliders are the layer's shown sprites, or `opts.sprites`.
600
+ */
601
+ moveAndSlide(volume: Volume, dx: number, dy: number, opts?: MoveOptions): MoveResult
602
+ _groups: Set<GroupState>
603
+ /** The core node every sprite and group of the layer sits under: what
604
+ * scopes the layer's queries in the arena shared with 3d scenes. */
605
+ _root: NodeId
377
606
  }
378
607
 
379
608
  /** The stored UVs at `at` un-mirrored by the sprite's flags: the frame as
@@ -401,6 +630,48 @@ function fillTransform(x: number, y: number, rot: number, sx: number, sy: number
401
630
  TRANSFORM[9] = 1
402
631
  }
403
632
 
633
+ // The core mover's options, refilled per call (the binding reads it
634
+ // synchronously); `up` is the 2d direction lifted into the plane.
635
+ const MOVE_OPTIONS: CoreMoveOptions & { up: [number, number, number] } = { up: [0, -1, 0] }
636
+
637
+ /** Pack a 2d volume into the core's scratch at z = 0: a circle is a
638
+ * capsule with a == b, a rect a "box" turned about z. Returns the kind. */
639
+ function packVolume(volume: Volume, site: string): "capsule" | "box" {
640
+ if ("width" in volume) {
641
+ if (!(volume.width >= 0 && volume.height >= 0)) {
642
+ throw new Error(site + ": width and height must be >= 0, got " + volume.width + " x " + volume.height)
643
+ }
644
+ let half = (volume.rotation ?? 0) / 2
645
+ BOX[0] = volume.x + volume.width / 2
646
+ BOX[1] = volume.y + volume.height / 2
647
+ BOX[2] = 0
648
+ BOX[3] = volume.width / 2
649
+ BOX[4] = volume.height / 2
650
+ BOX[5] = BOX_HALF_DEPTH
651
+ BOX[6] = 0
652
+ BOX[7] = 0
653
+ BOX[8] = Math.sin(half)
654
+ BOX[9] = Math.cos(half)
655
+ return "box"
656
+ }
657
+ if (!(volume.radius >= 0)) throw new Error(site + ": radius must be >= 0, got " + volume.radius)
658
+ if ("x" in volume) {
659
+ CAPSULE[0] = volume.x
660
+ CAPSULE[1] = volume.y
661
+ CAPSULE[3] = volume.x
662
+ CAPSULE[4] = volume.y
663
+ } else {
664
+ CAPSULE[0] = volume.ax
665
+ CAPSULE[1] = volume.ay
666
+ CAPSULE[3] = volume.bx
667
+ CAPSULE[4] = volume.by
668
+ }
669
+ CAPSULE[2] = 0
670
+ CAPSULE[5] = 0
671
+ CAPSULE[6] = volume.radius
672
+ return "capsule"
673
+ }
674
+
404
675
  /** Compose and push a node-backed sprite's local transform - through the
405
676
  * node's transition declaration, so with one set (setSpriteTransition)
406
677
  * the write is a target the core animates toward. */
@@ -410,57 +681,25 @@ function writeTransform(sprite: Sprite): void {
410
681
  }
411
682
 
412
683
  /**
413
- * Create a sprite layer rendering into a `width` x `height` texture from one
414
- * atlas texture. Disposed automatically with the owning reactive scope (opt
415
- * out with `{ autoFree: false }`); the atlas is NOT owned - dispose it
416
- * yourself (it commonly outlives layers).
684
+ * Create a sprite layer over one atlas texture. It renders nothing by
685
+ * itself: `layer.createView({ width, height })` (or a `<SpriteLayer>` /
686
+ * `<View2d>`) is where it shows, once or many times. Disposed
687
+ * automatically with the owning reactive scope (opt out with
688
+ * `{ autoFree: false }`); the atlas is NOT owned - dispose it yourself
689
+ * (it commonly outlives layers).
417
690
  */
418
- export function createSpriteLayer(
419
- width: number,
420
- height: number,
421
- atlas: TextureId,
422
- opts?: SpriteLayerOptions,
423
- ): SpriteLayer {
691
+ export function createSpriteLayer(atlas: TextureId, opts?: SpriteLayerOptions): SpriteLayer {
424
692
  let capacity = opts?.capacity ?? 1024
425
693
  if (!(capacity > 0 && Number.isInteger(capacity))) {
426
694
  throw new Error(`createSpriteLayer: capacity must be a positive integer, got ${capacity}`)
427
695
  }
428
696
  let label = opts?.label ?? "sprites"
429
- let oversample = opts?.oversample ?? 1
430
- checkOversample("createSpriteLayer", oversample, width, height)
431
- // One unit quad (triangle strip), reused by every instance.
432
- let quad = createBuffer(new Float32Array([-0.5, -0.5, 0.5, -0.5, -0.5, 0.5, 0.5, 0.5]), {
433
- label: `${label}-quad`,
434
- autoFree: false,
435
- })
697
+ let tint = opts?.tint ?? [1, 1, 1, 1]
698
+ checkTint("createSpriteLayer", tint)
436
699
  let pose: BufferId = createBuffer(capacity * POSE_FLOATS * 4, { label: `${label}-pose`, autoFree: false })
437
700
  let style: BufferId = createBuffer(capacity * STYLE_FLOATS * 4, { label: `${label}-style`, autoFree: false })
438
- let texture = createPipelineTexture(
439
- VERTEX_SPLIT,
440
- FRAGMENT,
441
- width * oversample,
442
- height * oversample,
443
- { uViewport: [width, height], uCamera: [0, 0, 1, 1] },
444
- {
445
- label,
446
- topology: "triangle-strip",
447
- vertexCount: 4,
448
- attributes: [{ name: "aPos", format: "vec2" }],
449
- buffer: quad,
450
- instanceAttributes: INSTANCE_ATTRIBUTES_SPLIT,
451
- instanceBuffers: [pose, style],
452
- instanceCount: 0,
453
- blend: "alpha",
454
- textures: { uAtlas: atlas },
455
- clearColor: opts?.clearColor ?? [0, 0, 0, 0],
456
- autoFree: false,
457
- },
458
- )
701
+ let gpu = createSpritePipeline(label, VERTEX_SPLIT, INSTANCE_ATTRIBUTES_SPLIT)
459
702
 
460
- // Camera state, mirrored for picking (the inverse mapping).
461
- let camX = 0
462
- let camY = 0
463
- let camZoom = 1
464
703
  let disposed = false
465
704
  let scheduled = false
466
705
  let styleDirty = false
@@ -473,7 +712,31 @@ export function createSpriteLayer(
473
712
  let freeSlots: number[] = []
474
713
  let gpuCapacity = capacity
475
714
  let styleData = new Float32Array(capacity * STYLE_FLOATS)
476
- let byNode = new Map<NodeId, Sprite>()
715
+ let byNode = new Map<NodeId, SpriteState>()
716
+ // The layer's root node (identity): parentless sprites and groups hang
717
+ // off it, so a query scoped to it sees exactly this layer.
718
+ fillTransform(0, 0, 0, 1, 1)
719
+ let root = spatial.createNode(TRANSFORM, true)
720
+ let filter: QueryFilter = { root }
721
+ // Refill the layer's one filter for a query: the root always, the
722
+ // include-list as the sprites' nodes when given.
723
+ let queryFilter = (opts: QueryOptions | undefined, site: string): void => {
724
+ if (opts?.sprites === undefined) {
725
+ filter.nodes = undefined
726
+ return
727
+ }
728
+ let nodes: NodeId[] = []
729
+ for (let sprite of opts.sprites) {
730
+ if (sprite.layer !== layer) throw new Error(site + ": a sprite in `sprites` belongs to another layer")
731
+ nodes.push(sprite.node!)
732
+ }
733
+ filter.nodes = nodes
734
+ }
735
+ let impactOf = (h: CoreImpact): Impact | null => {
736
+ let sprite = byNode.get(h.node)
737
+ if (!sprite) return null
738
+ return { sprite, time: h.time, point: [h.point[0], h.point[1]], normal: [h.normal[0], h.normal[1]] }
739
+ }
477
740
 
478
741
  let flush = () => {
479
742
  scheduled = false
@@ -485,7 +748,7 @@ export function createSpriteLayer(
485
748
  endBufferWrite(style, highWater * STYLE_FLOATS * 4)
486
749
  }
487
750
  if (published !== highWater) {
488
- setDraw(texture, { instanceCount: highWater })
751
+ views.setCount(highWater)
489
752
  published = highWater
490
753
  }
491
754
  // The core recomputes moved subtrees and publishes every dirty pose
@@ -496,7 +759,7 @@ export function createSpriteLayer(
496
759
  // Grow both instance buffers to `next` slots: the pose sinks move in one
497
760
  // retargetRecords call (the whole used range republishes at the next
498
761
  // flush), the style mirror grows in JS and republishes through the lease.
499
- // The entry holds the old buffers alive until the swap lands, so the
762
+ // The entries hold the old buffers alive until the swaps land, so the
500
763
  // destroys are safe to issue right after.
501
764
  let grow = (next: number) => {
502
765
  let newPose = createBuffer(next * POSE_FLOATS * 4, { label: `${label}-pose`, autoFree: false })
@@ -505,7 +768,7 @@ export function createSpriteLayer(
505
768
  let grownStyle = new Float32Array(next * STYLE_FLOATS)
506
769
  grownStyle.set(styleData)
507
770
  styleData = grownStyle
508
- setDraw(texture, { instanceBuffers: [newPose, newStyle] })
771
+ views.setBuffers({ instanceBuffers: [newPose, newStyle] })
509
772
  destroyBuffer(pose)
510
773
  destroyBuffer(style)
511
774
  pose = newPose
@@ -514,7 +777,7 @@ export function createSpriteLayer(
514
777
  styleDirty = true
515
778
  }
516
779
 
517
- let writeStyle = (sprite: Sprite, opts: SpriteOptions) => {
780
+ let writeStyle = (sprite: SpriteState, opts: SpriteOptions) => {
518
781
  let at = sprite._slot * STYLE_FLOATS
519
782
  let flipX = opts.flipX !== undefined && opts.flipX !== sprite._flipX
520
783
  let flipY = opts.flipY !== undefined && opts.flipY !== sprite._flipY
@@ -536,46 +799,47 @@ export function createSpriteLayer(
536
799
  styleData[at + 7] = opts.tint[3]
537
800
  styleDirty = true
538
801
  }
802
+ if (opts.renderOrder !== undefined) {
803
+ styleData[at + STYLE_KEY_FIELD] = opts.renderOrder
804
+ styleDirty = true
805
+ }
539
806
  }
540
807
 
541
- let dispatch = spriteDispatch({
542
- size: () => [width, height],
543
- camera: () => [camX, camY, camZoom],
808
+ let views = createViews({
809
+ label,
810
+ pipeline: gpu.pipeline,
811
+ quad: gpu.quad,
812
+ atlas,
813
+ buffers: () => ({ instanceBuffers: [pose, style] }),
814
+ count: () => published,
815
+ tint: () => tint,
544
816
  pick: (x, y) => layer.pick(x, y),
817
+ // The order key one view entry declares: "y" on world y in the pose
818
+ // record (slot 0), "renderOrder" on the app-owned key in the style
819
+ // record (slot 1); either way the core gathers BOTH buffers under the
820
+ // one permutation at every publish and republishes the sibling itself
821
+ // when the key buffer re-orders.
822
+ order:
823
+ opts?.orderBy === "y"
824
+ ? { field: POSE_Y_FIELD }
825
+ : opts?.orderBy === "renderOrder"
826
+ ? { field: STYLE_KEY_FIELD, slot: 1 }
827
+ : undefined,
545
828
  })
546
829
 
547
830
  let layer: SpriteLayer = {
548
- texture,
549
- handlers: undefined as unknown as SpriteHandlers,
550
831
  get count() {
551
832
  return byNode.size
552
833
  },
553
- setSize(w, h) {
554
- if (disposed || (w === width && h === height)) return
555
- checkOversample("setSize", oversample, w, h)
556
- width = w
557
- height = h
558
- setTargetSize(texture, w * oversample, h * oversample)
559
- setTargetParams(texture, { uViewport: [w, h] })
560
- },
561
- get oversample() {
562
- return oversample
563
- },
564
- setOversample(n) {
565
- if (disposed || n === oversample) return
566
- checkOversample("setOversample", n, width, height)
567
- oversample = n
568
- setTargetSize(texture, width * n, height * n)
569
- },
570
- setCamera(update) {
834
+ setTint(next) {
571
835
  if (disposed) return
572
- if (update.x !== undefined) camX = update.x
573
- if (update.y !== undefined) camY = update.y
574
- if (update.zoom !== undefined) {
575
- if (!(update.zoom > 0)) throw new Error(`setCamera: zoom must be positive, got ${update.zoom}`)
576
- camZoom = update.zoom
577
- }
578
- setTargetParams(texture, { uCamera: [camX, camY, camZoom, camZoom] })
836
+ checkTint("setTint", next)
837
+ tint = next
838
+ views.setTint(next)
839
+ },
840
+ createView(vopts) {
841
+ if (disposed) throw new Error("createView: layer is disposed")
842
+ return views.create(vopts)
579
843
  },
580
844
  pick(x, y) {
581
845
  // The index reads as of the last core flush; run any pending batch
@@ -583,33 +847,92 @@ export function createSpriteLayer(
583
847
  if (scheduled) flush()
584
848
  RAY_ORIGIN[0] = x
585
849
  RAY_ORIGIN[1] = y
586
- RAY_ORIGIN[2] = -1
587
- let best: Sprite | null = null
588
- for (let hit of spatial.raycast(RAY_ORIGIN, RAY_DIR)) {
589
- // The arena is shared (a 3d scene lives in the same index): only
590
- // this layer's nodes count. Topmost = highest slot (draw order).
850
+ RAY_ORIGIN[2] = PICK_RAY_START
851
+ RAY_DIR[0] = 0
852
+ RAY_DIR[1] = 0
853
+ RAY_DIR[2] = 1
854
+ filter.nodes = undefined
855
+ let out: Sprite[] = []
856
+ for (let hit of spatial.raycast(RAY_ORIGIN, RAY_DIR, filter)) {
591
857
  let sprite = byNode.get(hit.node)
592
- if (sprite && (best === null || sprite._slot > best._slot)) best = sprite
858
+ if (sprite) out.push(sprite)
593
859
  }
594
- return best
860
+ // Topmost first: higher slot = drawn later = on top.
861
+ return out.sort((a, b) => b._slot - a._slot)
595
862
  },
596
- pickRect(x, y, w, h) {
597
- if (scheduled) flush()
598
- BOX[0] = x
599
- BOX[1] = y
600
- BOX[2] = -1
601
- BOX[3] = x + w
602
- BOX[4] = y + h
603
- BOX[5] = 1
863
+ pickRect(x, y, width, height) {
604
864
  let out: Sprite[] = []
605
- for (let node of spatial.overlap(BOX)) {
606
- let sprite = byNode.get(node)
607
- if (sprite) out.push(sprite)
865
+ for (let hit of layer.overlap({ x, y, width, height })) out.push(hit.sprite)
866
+ return out
867
+ },
868
+ raycast(x, y, dx, dy, opts) {
869
+ if (scheduled) flush()
870
+ RAY_ORIGIN[0] = x
871
+ RAY_ORIGIN[1] = y
872
+ RAY_ORIGIN[2] = 0
873
+ RAY_DIR[0] = dx
874
+ RAY_DIR[1] = dy
875
+ RAY_DIR[2] = 0
876
+ queryFilter(opts, "raycast")
877
+ let out: RayHit[] = []
878
+ for (let h of spatial.raycast(RAY_ORIGIN, RAY_DIR, filter)) {
879
+ let sprite = byNode.get(h.node)
880
+ if (sprite) out.push({ sprite, distance: h.distance, point: [h.point[0], h.point[1]], normal: [h.normal[0], h.normal[1]] })
608
881
  }
609
882
  return out
610
883
  },
611
- handlersFor(layout) {
612
- return dispatch(layout)
884
+ overlap(volume, opts) {
885
+ if (scheduled) flush()
886
+ let kind = packVolume(volume, "overlap")
887
+ queryFilter(opts, "overlap")
888
+ let out: Overlap[] = []
889
+ for (let h of spatial.overlap(kind, kind === "box" ? BOX : CAPSULE, filter)) {
890
+ let sprite = byNode.get(h.node)
891
+ if (sprite) out.push({ sprite, point: [h.point[0], h.point[1]], normal: [h.normal[0], h.normal[1]], depth: h.depth })
892
+ }
893
+ return out
894
+ },
895
+ sweep(volume, dx, dy, opts) {
896
+ if (scheduled) flush()
897
+ let kind = packVolume(volume, "sweep")
898
+ MOTION[0] = dx
899
+ MOTION[1] = dy
900
+ MOTION[2] = 0
901
+ queryFilter(opts, "sweep")
902
+ let out: Impact[] = []
903
+ for (let h of spatial.sweep(kind, kind === "box" ? BOX : CAPSULE, MOTION, filter)) {
904
+ let impact = impactOf(h)
905
+ if (impact) out.push(impact)
906
+ }
907
+ return out
908
+ },
909
+ moveAndSlide(volume, dx, dy, opts) {
910
+ if (scheduled) flush()
911
+ let kind = packVolume(volume, "moveAndSlide")
912
+ MOTION[0] = dx
913
+ MOTION[1] = dy
914
+ MOTION[2] = 0
915
+ queryFilter(opts, "moveAndSlide")
916
+ let up = opts?.up ?? UP_2D
917
+ MOVE_OPTIONS.up[0] = up[0]
918
+ MOVE_OPTIONS.up[1] = up[1]
919
+ MOVE_OPTIONS.floorMaxAngle = opts?.floorMaxAngle
920
+ MOVE_OPTIONS.maxSlides = opts?.maxSlides
921
+ MOVE_OPTIONS.skin = opts?.skin
922
+ MOVE_OPTIONS.floorSnap = opts?.floorSnap
923
+ let r = spatial.moveAndSlide(kind, kind === "box" ? BOX : CAPSULE, MOTION, MOVE_OPTIONS, filter)
924
+ let hits: Impact[] = []
925
+ for (let h of r.hits) {
926
+ let impact = impactOf(h)
927
+ if (impact) hits.push(impact)
928
+ }
929
+ return {
930
+ motion: [r.motion[0], r.motion[1]],
931
+ floor: r.floor === null ? null : [r.floor[0], r.floor[1]],
932
+ wall: r.wall,
933
+ ceiling: r.ceiling,
934
+ hits,
935
+ }
613
936
  },
614
937
  dispose() {
615
938
  if (disposed) return
@@ -626,19 +949,20 @@ export function createSpriteLayer(
626
949
  spatial.destroyNode(group.node)
627
950
  }
628
951
  layer._groups.clear()
952
+ spatial.destroyNode(root)
629
953
  // Let the core emit its final slot-zeroing writes while the pose
630
954
  // buffer still exists, then free everything.
631
955
  spatial.flush()
632
- destroyTexture(texture)
956
+ views.dispose()
633
957
  destroyBuffer(pose)
634
958
  destroyBuffer(style)
635
- destroyBuffer(quad)
959
+ gpu.dispose()
636
960
  },
637
961
  _add(opts) {
638
962
  if (disposed) throw new Error("addSprite: layer is disposed")
639
963
  let slot = freeSlots.pop() ?? highWater++
640
964
  if (slot >= gpuCapacity) grow(gpuCapacity * 2)
641
- let sprite: Sprite = {
965
+ let sprite: SpriteState = {
642
966
  layer,
643
967
  node: null,
644
968
  _slot: slot,
@@ -649,18 +973,25 @@ export function createSpriteLayer(
649
973
  _rot: opts?.rotation ?? 0,
650
974
  _flipX: false,
651
975
  _flipY: false,
976
+ _visible: opts?.visible ?? true,
977
+ _parent: opts?.parent ?? null,
652
978
  }
653
979
  fillTransform(sprite._x, sprite._y, sprite._rot, sprite._w, sprite._h)
654
- let node = spatial.createNode(TRANSFORM, true)
980
+ let node = spatial.createNode(TRANSFORM, sprite._visible)
655
981
  sprite.node = node
656
982
  if (opts?.parent) {
657
983
  if (opts.parent.layer !== layer) throw new Error("addSprite: parent group belongs to another layer")
658
984
  spatial.setParent(node, opts.parent.node)
985
+ opts.parent._children.add(sprite)
986
+ } else {
987
+ spatial.setParent(node, root)
659
988
  }
660
- spatial.setBounds(node, FLAT_BOUNDS)
989
+ spatial.setBounds(node, COLUMN_BOUNDS)
661
990
  spatial.bindPoseRecord(node, pose, slot)
662
991
  byNode.set(node, sprite)
663
- writeStyle(sprite, { frame: FULL_FRAME, tint: [1, 1, 1, 1], ...opts })
992
+ // renderOrder defaults to 0 explicitly: a recycled slot holds the
993
+ // previous occupant's key otherwise.
994
+ writeStyle(sprite, { frame: FULL_FRAME, tint: [1, 1, 1, 1], renderOrder: 0, ...opts })
664
995
  layer._schedule()
665
996
  return sprite
666
997
  },
@@ -672,7 +1003,18 @@ export function createSpriteLayer(
672
1003
  if (opts.h !== undefined && opts.h !== sprite._h) (sprite._h = opts.h), (moved = true)
673
1004
  if (opts.rotation !== undefined && opts.rotation !== sprite._rot) (sprite._rot = opts.rotation), (moved = true)
674
1005
  if (moved) writeTransform(sprite)
675
- if (opts.frame !== undefined || opts.tint !== undefined || opts.flipX !== undefined || opts.flipY !== undefined) {
1006
+ if (opts.visible !== undefined && opts.visible !== sprite._visible) {
1007
+ sprite._visible = opts.visible
1008
+ spatial.setVisible(sprite.node!, opts.visible)
1009
+ moved = true
1010
+ }
1011
+ if (
1012
+ opts.frame !== undefined ||
1013
+ opts.tint !== undefined ||
1014
+ opts.flipX !== undefined ||
1015
+ opts.flipY !== undefined ||
1016
+ opts.renderOrder !== undefined
1017
+ ) {
676
1018
  writeStyle(sprite, opts)
677
1019
  }
678
1020
  if (moved || styleDirty) layer._schedule()
@@ -689,10 +1031,16 @@ export function createSpriteLayer(
689
1031
  flipY: sprite._flipY,
690
1032
  rotation: sprite._rot,
691
1033
  tint: [styleData[at + 4]!, styleData[at + 5]!, styleData[at + 6]!, styleData[at + 7]!],
1034
+ renderOrder: styleData[at + STYLE_KEY_FIELD]!,
1035
+ visible: sprite._visible,
692
1036
  }
693
1037
  },
694
1038
  _remove(sprite) {
695
1039
  sprite.layer = null
1040
+ if (sprite._parent) {
1041
+ sprite._parent._children.delete(sprite)
1042
+ sprite._parent = null
1043
+ }
696
1044
  // Destroying the node zeroes its pose slot at the next core flush
697
1045
  // (zero scale = nothing drawn); the slot then recycles.
698
1046
  byNode.delete(sprite.node!)
@@ -707,9 +1055,8 @@ export function createSpriteLayer(
707
1055
  RESOLVED.then(flush)
708
1056
  },
709
1057
  _groups: new Set(),
1058
+ _root: root,
710
1059
  }
711
- layer.handlers = dispatch(null)
712
-
713
1060
  if (opts?.autoFree !== false && getOwner()) onCleanup(() => layer.dispose())
714
1061
  return layer
715
1062
  }
@@ -719,7 +1066,8 @@ export function createSpriteLayer(
719
1066
  * order, and a removed sprite's slot recycles to the next add - so unlike
720
1067
  * the record layer there is no painter's-insertion-order guarantee across
721
1068
  * removals. Opaque-or-transparent pixel art (the overwhelming case) never
722
- * notices; z-ordered translucency is the sort-key backlog item. Past the
1069
+ * notices; a scene that needs depth order sorts by world y with the
1070
+ * layer's `orderBy: "y"` (core-produced, zero JS per frame). Past the
723
1071
  * layer's reservation both instance buffers double (pose sinks move in one
724
1072
  * core retarget); reserve with `capacity` to avoid the copies.
725
1073
  */
@@ -745,17 +1093,46 @@ export function removeSprite(sprite: Sprite): void {
745
1093
  sprite.layer?._remove(sprite)
746
1094
  }
747
1095
 
1096
+ /**
1097
+ * A sprite's or group's position in LAYER pixels: its local x/y composed
1098
+ * through every enclosing group (@solidrt/3d's worldPosition, Three's
1099
+ * getWorldPosition), read from the core's world matrix as the tree stands
1100
+ * now, pending writes included. Under a transition this is the
1101
+ * mid-flight pose - what picking and the screen show - where the handle's
1102
+ * own fields hold the target. A record layer's sprite has no groups, so
1103
+ * its world position is its own, read as getSprite reads it (the records
1104
+ * array). A fresh pair per call; null once the handle is inert.
1105
+ */
1106
+ export function worldPosition(target: Sprite | SpriteGroup): [number, number] | null {
1107
+ if (target.layer === null) return null
1108
+ // The set only groups carry tells the two handle kinds apart (as in
1109
+ // removeGroup); a node-less sprite is a record layer's.
1110
+ if (!("_children" in target) && target.node === null) {
1111
+ let s = target.layer._read(target)
1112
+ return [s.x, s.y]
1113
+ }
1114
+ spatial.worldMatrix(target.node!, WORLD)
1115
+ return [WORLD[12]!, WORLD[13]!]
1116
+ }
1117
+
748
1118
  /**
749
1119
  * Re-parent a sprite under a group (null = back to the layer root); its
750
1120
  * pose fields then read in the new parent's frame, where the sprite keeps
751
- * them (it holds its local pose, not its world pose). Node layer only.
1121
+ * them (it holds its local pose, not its world pose; worldPosition reads
1122
+ * the composed one). Node layer only.
752
1123
  */
753
1124
  export function setSpriteParent(sprite: Sprite, parent: SpriteGroup | null): void {
754
1125
  let layer = sprite.layer
755
1126
  if (!layer) return
756
1127
  if (sprite.node === null) throw new Error("setSpriteParent: record layers have no groups")
757
1128
  if (parent && parent.layer !== layer) throw new Error("setSpriteParent: group belongs to another layer")
758
- spatial.setParent(sprite.node, parent ? parent.node : null)
1129
+ // A sprite with a node is on a node layer (the throw above), which TS
1130
+ // cannot narrow through sprite.layer.
1131
+ spatial.setParent(sprite.node, parent ? parent.node : (layer as SpriteLayer)._root)
1132
+ let s: SpriteState = sprite
1133
+ if (s._parent) s._parent._children.delete(s)
1134
+ s._parent = parent
1135
+ if (parent) parent._children.add(s)
759
1136
  layer._schedule()
760
1137
  }
761
1138
 
@@ -796,12 +1173,18 @@ export function addGroup(layer: SpriteLayer, opts?: GroupOptions): SpriteGroup {
796
1173
  _y: opts?.y ?? 0,
797
1174
  _rot: opts?.rotation ?? 0,
798
1175
  _scale: opts?.scale ?? 1,
799
- } as SpriteGroup
1176
+ _visible: opts?.visible ?? true,
1177
+ _parent: opts?.parent ?? null,
1178
+ _children: new Set(),
1179
+ } as GroupState
800
1180
  writeGroupTransform(group)
801
- group.node = spatial.createNode(TRANSFORM, true)
1181
+ group.node = spatial.createNode(TRANSFORM, group._visible)
802
1182
  if (opts?.parent) {
803
1183
  if (opts.parent.layer !== layer) throw new Error("addGroup: parent group belongs to another layer")
804
1184
  spatial.setParent(group.node, opts.parent.node)
1185
+ opts.parent._children.add(group)
1186
+ } else {
1187
+ spatial.setParent(group.node, layer._root)
805
1188
  }
806
1189
  layer._groups.add(group)
807
1190
  layer._schedule()
@@ -816,33 +1199,57 @@ function writeGroupTransform(group: SpriteGroup): void {
816
1199
  export function setGroup(group: SpriteGroup, opts: GroupOptions): void {
817
1200
  let layer = group.layer
818
1201
  if (!layer) return
1202
+ let g: GroupState = group
819
1203
  let moved = false
820
- if (opts.x !== undefined && opts.x !== group._x) (group._x = opts.x), (moved = true)
821
- if (opts.y !== undefined && opts.y !== group._y) (group._y = opts.y), (moved = true)
822
- if (opts.rotation !== undefined && opts.rotation !== group._rot) (group._rot = opts.rotation), (moved = true)
823
- if (opts.scale !== undefined && opts.scale !== group._scale) (group._scale = opts.scale), (moved = true)
1204
+ if (opts.x !== undefined && opts.x !== g._x) (g._x = opts.x), (moved = true)
1205
+ if (opts.y !== undefined && opts.y !== g._y) (g._y = opts.y), (moved = true)
1206
+ if (opts.rotation !== undefined && opts.rotation !== g._rot) (g._rot = opts.rotation), (moved = true)
1207
+ if (opts.scale !== undefined && opts.scale !== g._scale) (g._scale = opts.scale), (moved = true)
824
1208
  if (moved) {
825
1209
  writeGroupTransform(group)
826
1210
  spatial.writeTransform(group.node, TRANSFORM)
827
1211
  }
1212
+ if (opts.visible !== undefined && opts.visible !== g._visible) {
1213
+ g._visible = opts.visible
1214
+ spatial.setVisible(group.node, opts.visible)
1215
+ moved = true
1216
+ }
828
1217
  if (opts.parent !== undefined) {
829
1218
  if (opts.parent && opts.parent.layer !== layer) throw new Error("setGroup: parent group belongs to another layer")
830
- spatial.setParent(group.node, opts.parent ? opts.parent.node : null)
1219
+ spatial.setParent(group.node, opts.parent ? opts.parent.node : layer._root)
1220
+ if (g._parent) g._parent._children.delete(g)
1221
+ g._parent = opts.parent
1222
+ if (opts.parent) opts.parent._children.add(g)
831
1223
  moved = true
832
1224
  }
833
1225
  if (moved) layer._schedule()
834
1226
  }
835
1227
 
836
1228
  /**
837
- * Remove a group: its children (sprites and groups) become layer roots and
838
- * KEEP THEIR LOCAL POSE, so they jump to root frame unless the caller
839
- * removes or re-parents them too (a component tree unmounts children first
840
- * and never sees this). The handle goes inert.
1229
+ * Remove a group AND everything under it: child sprites and groups are
1230
+ * removed with it - Unity's Destroy, Godot's free, the subtree form of
1231
+ * removeSprite. To keep a child, re-parent it out first (setSpriteParent /
1232
+ * setGroup's `parent`). Every removed handle goes inert. A component tree
1233
+ * unmounts children first and never sees the recursion. (@solidrt/3d's
1234
+ * `remove` DETACHES a re-addable subtree instead - its nodes exist outside
1235
+ * a scene; a sprite cannot exist outside its layer, so here remove means
1236
+ * destroy, exactly as it does for removeSprite.)
841
1237
  */
842
1238
  export function removeGroup(group: SpriteGroup): void {
843
1239
  let layer = group.layer
844
1240
  if (!layer) return
845
- group.layer = null
1241
+ let g: GroupState = group
1242
+ // Children first, over a snapshot (each removal edits _children); the
1243
+ // set only groups carry tells the two handle kinds apart.
1244
+ for (let child of [...g._children]) {
1245
+ if ("_children" in child) removeGroup(child)
1246
+ else removeSprite(child)
1247
+ }
1248
+ g.layer = null
1249
+ if (g._parent) {
1250
+ g._parent._children.delete(g)
1251
+ g._parent = null
1252
+ }
846
1253
  layer._groups.delete(group)
847
1254
  declared.delete(group.node)
848
1255
  spatial.destroyNode(group.node)