// The live sprite layer, re-founded on the spatial core: every sprite is a // SPATIAL ARENA node (arena slot: local pose, parent, world matrix, index // leaf, record sink - no layout, no paint, no rendertree element) whose // Pose2D record sink writes [x, y, angle, sx, sy] into the sprite's slot of // the pose instance buffer at the core's flush. Rendering is one // instanced draw per VIEW (views.ts: the layer shows through its views // only, each a target with a camera of its own); what changed is who owns // the pose upstream of the instance buffer - the arena, so every core // producer (native transitions, animation clips, physics) reaches sprites // through `sprite.node`, and picking walks the core BVH instead of a JS // loop. // // Two instance-buffer slots split ownership: slot 0 is the pose buffer, // written ONLY by the core (one coalesced write per flush however many // nodes moved); slot 1 is the SPRITE record [u0, v0, u1, v1, tint rgba, // renderOrder, minScreenPx, maxScreenPx, atlas, outline], JS-owned // through the sprite verbs and published through the zero-copy write // lease. After them come the MATERIAL's style streams (style.ts), one // per instance buffer its material declares: app-owned per-sprite data // in any vertex format, written with setInstanceStyle, instanceAttribute // or records(layer, stream). Never write the pose buffer from JS - the // core's staging mirror is the owner and will overwrite. For motion only // JS can compute at large populations, the records layer (records.ts) // is the escape hatch. // // A layer draws from the ATLASES it declares at creation, bound together // as one draw: every frame carries its texture, the sprite record stores // that texture's index in the list, and the fragment set generated for // the list (glsl.ts) picks the sampler per instance - so sprites from // several sheets interleave freely in one draw, key order included, and // a frame from a sheet the layer did not declare throws at the write. // What the pixels look like is the layer's MATERIAL (material.ts): the // stock `unlit()` unless the options name one, which owns the pipeline // (its blend, its program over the layer's set) and whose params and // textures every view seeds. // // Sprites hold FIXED instance slots (freed slots recycle): draw order is // slot order, so removal never shifts records and pose sinks never rebind. // Layer space is pixels, top-left origin, y-down - the render tree's // frame. A view's camera (offset, zoom, rotation about a pivot - // CameraUpdate in camera.ts) is a shared-params write (uCamera + // uCameraRot) on that view's target, never per-sprite; pointer dispatch // (dispatch.ts) undoes it with unprojectCamera. import { getOwner, onBeforeRender, onCleanup, runWithOwner } from "@solidrt/core" import type { PointerEvent as ElementPointerEvent, WheelEvent as ElementWheelEvent } from "@solidrt/core" import { checkScreenSize, createBuffer, createRecordStream, destroyBuffer, screenSizeScale } from "@solidrt/core/gpu" import type { BufferId, RecordStream, ShaderParams, TextureId } from "@solidrt/core/gpu" import * as spatial from "flux:spatial" import type { Impact as CoreImpact, MoveOptions as CoreMoveOptions, NodeEndpoint, NodeId, NodeMotionSpec, NodeTransition, NodeTransitionSpec, QueryFilter, } from "flux:spatial" import { on } from "sol:events" import { checkAtlases, frameIndex } from "./atlas.ts" import type { Atlas } from "./atlas.ts" import type { CameraState, CameraUpdate } from "./camera.ts" import type { Frame } from "./frames.ts" import { fullFrame, isFrame, writeFrame } from "./frames.ts" import type { RecordLayer } from "./records.ts" import { floorReach, pointInSprite } from "./pick.ts" import { atlasBindings, POSE_ATTRIBUTES, SPRITE_ATTRIBUTES } from "./glsl.ts" import { materialSamplers, unlit } from "./material.ts" import type { Material } from "./material.ts" import { blankStyle, createStyleStreams, growStyle } from "./style.ts" import type { StyleStream } from "./style.ts" import { createViews } from "./views.ts" import type { ViewHandle, ViewOptions } from "./views.ts" /** Floats per pose record (the core's Pose2D projection). */ export const POSE_FLOATS = 5 // Float offset of world y in a pose record - what `orderBy: "y"` keys on. const POSE_Y_FIELD = 1 /** Floats per sprite record (the node layer's slot 1, SPRITE_ATTRIBUTES): * [u0, v0, u1, v1, tintR, tintG, tintB, tintA, renderOrder, minScreenPx, * maxScreenPx, atlas, outlineR, outlineG, outlineB, outlineWidth]. */ export const SPRITE_FLOATS = 16 // Float offset of renderOrder in a sprite record - what `orderBy: "renderOrder"` // keys on. const SPRITE_KEY_FIELD = 8 // Float offsets of the screen-size clamp in a sprite record. const SPRITE_MIN_PX_FIELD = 9 const SPRITE_MAX_PX_FIELD = 10 // Float offset of the outline (rgb plus width in world pixels) a // distance-field atlas draws under the sprite; text runs write it, a // colour atlas ignores it. const SPRITE_OUTLINE_FIELD = 12 // Float offset of the atlas sampler index (the frame's texture's position // in the layer's atlas list). const SPRITE_ATLAS_FIELD = 11 const RESOLVED = Promise.resolve() // Shared marshalling scratch (the bindings copy synchronously). const TRANSFORM = new Float64Array(10) // Half depth of a sprite's index box along z, node units (a sprite's z // scale is 1, so world units too). A sprite is a COLUMN in the index, not // a flat quad: a volume query at z = 0 then only ever meets its side // faces, so every contact normal and depth is in the plane, and a circle // whose center lies over a sprite pushes out sideways instead of along z // (the core's contact against a flat triangle is the plane normal). Far // larger than any volume's reach, so the end faces never take part. const SPRITE_DEPTH = 1e4 const COLUMN_BOUNDS = new Float32Array([-0.5, -0.5, -SPRITE_DEPTH, 0.5, 0.5, SPRITE_DEPTH]) const RAY_ORIGIN = new Float64Array(3) const RAY_DIR = new Float64Array(3) // Volume scratch: a "capsule" is a, b, radius (7 floats), a "box" is // center, half extents, quaternion (10); both packed at z = 0. const CAPSULE = new Float64Array(7) const BOX = new Float64Array(10) const MOTION = new Float64Array(3) // A box volume's half depth: any positive value inside the columns. const BOX_HALF_DEPTH = 1 // The pick ray starts this far in front of the plane; it crosses every // column at z = 0 whatever the start, but a start inside the column meets // only its far end face, which is fine for pick (draw order sorts, not // distance). const PICK_RAY_START = -1 // The direction floors face in a y-down world. const UP_2D: [number, number] = [0, -1] // worldPosition's world-matrix scratch (column-major; translation at 12, 13). const WORLD = new Float64Array(16) // keepWorld's second scratch: the new parent's world matrix. const WORLD_PARENT = new Float64Array(16) // The pose keepWorld composes: [x, y, angle, sx, sy]. const LOCAL_POSE = new Float64Array(5) // Settle routing: the core's "spatialTransitionEnd" event carries the node // id, so the handles with a transition DECLARED are indexed by node (only // those can settle; adding a sprite costs nothing here) and one lazy // subscription, started at the first declaration, routes to the handle's // onTransitionEnd. Target-only, like the element transitions. let declared = new Map() let subscribed = false // Free routing: a destroyed sprite or group whose declaration carries an // `exit` stays in the core as a LEAVING node (drawn, picked by nothing) // until its exits settle, and the core's "spatialNodeFreed" event says // when it is gone - the cue to recycle what the layer kept for it (a // sprite's pose slot). One lazy subscription, keyed by node. let freeing = new Map void>() let freeingSubscribed = false function onFreed(node: NodeId, done: () => void): void { freeing.set(node, done) if (freeingSubscribed) return freeingSubscribed = true on("spatialNodeFreed", (event: { node: NodeId }) => { let done = freeing.get(event.node) if (!done) return freeing.delete(event.node) done() }) } function declareTransition(node: NodeId, handle: Sprite | SpriteGroup, transition: NodeTransition | string | null): void { if (transition === null) { declared.delete(node) return } declared.set(node, handle) if (subscribed) return subscribed = true on("spatialTransitionEnd", (event: { node: NodeId; component: TransitionEndEvent["component"] }) => { let handle = declared.get(event.node) if (!handle) return try { handle.onTransitionEnd?.({ component: event.component }) } catch (err) { console.error("Error in onTransitionEnd handler:", err) } }) } /** * A node's pose in the frame of `parent` (null = the layer root, identity) * as the tree stands now - the world matrix the core holds, pending * writes included, so under a transition the pose of THIS frame - into * LOCAL_POSE as [x, y, angle, sx, sy]: the plane's 2x2 and translation of * inverse(parent world) * node world, the angle `atan2(m[1], m[0])` and * sy negated under a mirroring (the core's own Pose2D rule). What * `keepWorld` re-homes a handle by. */ function localPoseIn(node: NodeId, parent: SpriteGroup | null): void { spatial.worldMatrix(node, WORLD) let a = 1 let b = 0 let c = 0 let d = 1 let tx = 0 let ty = 0 if (parent !== null) { spatial.worldMatrix(parent.node, WORLD_PARENT) let det = WORLD_PARENT[0]! * WORLD_PARENT[5]! - WORLD_PARENT[1]! * WORLD_PARENT[4]! if (det === 0) throw new Error("keepWorld: the new parent's frame is collapsed (a zero scale)") a = WORLD_PARENT[5]! / det b = -WORLD_PARENT[1]! / det c = -WORLD_PARENT[4]! / det d = WORLD_PARENT[0]! / det tx = -(a * WORLD_PARENT[12]! + c * WORLD_PARENT[13]!) ty = -(b * WORLD_PARENT[12]! + d * WORLD_PARENT[13]!) } let m0 = a * WORLD[0]! + c * WORLD[1]! let m1 = b * WORLD[0]! + d * WORLD[1]! let m4 = a * WORLD[4]! + c * WORLD[5]! let m5 = b * WORLD[4]! + d * WORLD[5]! LOCAL_POSE[0] = a * WORLD[12]! + c * WORLD[13]! + tx LOCAL_POSE[1] = b * WORLD[12]! + d * WORLD[13]! + ty LOCAL_POSE[2] = Math.atan2(m1, m0) LOCAL_POSE[3] = Math.hypot(m0, m1) LOCAL_POSE[4] = Math.hypot(m4, m5) * (m0 * m5 - m1 * m4 < 0 ? -1 : 1) } /** * Snap a node's local transform to TRANSFORM, through nothing: a running * transition would overwrite a raw write at the next frame (the producer * rule) and would carry a target spelled in the OLD parent's frame, so a * declared node's declaration is cleared around the write (its tracks * end where they stand, which is the kept pose) and put back for the * writes that follow. */ function snapTransform(handle: SpriteState | GroupState, node: NodeId): void { let declaration = handle._transition if (declaration !== null) spatial.setTransition(node, null) spatial.setTransform(node, TRANSFORM) if (declaration !== null) spatial.setTransition(node, declaration) } /** * One sprite: a handle into its layer. Read via getSprite; write through * setSprite so changes publish. The pointer handlers are plain assignable * fields - they touch no GPU state (the scene-graph handler rule). */ export type Sprite = { /** The owning layer, null after destroySprite (which owns the write). */ readonly layer: SpriteLayer | RecordLayer | null /** * The sprite's SPATIAL ARENA node - the citizenship handle: bind core * producers to it or reach it through flux:spatial directly (the layer * still owns the node's life; destroy it only via destroySprite). Null * on a record layer's sprites. */ readonly node: NodeId | null /** Instance slot: fixed for the sprite's life on the node layer, the * shifting draw-order index on a record layer. Readable (readonly, like * the other underscore fields: cheap reads without getSprite's * allocation); writes go through setSprite. */ readonly _slot: number /** Pose mirror (node layer): what setSprite composes transforms from. * On a RECORD layer this mirror goes stale once records are written * raw - there the records array is the truth. */ readonly _x: number readonly _y: number readonly _w: number readonly _h: number readonly _rot: number /** Mirror flags (both layer kinds): re-applied to every frame write. * The cheap way to read flip state. */ readonly _flipX: boolean readonly _flipY: boolean /** Visibility mirror (node layer; always true on a record layer's * sprites). The cheap read; writes go through setSprite. */ readonly _visible: boolean /** The enclosing group (null at the layer root, always null on a record * layer's sprites) - the bubble path; writes go through * setSpriteParent/addSprite's `parent`. */ readonly _parent: SpriteGroup | null /** The transition as declared to the core (setSpriteTransition), null * for none; internal - what a snapping write restores. */ readonly _transition: NodeTransition | string | null onPointerDown?: (event: SpritePointerEvent) => void onPointerMove?: (event: SpritePointerEvent) => void onPointerUp?: (event: SpritePointerEvent) => void /** The pointer's other end: the system took it away (see core's * onPointerCancel); bubbles like up, and the press it ends never taps. */ onPointerCancel?: (event: SpritePointerEvent) => void onPointerEnter?: (event: SpritePointerEvent) => void onPointerLeave?: (event: SpritePointerEvent) => void /** A wheel notch over the sprite; bubbles like down/move/up. */ onWheel?: (event: SpriteWheelEvent) => void /** A press that released on this sprite without dragging (see * SpriteTapEvent); bubbles like down/move/up. */ onTap?: (event: SpriteTapEvent) => void /** A declared transition (setSpriteTransition) settled naturally on * one component; a cancel or snap never fires. */ onTransitionEnd?: (event: TransitionEndEvent) => void } /** The layer-internal mutable view of a Sprite: the readonly on the * underscore fields is for the public surface; internal write sites * annotate with this (readonly does not affect assignability, so the two * types flow into each other freely). */ export type SpriteState = { -readonly [K in keyof Sprite]: Sprite[K] } /** The settled component of a node transition: `position` is x/y, * `scale` w/h (a group's uniform scale). */ export type TransitionEndEvent = { component: "position" | "rotation" | "scale" } /** Sprite fields, all optional at every call site: absent keys keep values. */ export type SpriteOptions = { /** Center position in layer pixels (in the parent group's frame when the * sprite is grouped). */ x?: number y?: number /** Drawn size in layer pixels. */ w?: number h?: number /** Atlas frame (a texture plus normalized UVs, from grid/namedFrames/ * fullFrame); default the whole first atlas of the layer. Its texture * must be one of the layer's `atlases`, else the write throws. */ frame?: Frame /** Mirror the frame horizontally / vertically about the sprite's center. * A UV-side mirror: w/h stay the drawn size, a scale transition never * sees it, picking is unchanged. Default false. */ flipX?: boolean flipY?: boolean /** Rotation about the center, radians, clockwise (y-down space). */ rotation?: number /** RGBA multiplier 0..1 each; default opaque white (the texture as-is). */ tint?: [number, number, number, number] /** * The screen-size clamp's floor, view pixels (default 0: off). Whatever * the camera's zoom, the sprite draws at least this big on its smaller * axis, scaled uniformly so its aspect holds - a selection ring that * stays findable at the overview, a map pin, a traffic dot that never * falls under two device pixels. A floored sprite is world-sized until * zoom shrinks it to the floor, then holds there. Applied in the vertex * stage over the pose the core writes (core's screenSizeScale), so it * comes after group scale and a size transition, and rotation stays * world-space; `pick` honours it at the zoom it is asked for. A * collapsed sprite (zero w or h) stays collapsed. Finite, >= 0. */ minScreenPx?: number /** * The clamp's ceiling, view pixels (default 0: off): the sprite draws * at most this big on its smaller axis however far the camera zooms in * - a label marker that must not balloon. Equal to `minScreenPx` it is * a constant screen size, Three's `sizeAttenuation: false`, and `w`/`h` * then carry the aspect alone. Never below a floor that is on. */ maxScreenPx?: number /** * Explicit draw-order key, read only by a layer created with `orderBy: * "renderOrder"` (default 0; ties keep slot order, so untouched sprites draw * as without one). The raise idiom: `setSprite(hit, { renderOrder: ++top })` * on interaction, back to 0 to restore. Node layer only - a record * layer's 16-float record has no key field (order it by one of its own * fields with `orderBy: { field }`); setting this there throws. */ renderOrder?: number /** * Show or hide the sprite (default true), @solidrt/3d's setVisible in * the setSprite bag: hidden, its pose slot zeroes (nothing drawn) and * pick/pickRect skip it; the handle, slot, sprite records and any * running transition state stay, so showing again restores the sprite * as it was. Node layer only - a record sprite has no node (hide it by * zeroing w or h); setting this there throws. */ visible?: boolean } /** * addSprite's bag: the sprite fields plus the mount point. The pool idiom * for a game: reserve `capacity`, add every sprite at mount with `visible: * false`, then show/hide - a hidden sprite keeps its slot, so draw order * stays stable and a spawn is one setSprite, never an add. */ export type AddSpriteOptions = SpriteOptions & { /** Mount under this group (node layer only; null = the layer root, the * default); pose fields are then local to it. Reparent later with * setSpriteParent. */ parent?: SpriteGroup | null } /** What every layer pointer event carries, whichever handler sees it. */ export type LayerEventBase = { /** Pointer position in LAYER pixels (the camera mapping undone). */ x: number y: number /** When the event happened, ms: the element event's `timeStamp`. */ timeStamp: number /** The element event's `predicted`: a move the pointer was never * reported at. Draw with it, do not measure with it. */ predicted: boolean pointerId: number pointerType: string /** The pressed button on a down, up or tap (0 = primary, 2 = secondary; * touch is always 0), as the element event carries it; absent on a * move, cancel or wheel. */ button?: number shiftKey: boolean ctrlKey: boolean altKey: boolean metaKey: boolean /** * The element event the layer's leaf received, in the leaf's own frame * (localX/localY, clientX/clientY, movementX/Y): what core's * recognizers consume, so a sprite drags itself through `createPan` and * a camera rides `createTransform` on the same events the walk carries. */ native: ElementPointerEvent /** * Stops the walk after the current handler: no enclosing group and none * of the layer's listeners see the event. Stopping a DOWN claims the * whole press - that pointer's move, up and tap never reach the layer * either, so a sprite that drags itself stops its down once and a * camera attached at the root never pans it. */ stopPropagation(): void } /** The event as a sprite's or group's handler sees it. */ export type SpritePointerEvent = LayerEventBase & { /** The sprite the event is about (the topmost hit at the point, or the * captured sprite during a drag) - constant while the event bubbles. */ sprite: Sprite /** The sprite or group whose handler is running; changes as the event * bubbles from the hit sprite through its enclosing groups. */ currentTarget: Sprite | SpriteGroup } /** * The event as the VIEW's listeners see it (ViewHandle.listen), the last * stop of the walk: `sprite` is the hit sprite it bubbled from, or null * over empty space, where the view is the only target. */ export type LayerPointerEvent = LayerEventBase & { sprite: Sprite | null currentTarget: ViewHandle } type WheelFields = { /** The wheel delta as the element event reports it. */ deltaX: number deltaY: number native: ElementWheelEvent } type TapFields = { /** 1 for a tap, 2 for the second of a double tap (the same target, * within the repeat interval and distance), and so on - DOM's `detail`, * Unity's `clickCount`. */ tapCount: number } export type SpriteWheelEvent = SpritePointerEvent & WheelFields export type LayerWheelEvent = LayerPointerEvent & WheelFields /** * A primary-button press (DOM click: a right click never taps) that * released on the target it pressed without travelling past the slop, the * only pointer down for its whole press (a pinch never taps). Dispatched * after the up, bubbling the same way; `x`/`y` are the release point. */ export type SpriteTapEvent = SpritePointerEvent & TapFields export type LayerTapEvent = LayerPointerEvent & TapFields /** A listener at a view's root; see ViewHandle.listen. */ export type LayerPointerListener = { onPointerDown?: (event: LayerPointerEvent) => void onPointerMove?: (event: LayerPointerEvent) => void onPointerUp?: (event: LayerPointerEvent) => void onPointerCancel?: (event: LayerPointerEvent) => void onWheel?: (event: LayerWheelEvent) => void onTap?: (event: LayerTapEvent) => void } /** The element handlers for the leaf that shows a view (`ViewHandle.handlers`); * spread onto the host element. */ export type LayerHandlers = { onPointerDown: (event: ElementPointerEvent) => void onPointerMove: (event: ElementPointerEvent) => void onPointerUp: (event: ElementPointerEvent) => void onPointerCancel: (event: ElementPointerEvent) => void onPointerLeave: (event: ElementPointerEvent) => void onWheel: (event: ElementWheelEvent) => void } /** Validate an [r, g, b, a] tint (throws - the dev validation policy). * Internal - every layer kind (sprite, record, tile) calls it. */ export function checkTint(verb: string, tint: [number, number, number, number]): void { if (!(Array.isArray(tint) && tint.length === 4 && tint.every(Number.isFinite))) { throw new Error(`${verb}: tint must be [r, g, b, a], got ${JSON.stringify(tint)}`) } } /** Validate a params record (throws - the dev validation policy): a * plain object of finite numbers or number lists. Internal - every * layer kind's setParams calls it. */ export function checkParams(verb: string, params: ShaderParams): void { if (typeof params !== "object" || params === null || Array.isArray(params)) throw new Error(`${verb}: params must be an object of uniform values, got ${JSON.stringify(params)}`) for (let [name, value] of Object.entries(params)) { let ok = typeof value === "number" ? Number.isFinite(value) : Array.isArray(value) && value.length > 0 && value.every(Number.isFinite) if (!ok) throw new Error(`${verb}: ${name} must be a finite number or a list of them, got ${JSON.stringify(value)}`) } } export type SpriteLayerOptions = { /** * Initial slot reservation; default 1024. The layer grows past it on * demand (doubling), so this is a hint that avoids regrowth copies, not a * limit. */ capacity?: number /** Layer tint, [r, g, b, a] in 0..1, multiplied over every sprite's own * tint in every view; default opaque white (sprites as-is). See * setTint. */ tint?: [number, number, number, number] /** * What the sprites look like: the layer's material (material.ts), * fixed at creation - its pipeline (the blend mode, its program over * the layer's atlases), its params and its textures on every view, * its instance buffers as per-sprite style streams after the layer's * own records. Default `unlit()`: the atlas texel times the tints, * alpha-blended in draw order. An additive glow layer is * `material: unlit({ blend: "add" })` over the same atlases; a look is * `unlit({ prelude, surface })` or a shaderMaterialClass instance. The * material sits on the LAYER because the layer is the batch - two * looks are two layers, which is also how you order them. */ material?: Material /** * Uniform values seeded on every view of the layer over the * material's own (a prelude's uniforms: a flash amount, a palette * row, a clock); see setParams. Names the program does not declare * are skipped. */ params?: ShaderParams /** Names the GPU resources (buffers, pipeline; views default to * `