@solidrt/2d 0.0.56 → 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,12 +2,13 @@
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
@@ -20,35 +21,24 @@
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 (offset, zoom, rotation about a pivot - CameraUpdate
24
- // in camera.ts) is a shared-params write (uCamera + uCameraRot), never
25
- // per-sprite; pointer dispatch (dispatch.ts) undoes it with
26
- // unprojectCamera.
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.
27
28
  import { getOwner, onCleanup } from "@solidrt/core"
28
29
  import type { PointerEvent as ElementPointerEvent, WheelEvent as ElementWheelEvent } from "@solidrt/core"
29
- import {
30
- beginBufferWrite,
31
- createBuffer,
32
- createPipelineTexture,
33
- destroyBuffer,
34
- destroyTexture,
35
- endBufferWrite,
36
- setDraw,
37
- setTargetParams,
38
- setTargetSize,
39
- } from "@solidrt/core/gpu"
30
+ import { beginBufferWrite, createBuffer, destroyBuffer, endBufferWrite } from "@solidrt/core/gpu"
40
31
  import type { BufferId, TextureId } from "@solidrt/core/gpu"
41
32
  import * as spatial from "flux:spatial"
42
- import type { NodeId, NodeTransition } from "flux:spatial"
33
+ import type { Impact as CoreImpact, MoveOptions as CoreMoveOptions, NodeId, NodeTransition, QueryFilter } from "flux:spatial"
43
34
  import { on } from "srt:events"
44
- import { checkCamera, projectCamera, unprojectCamera } from "./camera.ts"
45
35
  import type { CameraState, CameraUpdate } from "./camera.ts"
46
- import { spriteDispatch } from "./dispatch.ts"
47
- import { checkOversample, thrashSentinel } from "./oversample.ts"
48
36
  import type { Frame } from "./frames.ts"
49
37
  import { FULL_FRAME, writeFrame } from "./frames.ts"
50
38
  import type { RecordLayer } from "./records.ts"
51
- 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"
52
42
 
53
43
  /** Floats per pose record (the core's Pose2D projection). */
54
44
  export const POSE_FLOATS = 5
@@ -67,10 +57,31 @@ const RESOLVED = Promise.resolve()
67
57
 
68
58
  // Shared marshalling scratch (the bindings copy synchronously).
69
59
  const TRANSFORM = new Float32Array(10)
70
- 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])
71
69
  const RAY_ORIGIN = new Float32Array(3)
72
- const RAY_DIR = new Float32Array([0, 0, 1])
73
- const BOX = new Float32Array([0, 0, 0, 0, 0, 1, 0, 0, 0, 1])
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]
74
85
  // worldPosition's world-matrix scratch (column-major; translation at 12, 13).
75
86
  const WORLD = new Float32Array(16)
76
87
 
@@ -207,6 +218,12 @@ export type SpriteOptions = {
207
218
  visible?: boolean
208
219
  }
209
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
+ */
210
227
  export type AddSpriteOptions = SpriteOptions & {
211
228
  /** Mount under this group (node layer only; null = the layer root, the
212
229
  * default); pose fields are then local to it. Reparent later with
@@ -254,13 +271,13 @@ export type SpritePointerEvent = LayerEventBase & {
254
271
  }
255
272
 
256
273
  /**
257
- * The event as the LAYER's listeners see it (LayerBase.listen), the last
274
+ * The event as the VIEW's listeners see it (ViewHandle.listen), the last
258
275
  * stop of the walk: `sprite` is the hit sprite it bubbled from, or null
259
- * over empty space, where the layer is the only target.
276
+ * over empty space, where the view is the only target.
260
277
  */
261
278
  export type LayerPointerEvent = LayerEventBase & {
262
279
  sprite: Sprite | null
263
- currentTarget: SpriteLayer | RecordLayer
280
+ currentTarget: ViewHandle
264
281
  }
265
282
 
266
283
  type WheelFields = {
@@ -287,7 +304,7 @@ export type LayerWheelEvent = LayerPointerEvent & WheelFields
287
304
  export type SpriteTapEvent = SpritePointerEvent & TapFields
288
305
  export type LayerTapEvent = LayerPointerEvent & TapFields
289
306
 
290
- /** A listener at the layer root; see LayerBase.listen. */
307
+ /** A listener at a view's root; see ViewHandle.listen. */
291
308
  export type LayerPointerListener = {
292
309
  onPointerDown?: (event: LayerPointerEvent) => void
293
310
  onPointerMove?: (event: LayerPointerEvent) => void
@@ -319,20 +336,13 @@ export type SpriteLayerOptions = {
319
336
  * limit.
320
337
  */
321
338
  capacity?: number
322
- clearColor?: [number, number, number, number]
323
339
  /** Layer tint, [r, g, b, a] in 0..1, multiplied over every sprite's own
324
- * tint; default opaque white (sprites as-is). See setTint. */
340
+ * tint in every view; default opaque white (sprites as-is). See
341
+ * setTint. */
325
342
  tint?: [number, number, number, number]
343
+ /** Names the GPU resources (buffers, pipeline; views default to
344
+ * `<label>-view`); default "sprites". */
326
345
  label?: string
327
- /**
328
- * Target texels per layer pixel (positive integer, default 1). The layer
329
- * renders at `oversample` times its size and is composited down, so a
330
- * fractional or HiDPI display scale resamples properly instead of snapping
331
- * (nearest) or smearing (linear); see setOversample. The components pick
332
- * it from the leaf's on-screen size - set it here when composing the
333
- * output yourself.
334
- */
335
- oversample?: number
336
346
  /** Skip the owner-scoped auto-dispose (see createSpriteLayer). */
337
347
  autoFree?: boolean
338
348
  /**
@@ -423,57 +433,23 @@ export type GroupOptions = {
423
433
  }
424
434
 
425
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
+ */
426
443
  export type LayerBase = {
427
- /** The layer's output: an ordinary texture id (`<texture src>`). */
428
- texture: TextureId
429
- /** Element handlers wiring the layer's pointer events (sprites, groups
430
- * and the root listeners); see handlersFor. */
431
- handlers: SpriteHandlers
432
444
  /** Live sprite count. */
433
445
  readonly count: number
434
- /** Layer pixels, as created or last set by setSize. */
435
- readonly width: number
436
- readonly height: number
437
- setSize(width: number, height: number): void
438
- /**
439
- * Listen at the root of the event walk. Every down, move, up, wheel and
440
- * tap arrives here after the hit sprite and its enclosing groups
441
- * (`sprite` set) or as the walk's only stop over empty space (`sprite`
442
- * null), unless a handler stopped it on the way. Listeners run in
443
- * registration order and all of them run - the root is the last stop,
444
- * there is nothing left to claim. Returns the remover. The app's own
445
- * root handling (deselect on a miss, a marquee) and controls
446
- * (createCamera2d's attach) meet here, which is why the root is a list
447
- * where a sprite has plain fields.
448
- */
449
- listen(listener: LayerPointerListener): () => void
450
- /** Target texels per layer pixel; see setOversample. */
451
- readonly oversample: number
452
- /**
453
- * Re-render at `n` target texels per layer pixel (positive integer): the
454
- * target resizes in place at its stable id, layer pixels, records, camera
455
- * and picking are untouched. Pick `n` as the ceiling of the device pixels
456
- * one layer pixel covers on screen (display scale times any designSize
457
- * fit or layout scaling), which the components do in onLayout.
458
- */
459
- setOversample(n: number): void
460
- setCamera(update: CameraUpdate): void
461
- /** The camera as last set (a fresh object per call, every field
462
- * present): the argument for projectCamera/unprojectCamera -
463
- * @solidrt/3d's scene.camera(). */
464
- camera(): CameraState
465
- /** World (layer) pixels -> viewport pixels under the current camera:
466
- * projectCamera over camera(). */
467
- project(x: number, y: number): [number, number]
468
- /** Viewport pixels -> world (layer) pixels, the inverse: what pointer
469
- * dispatch applies to every event. */
470
- unproject(x: number, y: number): [number, number]
471
446
  /**
472
447
  * Tint the whole layer, [r, g, b, a] in 0..1: a uniform multiplied over
473
448
  * every sprite's own tint (day/night, a dimmed parallax plane, a
474
- * fade-in). One shared-params write - no record touches, cheap to
475
- * animate - and the same contract as TileLayer.setTint, so one signal
476
- * drives a whole scene across layer kinds.
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.
477
453
  */
478
454
  setTint(tint: [number, number, number, number]): void
479
455
  /** Every shown sprite whose rotated rect contains the layer-pixel point
@@ -481,15 +457,21 @@ export type LayerBase = {
481
457
  * all-hits shape of @solidrt/3d's pick; `pick(x, y)[0]` is the topmost.
482
458
  * Topmost means draw order (highest slot on the node layer, last added
483
459
  * on a record layer); an `orderBy` key is not consulted (see
484
- * SpriteLayerOptions.orderBy's known limitation). */
460
+ * SpriteLayerOptions.orderBy's known limitation). World space: a view
461
+ * undoes its camera before asking. */
485
462
  pick(x: number, y: number): Sprite[]
486
463
  /**
487
- * handlers for a leaf whose LAYOUT size differs from the layer size
488
- * (events scale by layer/layout; a leaf laid out AT layer size just uses
489
- * `handlers`). `layout` is read per event, so a resize-reactive layout
490
- * just works - @solidrt/3d's handlersFor, one dimension down.
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.
491
473
  */
492
- handlersFor(layout: () => { width: number; height: number }): SpriteHandlers
474
+ createView(opts: ViewOptions): ViewHandle
493
475
  dispose(): void
494
476
  _add(opts?: AddSpriteOptions): Sprite
495
477
  _write(sprite: SpriteState, opts: SpriteOptions): void
@@ -498,14 +480,129 @@ export type LayerBase = {
498
480
  _schedule(): void
499
481
  }
500
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
+
501
551
  export type SpriteLayer = LayerBase & {
502
552
  /**
503
553
  * Every shown sprite whose rotated rect overlaps the layer-pixel rect
504
554
  * (the core BVH overlap query, exact for rotated sprites), unordered -
505
- * the marquee query. Node layer only.
555
+ * the marquee query: `overlap` over an unrotated rect, sprites only.
556
+ * Node layer only.
506
557
  */
507
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
508
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
509
606
  }
510
607
 
511
608
  /** The stored UVs at `at` un-mirrored by the sprite's flags: the frame as
@@ -533,6 +630,48 @@ function fillTransform(x: number, y: number, rot: number, sx: number, sy: number
533
630
  TRANSFORM[9] = 1
534
631
  }
535
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
+
536
675
  /** Compose and push a node-backed sprite's local transform - through the
537
676
  * node's transition declaration, so with one set (setSpriteTransition)
538
677
  * the write is a target the core animates toward. */
@@ -542,73 +681,25 @@ function writeTransform(sprite: Sprite): void {
542
681
  }
543
682
 
544
683
  /**
545
- * Create a sprite layer rendering into a `width` x `height` texture from one
546
- * atlas texture. Disposed automatically with the owning reactive scope (opt
547
- * out with `{ autoFree: false }`); the atlas is NOT owned - dispose it
548
- * 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).
549
690
  */
550
- export function createSpriteLayer(
551
- width: number,
552
- height: number,
553
- atlas: TextureId,
554
- opts?: SpriteLayerOptions,
555
- ): SpriteLayer {
691
+ export function createSpriteLayer(atlas: TextureId, opts?: SpriteLayerOptions): SpriteLayer {
556
692
  let capacity = opts?.capacity ?? 1024
557
693
  if (!(capacity > 0 && Number.isInteger(capacity))) {
558
694
  throw new Error(`createSpriteLayer: capacity must be a positive integer, got ${capacity}`)
559
695
  }
560
696
  let label = opts?.label ?? "sprites"
561
- let oversample = opts?.oversample ?? 1
562
- checkOversample("createSpriteLayer", oversample, width, height)
563
697
  let tint = opts?.tint ?? [1, 1, 1, 1]
564
698
  checkTint("createSpriteLayer", tint)
565
- let thrash = thrashSentinel(`sprite layer "${label}"`)
566
- // One unit quad (triangle strip), reused by every instance.
567
- let quad = createBuffer(new Float32Array([-0.5, -0.5, 0.5, -0.5, -0.5, 0.5, 0.5, 0.5]), {
568
- label: `${label}-quad`,
569
- autoFree: false,
570
- })
571
699
  let pose: BufferId = createBuffer(capacity * POSE_FLOATS * 4, { label: `${label}-pose`, autoFree: false })
572
700
  let style: BufferId = createBuffer(capacity * STYLE_FLOATS * 4, { label: `${label}-style`, autoFree: false })
573
- let texture = createPipelineTexture(
574
- VERTEX_SPLIT,
575
- FRAGMENT,
576
- width * oversample,
577
- height * oversample,
578
- { uViewport: [width, height], uCamera: [0, 0, 1, 1], uCameraRot: [1, 0, 0, 0], uTint: tint },
579
- {
580
- label,
581
- topology: "triangle-strip",
582
- vertexCount: 4,
583
- attributes: [{ name: "aPos", format: "vec2" }],
584
- buffer: quad,
585
- instanceAttributes: INSTANCE_ATTRIBUTES_SPLIT,
586
- instanceBuffers: [pose, style],
587
- // "y" keys on world y in the pose record (slot 0), "renderOrder" on the
588
- // app-owned key in the style record (slot 1); either way the core
589
- // gathers BOTH buffers under the one permutation at every publish
590
- // and republishes the sibling itself when the key buffer re-orders.
591
- instanceOrder:
592
- opts?.orderBy === "y"
593
- ? { field: POSE_Y_FIELD }
594
- : opts?.orderBy === "renderOrder"
595
- ? { field: STYLE_KEY_FIELD, slot: 1 }
596
- : undefined,
597
- instanceCount: 0,
598
- blend: "alpha",
599
- textures: { uAtlas: atlas },
600
- clearColor: opts?.clearColor ?? [0, 0, 0, 0],
601
- autoFree: false,
602
- },
603
- )
701
+ let gpu = createSpritePipeline(label, VERTEX_SPLIT, INSTANCE_ATTRIBUTES_SPLIT)
604
702
 
605
- // Camera state, mirrored for picking (the inverse mapping).
606
- let camX = 0
607
- let camY = 0
608
- let camZoom = 1
609
- let camRot = 0
610
- let camPivotX = 0
611
- let camPivotY = 0
612
703
  let disposed = false
613
704
  let scheduled = false
614
705
  let styleDirty = false
@@ -622,6 +713,30 @@ export function createSpriteLayer(
622
713
  let gpuCapacity = capacity
623
714
  let styleData = new Float32Array(capacity * STYLE_FLOATS)
624
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
+ }
625
740
 
626
741
  let flush = () => {
627
742
  scheduled = false
@@ -633,7 +748,7 @@ export function createSpriteLayer(
633
748
  endBufferWrite(style, highWater * STYLE_FLOATS * 4)
634
749
  }
635
750
  if (published !== highWater) {
636
- setDraw(texture, { instanceCount: highWater })
751
+ views.setCount(highWater)
637
752
  published = highWater
638
753
  }
639
754
  // The core recomputes moved subtrees and publishes every dirty pose
@@ -644,7 +759,7 @@ export function createSpriteLayer(
644
759
  // Grow both instance buffers to `next` slots: the pose sinks move in one
645
760
  // retargetRecords call (the whole used range republishes at the next
646
761
  // flush), the style mirror grows in JS and republishes through the lease.
647
- // 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
648
763
  // destroys are safe to issue right after.
649
764
  let grow = (next: number) => {
650
765
  let newPose = createBuffer(next * POSE_FLOATS * 4, { label: `${label}-pose`, autoFree: false })
@@ -653,7 +768,7 @@ export function createSpriteLayer(
653
768
  let grownStyle = new Float32Array(next * STYLE_FLOATS)
654
769
  grownStyle.set(styleData)
655
770
  styleData = grownStyle
656
- setDraw(texture, { instanceBuffers: [newPose, newStyle] })
771
+ views.setBuffers({ instanceBuffers: [newPose, newStyle] })
657
772
  destroyBuffer(pose)
658
773
  destroyBuffer(style)
659
774
  pose = newPose
@@ -690,71 +805,41 @@ export function createSpriteLayer(
690
805
  }
691
806
  }
692
807
 
693
- let listeners = new Set<LayerPointerListener>()
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,
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,
828
+ })
694
829
 
695
830
  let layer: SpriteLayer = {
696
- texture,
697
- handlers: undefined as unknown as SpriteHandlers,
698
831
  get count() {
699
832
  return byNode.size
700
833
  },
701
- get width() {
702
- return width
703
- },
704
- get height() {
705
- return height
706
- },
707
- setSize(w, h) {
708
- if (disposed || (w === width && h === height)) return
709
- checkOversample("setSize", oversample, w, h)
710
- width = w
711
- height = h
712
- setTargetSize(texture, w * oversample, h * oversample)
713
- setTargetParams(texture, { uViewport: [w, h] })
714
- },
715
- listen(listener) {
716
- listeners.add(listener)
717
- return () => {
718
- listeners.delete(listener)
719
- }
720
- },
721
- get oversample() {
722
- return oversample
723
- },
724
- setOversample(n) {
725
- if (disposed || n === oversample) return
726
- checkOversample("setOversample", n, width, height)
727
- thrash()
728
- oversample = n
729
- setTargetSize(texture, width * n, height * n)
730
- },
731
- setCamera(update) {
732
- if (disposed) return
733
- checkCamera(update)
734
- if (update.x !== undefined) camX = update.x
735
- if (update.y !== undefined) camY = update.y
736
- if (update.zoom !== undefined) camZoom = update.zoom
737
- if (update.rotation !== undefined) camRot = update.rotation
738
- if (update.pivotX !== undefined) camPivotX = update.pivotX
739
- if (update.pivotY !== undefined) camPivotY = update.pivotY
740
- setTargetParams(texture, {
741
- uCamera: [camX, camY, camZoom, camZoom],
742
- uCameraRot: [Math.cos(camRot), Math.sin(camRot), camPivotX, camPivotY],
743
- })
744
- },
745
- camera() {
746
- return { x: camX, y: camY, zoom: camZoom, rotation: camRot, pivotX: camPivotX, pivotY: camPivotY }
747
- },
748
- project(x, y) {
749
- return projectCamera(layer.camera(), x, y)
750
- },
751
- unproject(x, y) {
752
- return unprojectCamera(layer.camera(), x, y)
753
- },
754
834
  setTint(next) {
755
835
  if (disposed) return
756
836
  checkTint("setTint", next)
757
- setTargetParams(texture, { uTint: 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)
758
843
  },
759
844
  pick(x, y) {
760
845
  // The index reads as of the last core flush; run any pending batch
@@ -762,11 +847,13 @@ export function createSpriteLayer(
762
847
  if (scheduled) flush()
763
848
  RAY_ORIGIN[0] = x
764
849
  RAY_ORIGIN[1] = y
765
- RAY_ORIGIN[2] = -1
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
766
855
  let out: Sprite[] = []
767
- for (let hit of spatial.raycast(RAY_ORIGIN, RAY_DIR)) {
768
- // The arena is shared (a 3d scene lives in the same index): only
769
- // this layer's nodes count.
856
+ for (let hit of spatial.raycast(RAY_ORIGIN, RAY_DIR, filter)) {
770
857
  let sprite = byNode.get(hit.node)
771
858
  if (sprite) out.push(sprite)
772
859
  }
@@ -774,29 +861,82 @@ export function createSpriteLayer(
774
861
  return out.sort((a, b) => b._slot - a._slot)
775
862
  },
776
863
  pickRect(x, y, width, height) {
777
- if (scheduled) flush()
778
- // The marquee as a "box" volume: center, half extents, no rotation,
779
- // a unit deep so every sprite plane (z = 0) lies inside it.
780
- BOX[0] = x + width / 2
781
- BOX[1] = y + height / 2
782
- BOX[2] = 0
783
- BOX[3] = width / 2
784
- BOX[4] = height / 2
785
- BOX[5] = 1
786
864
  let out: Sprite[] = []
787
- for (let hit of spatial.overlap("box", BOX)) {
788
- let sprite = byNode.get(hit.node)
789
- 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]] })
790
881
  }
791
882
  return out
792
883
  },
793
- handlersFor(layout) {
794
- 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
+ }
795
936
  },
796
937
  dispose() {
797
938
  if (disposed) return
798
939
  disposed = true
799
- listeners.clear()
800
940
  for (let sprite of byNode.values()) {
801
941
  sprite.layer = null
802
942
  declared.delete(sprite.node!)
@@ -809,13 +949,14 @@ export function createSpriteLayer(
809
949
  spatial.destroyNode(group.node)
810
950
  }
811
951
  layer._groups.clear()
952
+ spatial.destroyNode(root)
812
953
  // Let the core emit its final slot-zeroing writes while the pose
813
954
  // buffer still exists, then free everything.
814
955
  spatial.flush()
815
- destroyTexture(texture)
956
+ views.dispose()
816
957
  destroyBuffer(pose)
817
958
  destroyBuffer(style)
818
- destroyBuffer(quad)
959
+ gpu.dispose()
819
960
  },
820
961
  _add(opts) {
821
962
  if (disposed) throw new Error("addSprite: layer is disposed")
@@ -842,8 +983,10 @@ export function createSpriteLayer(
842
983
  if (opts.parent.layer !== layer) throw new Error("addSprite: parent group belongs to another layer")
843
984
  spatial.setParent(node, opts.parent.node)
844
985
  opts.parent._children.add(sprite)
986
+ } else {
987
+ spatial.setParent(node, root)
845
988
  }
846
- spatial.setBounds(node, FLAT_BOUNDS)
989
+ spatial.setBounds(node, COLUMN_BOUNDS)
847
990
  spatial.bindPoseRecord(node, pose, slot)
848
991
  byNode.set(node, sprite)
849
992
  // renderOrder defaults to 0 explicitly: a recycled slot holds the
@@ -912,16 +1055,8 @@ export function createSpriteLayer(
912
1055
  RESOLVED.then(flush)
913
1056
  },
914
1057
  _groups: new Set(),
1058
+ _root: root,
915
1059
  }
916
- let dispatch = spriteDispatch({
917
- size: () => [width, height],
918
- camera: () => layer.camera(),
919
- pick: (x, y) => layer.pick(x, y),
920
- root: layer,
921
- listeners,
922
- })
923
- layer.handlers = dispatch(null)
924
-
925
1060
  if (opts?.autoFree !== false && getOwner()) onCleanup(() => layer.dispose())
926
1061
  return layer
927
1062
  }
@@ -991,7 +1126,9 @@ export function setSpriteParent(sprite: Sprite, parent: SpriteGroup | null): voi
991
1126
  if (!layer) return
992
1127
  if (sprite.node === null) throw new Error("setSpriteParent: record layers have no groups")
993
1128
  if (parent && parent.layer !== layer) throw new Error("setSpriteParent: group belongs to another layer")
994
- 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)
995
1132
  let s: SpriteState = sprite
996
1133
  if (s._parent) s._parent._children.delete(s)
997
1134
  s._parent = parent
@@ -1046,6 +1183,8 @@ export function addGroup(layer: SpriteLayer, opts?: GroupOptions): SpriteGroup {
1046
1183
  if (opts.parent.layer !== layer) throw new Error("addGroup: parent group belongs to another layer")
1047
1184
  spatial.setParent(group.node, opts.parent.node)
1048
1185
  opts.parent._children.add(group)
1186
+ } else {
1187
+ spatial.setParent(group.node, layer._root)
1049
1188
  }
1050
1189
  layer._groups.add(group)
1051
1190
  layer._schedule()
@@ -1077,7 +1216,7 @@ export function setGroup(group: SpriteGroup, opts: GroupOptions): void {
1077
1216
  }
1078
1217
  if (opts.parent !== undefined) {
1079
1218
  if (opts.parent && opts.parent.layer !== layer) throw new Error("setGroup: parent group belongs to another layer")
1080
- spatial.setParent(group.node, opts.parent ? opts.parent.node : null)
1219
+ spatial.setParent(group.node, opts.parent ? opts.parent.node : layer._root)
1081
1220
  if (g._parent) g._parent._children.delete(g)
1082
1221
  g._parent = opts.parent
1083
1222
  if (opts.parent) opts.parent._children.add(g)