@solidrt/2d 0.0.67 → 0.0.68

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
@@ -13,10 +13,18 @@
13
13
  // Two instance-buffer slots split ownership: slot 0 is the pose buffer,
14
14
  // written ONLY by the core (one coalesced write per flush however many
15
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
17
- // the pose buffer from JS - the core's staging mirror is the owner and
18
- // will overwrite. For motion only JS can compute at large populations, the
19
- // records layer (records.ts) is the escape hatch.
16
+ // renderOrder, minScreenPx, maxScreenPx, atlas], JS-owned and published
17
+ // through the zero-copy write lease. Never write the pose buffer from JS -
18
+ // the core's staging mirror is the owner and will overwrite. For motion
19
+ // only JS can compute at large populations, the records layer (records.ts)
20
+ // is the escape hatch.
21
+ //
22
+ // A layer draws from the ATLASES it declares at creation, bound together
23
+ // as one draw: every frame carries its texture, the style record stores
24
+ // that texture's index in the list, and the generated fragment stage
25
+ // (shaders.ts) picks the sampler per instance - so sprites from several
26
+ // sheets interleave freely in one draw, key order included, and a frame
27
+ // from a sheet the layer did not declare throws at the write.
20
28
  //
21
29
  // Sprites hold FIXED instance slots (freed slots recycle): draw order is
22
30
  // slot order, so removal never shifts records and pose sinks never rebind.
@@ -25,10 +33,10 @@
25
33
  // CameraUpdate in camera.ts) is a shared-params write (uCamera +
26
34
  // uCameraRot) on that view's target, never per-sprite; pointer dispatch
27
35
  // (dispatch.ts) undoes it with unprojectCamera.
28
- import { getOwner, onCleanup } from "@solidrt/core"
36
+ import { getOwner, onBeforeRender, onCleanup, runWithOwner } from "@solidrt/core"
29
37
  import type { PointerEvent as ElementPointerEvent, WheelEvent as ElementWheelEvent } from "@solidrt/core"
30
- import { beginBufferWrite, createBuffer, destroyBuffer, endBufferWrite } from "@solidrt/core/gpu"
31
- import type { BufferId, TextureId } from "@solidrt/core/gpu"
38
+ import { beginBufferWrite, checkScreenSize, createBuffer, destroyBuffer, endBufferWrite, screenSizeScale } from "@solidrt/core/gpu"
39
+ import type { BlendMode, BufferId, TextureId } from "@solidrt/core/gpu"
32
40
  import * as spatial from "flux:spatial"
33
41
  import type {
34
42
  Impact as CoreImpact,
@@ -41,10 +49,13 @@ import type {
41
49
  QueryFilter,
42
50
  } from "flux:spatial"
43
51
  import { on } from "sol:events"
52
+ import { checkAtlases, frameIndex } from "./atlas.ts"
53
+ import type { Atlas } from "./atlas.ts"
44
54
  import type { CameraState, CameraUpdate } from "./camera.ts"
45
55
  import type { Frame } from "./frames.ts"
46
- import { FULL_FRAME, writeFrame } from "./frames.ts"
56
+ import { fullFrame, isFrame, writeFrame } from "./frames.ts"
47
57
  import type { RecordLayer } from "./records.ts"
58
+ import { floorReach, pointInSprite } from "./pick.ts"
48
59
  import { createSpritePipeline, INSTANCE_LAYOUTS_SPLIT, VERTEX_SPLIT } from "./shaders.ts"
49
60
  import { createViews } from "./views.ts"
50
61
  import type { ViewHandle, ViewOptions } from "./views.ts"
@@ -55,12 +66,23 @@ export const POSE_FLOATS = 5
55
66
  // Float offset of world y in a pose record - what `orderBy: "y"` keys on.
56
67
  const POSE_Y_FIELD = 1
57
68
  /** Floats per style record:
58
- * [u0, v0, u1, v1, tintR, tintG, tintB, tintA, renderOrder]. */
59
- export const STYLE_FLOATS = 9
69
+ * [u0, v0, u1, v1, tintR, tintG, tintB, tintA, renderOrder, minScreenPx,
70
+ * maxScreenPx, atlas, outlineR, outlineG, outlineB, outlineWidth]. */
71
+ export const STYLE_FLOATS = 16
60
72
 
61
73
  // Float offset of renderOrder in a style record - what `orderBy: "renderOrder"`
62
74
  // keys on.
63
75
  const STYLE_KEY_FIELD = 8
76
+ // Float offsets of the screen-size clamp in a style record.
77
+ const STYLE_MIN_PX_FIELD = 9
78
+ const STYLE_MAX_PX_FIELD = 10
79
+ // Float offset of the outline (rgb plus width in world pixels) a
80
+ // distance-field atlas draws under the sprite; text runs write it, a
81
+ // colour atlas ignores it.
82
+ const STYLE_OUTLINE_FIELD = 12
83
+ // Float offset of the atlas sampler index (the frame's texture's position
84
+ // in the layer's atlas list).
85
+ const STYLE_ATLAS_FIELD = 11
64
86
 
65
87
  const RESOLVED = Promise.resolve()
66
88
 
@@ -219,7 +241,9 @@ export type SpriteOptions = {
219
241
  /** Drawn size in layer pixels. */
220
242
  w?: number
221
243
  h?: number
222
- /** Atlas frame (normalized UVs); default the whole atlas. */
244
+ /** Atlas frame (a texture plus normalized UVs, from grid/namedFrames/
245
+ * fullFrame); default the whole first atlas of the layer. Its texture
246
+ * must be one of the layer's `atlases`, else the write throws. */
223
247
  frame?: Frame
224
248
  /** Mirror the frame horizontally / vertically about the sprite's center.
225
249
  * A UV-side mirror: w/h stay the drawn size, a scale transition never
@@ -230,12 +254,33 @@ export type SpriteOptions = {
230
254
  rotation?: number
231
255
  /** RGBA multiplier 0..1 each; default opaque white (the texture as-is). */
232
256
  tint?: [number, number, number, number]
257
+ /**
258
+ * The screen-size clamp's floor, view pixels (default 0: off). Whatever
259
+ * the camera's zoom, the sprite draws at least this big on its smaller
260
+ * axis, scaled uniformly so its aspect holds - a selection ring that
261
+ * stays findable at the overview, a map pin, a traffic dot that never
262
+ * falls under two device pixels. A floored sprite is world-sized until
263
+ * zoom shrinks it to the floor, then holds there. Applied in the vertex
264
+ * stage over the pose the core writes (core's screenSizeScale), so it
265
+ * comes after group scale and a size transition, and rotation stays
266
+ * world-space; `pick` honours it at the zoom it is asked for. A
267
+ * collapsed sprite (zero w or h) stays collapsed. Finite, >= 0.
268
+ */
269
+ minScreenPx?: number
270
+ /**
271
+ * The clamp's ceiling, view pixels (default 0: off): the sprite draws
272
+ * at most this big on its smaller axis however far the camera zooms in
273
+ * - a label marker that must not balloon. Equal to `minScreenPx` it is
274
+ * a constant screen size, Three's `sizeAttenuation: false`, and `w`/`h`
275
+ * then carry the aspect alone. Never below a floor that is on.
276
+ */
277
+ maxScreenPx?: number
233
278
  /**
234
279
  * Explicit draw-order key, read only by a layer created with `orderBy:
235
280
  * "renderOrder"` (default 0; ties keep slot order, so untouched sprites draw
236
281
  * as without one). The raise idiom: `setSprite(hit, { renderOrder: ++top })`
237
282
  * on interaction, back to 0 to restore. Node layer only - a record
238
- * layer's 13-float record has no key field (order it by one of its own
283
+ * layer's 16-float record has no key field (order it by one of its own
239
284
  * fields with `orderBy: { field }`); setting this there throws.
240
285
  */
241
286
  renderOrder?: number
@@ -381,6 +426,14 @@ export type SpriteLayerOptions = {
381
426
  * tint in every view; default opaque white (sprites as-is). See
382
427
  * setTint. */
383
428
  tint?: [number, number, number, number]
429
+ /**
430
+ * How the sprites blend into the layer's targets, core gpu's BlendMode;
431
+ * default "alpha" (premultiplied source-over in draw order). "add" for
432
+ * glows, explosions and additive particles, "multiply" to darken, "none"
433
+ * to overwrite. Pipeline state, fixed at creation: an additive layer is
434
+ * a second layer over the same atlas.
435
+ */
436
+ blend?: BlendMode
384
437
  /** Names the GPU resources (buffers, pipeline; views default to
385
438
  * `<label>-view`); default "sprites". */
386
439
  label?: string
@@ -489,6 +542,15 @@ export type GroupOptions = {
489
542
  * and a Godot World2D only through Viewports.
490
543
  */
491
544
  export type LayerBase = {
545
+ /**
546
+ * The atlases the layer draws from, as declared at creation: every
547
+ * frame written to the layer names one of their textures, and a
548
+ * record's `atlas` field is the texture's index in this list (what a
549
+ * raw record writer stores). Bound together as one draw per view -
550
+ * the multi-texture batch, so sprites from different sheets interleave
551
+ * in draw order.
552
+ */
553
+ readonly atlases: readonly Atlas[]
492
554
  /** Live sprite count. */
493
555
  readonly count: number
494
556
  /**
@@ -506,8 +568,11 @@ export type LayerBase = {
506
568
  * Topmost means draw order (highest slot on the node layer, last added
507
569
  * on a record layer); an `orderBy` key is not consulted (see
508
570
  * SpriteLayerOptions.orderBy's known limitation). World space: a view
509
- * undoes its camera before asking. */
510
- pick(x: number, y: number): Sprite[]
571
+ * undoes its camera before asking, and passes its camera's `zoom` so a
572
+ * sprite's screen-size floor (SpriteOptions.minScreenPx) is hit at the
573
+ * size it draws; default 1 (the unzoomed world). The pointer walk and
574
+ * `view.pick` do both. */
575
+ pick(x: number, y: number, zoom?: number): Sprite[]
511
576
  /**
512
577
  * A rendering of the layer's world from a camera of its own - the main
513
578
  * view, a minimap, a zoomed inset, one split-screen pane -
@@ -601,7 +666,8 @@ export type SpriteLayer = LayerBase & {
601
666
  * Every shown sprite whose rotated rect overlaps the layer-pixel rect
602
667
  * (the core BVH overlap query, exact for rotated sprites), unordered -
603
668
  * the marquee query: `overlap` over an unrotated rect, sprites only.
604
- * Node layer only.
669
+ * Node layer only. A world-space query like overlap and sweep: the
670
+ * sprites' world rects, never a screen-size floor (that is `pick`'s).
605
671
  */
606
672
  pickRect(x: number, y: number, width: number, height: number): Sprite[]
607
673
  /**
@@ -657,15 +723,20 @@ export type SpriteLayer = LayerBase & {
657
723
  * scopes the layer's queries in the arena shared with 3d scenes. */
658
724
  _root: NodeId
659
725
  _destroyGroup(group: GroupState): void
726
+ /** Write a sprite's outline (rgb 0..1 and a width in world pixels)
727
+ * for a distance-field atlas to draw under it; internal, the text
728
+ * runs' write (text.ts). A colour atlas ignores the field. */
729
+ _outline(sprite: Sprite, r: number, g: number, b: number, width: number): void
660
730
  }
661
731
 
662
- /** The stored UVs at `at` un-mirrored by the sprite's flags: the frame as
663
- * the caller gave it. Internal - records.ts reads through it too. */
664
- export function readFrame(data: Float32Array, at: number, sprite: Sprite): Frame {
732
+ /** The stored UVs at `at` un-mirrored by the sprite's flags, over the
733
+ * texture the record's atlas index resolved to: the frame as the caller
734
+ * gave it. Internal - records.ts reads through it too. */
735
+ export function readFrame(data: Float32Array, at: number, sprite: Sprite, texture: TextureId): Frame {
665
736
  let u0 = data[at]!, v0 = data[at + 1]!, u1 = data[at + 2]!, v1 = data[at + 3]!
666
737
  return sprite._flipX || sprite._flipY
667
- ? { u0: sprite._flipX ? u1 : u0, v0: sprite._flipY ? v1 : v0, u1: sprite._flipX ? u0 : u1, v1: sprite._flipY ? v0 : v1 }
668
- : { u0, v0, u1, v1 }
738
+ ? { texture, u0: sprite._flipX ? u1 : u0, v0: sprite._flipY ? v1 : v0, u1: sprite._flipX ? u0 : u1, v1: sprite._flipY ? v0 : v1 }
739
+ : { texture, u0, v0, u1, v1 }
669
740
  }
670
741
 
671
742
  /** Fill the shared transform scratch: xy translation, z rotation, xy scale
@@ -735,14 +806,19 @@ function writeTransform(sprite: Sprite): void {
735
806
  }
736
807
 
737
808
  /**
738
- * Create a sprite layer over one atlas texture. It renders nothing by
739
- * itself: `layer.createView({ width, height })` (or a `<SpriteLayer>` /
740
- * `<View2d>`) is where it shows, once or many times. Disposed
741
- * automatically with the owning reactive scope (opt out with
742
- * `{ autoFree: false }`); the atlas is NOT owned - dispose it yourself
743
- * (it commonly outlives layers).
809
+ * Create a sprite layer over its atlases: the list of sheets (createAtlas
810
+ * records, or `{ texture, width, height }` literals) every frame written
811
+ * to it must come from, bound together as ONE draw - up to
812
+ * `limits.maxTextureUnits` of them (16 on every GLES 3.0 device), of any
813
+ * sizes and sampler states, interleaving freely in draw order. It
814
+ * renders nothing by itself: `layer.createView({ width, height })` (or a
815
+ * `<SpriteLayer>` / `<View2d>`) is where it shows, once or many times.
816
+ * Disposed automatically with the owning reactive scope (opt out with
817
+ * `{ autoFree: false }`); the atlases are NOT owned - dispose them
818
+ * yourself (they commonly outlive layers).
744
819
  */
745
- export function createSpriteLayer(atlas: TextureId, opts?: SpriteLayerOptions): SpriteLayer {
820
+ export function createSpriteLayer(atlases: Atlas[], opts?: SpriteLayerOptions): SpriteLayer {
821
+ let atlasIndex = checkAtlases("createSpriteLayer", atlases)
746
822
  let capacity = opts?.capacity ?? 1024
747
823
  if (!(capacity > 0 && Number.isInteger(capacity))) {
748
824
  throw new Error(`createSpriteLayer: capacity must be a positive integer, got ${capacity}`)
@@ -752,12 +828,46 @@ export function createSpriteLayer(atlas: TextureId, opts?: SpriteLayerOptions):
752
828
  checkTint("createSpriteLayer", tint)
753
829
  let pose: BufferId = createBuffer(capacity * POSE_FLOATS * 4, { label: `${label}-pose`, autoFree: false })
754
830
  let style: BufferId = createBuffer(capacity * STYLE_FLOATS * 4, { label: `${label}-style`, autoFree: false })
755
- let gpu = createSpritePipeline(label, VERTEX_SPLIT, INSTANCE_LAYOUTS_SPLIT)
831
+ let gpu = createSpritePipeline(label, VERTEX_SPLIT, INSTANCE_LAYOUTS_SPLIT, opts?.blend ?? "alpha", atlases)
832
+ // The sprites with a screen-size clamp (either bound on), and the
833
+ // furthest any of them reaches from its center at its floor (floorReach,
834
+ // view pixels): the candidate box a pick searches around the pointer. A
835
+ // write that could only widen the reach raises it in place; one that may
836
+ // have shrunk it (a clamped sprite leaving, its floor or aspect dropping)
837
+ // marks it dirty and the next pick recomputes over the set - exact, and
838
+ // no cost while the clamps are untouched. Empty set: the raycast fast
839
+ // path, and no exact test at all.
840
+ let clamped = new Set<SpriteState>()
841
+ let reach = 0
842
+ let reachDirty = false
843
+ let dropClamp = (sprite: SpriteState): void => {
844
+ if (clamped.delete(sprite)) reachDirty = true
845
+ }
846
+ // The pair check against the STORED bounds, before any write lands: a
847
+ // ceiling set alone must not drop below the floor already there.
848
+ let checkClamp = (verb: string, sprite: SpriteState, opts: SpriteOptions): void => {
849
+ if (opts.minScreenPx === undefined && opts.maxScreenPx === undefined) return
850
+ let at = sprite._slot * STYLE_FLOATS
851
+ checkScreenSize(verb, opts.minScreenPx ?? styleData[at + STYLE_MIN_PX_FIELD]!, opts.maxScreenPx ?? styleData[at + STYLE_MAX_PX_FIELD]!)
852
+ }
756
853
 
757
854
  let disposed = false
758
855
  let scheduled = false
759
856
  let styleDirty = false
760
857
  let published = 0
858
+ // The publish pass of every frame runs the pending flush ahead of the
859
+ // paint, so a write made anywhere in the frame's JS is in that frame's
860
+ // picture; the microtask stays for writes made outside a frame.
861
+ // Registered outside any owner: dispose unhooks it (an autoFree: false
862
+ // layer outlives the owner it was created in).
863
+ let unhook = runWithOwner(null, () =>
864
+ onBeforeRender(
865
+ () => {
866
+ if (scheduled) flush()
867
+ },
868
+ { publish: true },
869
+ ),
870
+ )
761
871
 
762
872
  // Slot allocation: freed slots recycle, the high-water mark is the
763
873
  // published instance count (a freed slot's pose zeroes - zero scale
@@ -835,7 +945,16 @@ export function createSpriteLayer(atlas: TextureId, opts?: SpriteLayerOptions):
835
945
  styleDirty = true
836
946
  }
837
947
 
838
- let writeStyle = (sprite: SpriteState, opts: SpriteOptions) => {
948
+ // A frame's atlas index, or a throw: run before ANY field of a write
949
+ // lands (pose included), so a frame from an undeclared sheet leaves the
950
+ // sprite as it was.
951
+ let frameAtlas = (verb: string, opts: SpriteOptions): number => {
952
+ if (opts.frame === undefined) return -1
953
+ if (!isFrame(opts.frame)) throw new Error(`${verb}: not a frame, got ${JSON.stringify(opts.frame)}`)
954
+ return frameIndex(verb, atlasIndex, opts.frame)
955
+ }
956
+
957
+ let writeStyle = (sprite: SpriteState, opts: SpriteOptions, atlas: number) => {
839
958
  let at = sprite._slot * STYLE_FLOATS
840
959
  let flipX = opts.flipX !== undefined && opts.flipX !== sprite._flipX
841
960
  let flipY = opts.flipY !== undefined && opts.flipY !== sprite._flipY
@@ -844,6 +963,7 @@ export function createSpriteLayer(atlas: TextureId, opts?: SpriteLayerOptions):
844
963
  if (opts.frame !== undefined) {
845
964
  let f = opts.frame
846
965
  writeFrame(styleData, at, f.u0, f.v0, f.u1, f.v1, sprite._flipX, sprite._flipY)
966
+ styleData[at + STYLE_ATLAS_FIELD] = atlas
847
967
  styleDirty = true
848
968
  } else if (flipX || flipY) {
849
969
  // No new frame: toggle the changed axes on the stored UVs.
@@ -861,17 +981,34 @@ export function createSpriteLayer(atlas: TextureId, opts?: SpriteLayerOptions):
861
981
  styleData[at + STYLE_KEY_FIELD] = opts.renderOrder
862
982
  styleDirty = true
863
983
  }
984
+ if (opts.minScreenPx !== undefined) {
985
+ styleData[at + STYLE_MIN_PX_FIELD] = opts.minScreenPx
986
+ styleDirty = true
987
+ }
988
+ if (opts.maxScreenPx !== undefined) {
989
+ styleData[at + STYLE_MAX_PX_FIELD] = opts.maxScreenPx
990
+ styleDirty = true
991
+ }
992
+ let min = styleData[at + STYLE_MIN_PX_FIELD]!
993
+ if (min > 0 || styleData[at + STYLE_MAX_PX_FIELD]! > 0) {
994
+ clamped.add(sprite)
995
+ let r = floorReach(sprite._w, sprite._h, min)
996
+ if (r >= reach) reach = r
997
+ else reachDirty = true
998
+ } else {
999
+ dropClamp(sprite)
1000
+ }
864
1001
  }
865
1002
 
866
1003
  let views = createViews({
867
1004
  label,
868
1005
  pipeline: gpu.pipeline,
869
1006
  quad: gpu.quad,
870
- atlas,
1007
+ atlases: atlases.map(a => a.texture),
871
1008
  buffers: () => [pose, style],
872
1009
  count: () => published,
873
1010
  tint: () => tint,
874
- pick: (x, y) => layer.pick(x, y),
1011
+ pick: (x, y, zoom) => layer.pick(x, y, zoom),
875
1012
  // The order key one view entry declares: "y" on world y in the pose
876
1013
  // record (slot 0), "renderOrder" on the app-owned key in the style
877
1014
  // record (slot 1); either way the core gathers BOTH buffers under the
@@ -886,6 +1023,7 @@ export function createSpriteLayer(atlas: TextureId, opts?: SpriteLayerOptions):
886
1023
  })
887
1024
 
888
1025
  let layer: SpriteLayer = {
1026
+ atlases,
889
1027
  get count() {
890
1028
  return byNode.size
891
1029
  },
@@ -899,21 +1037,57 @@ export function createSpriteLayer(atlas: TextureId, opts?: SpriteLayerOptions):
899
1037
  if (disposed) throw new Error("createView: layer is disposed")
900
1038
  return views.create(vopts)
901
1039
  },
902
- pick(x, y) {
1040
+ pick(x, y, zoom = 1) {
903
1041
  // The index reads as of the last core flush; run any pending batch
904
1042
  // first so a write followed by a pick sees the write.
905
1043
  if (scheduled) flush()
1044
+ filter.nodes = undefined
1045
+ if (!(zoom > 0)) throw new Error(`pick: zoom must be positive, got ${zoom}`)
1046
+ let out: Sprite[] = []
906
1047
  RAY_ORIGIN[0] = x
907
1048
  RAY_ORIGIN[1] = y
908
1049
  RAY_ORIGIN[2] = PICK_RAY_START
909
1050
  RAY_DIR[0] = 0
910
1051
  RAY_DIR[1] = 0
911
1052
  RAY_DIR[2] = 1
912
- filter.nodes = undefined
913
- let out: Sprite[] = []
914
- for (let hit of spatial.raycast(RAY_ORIGIN, RAY_DIR, filter)) {
915
- let sprite = byNode.get(hit.node)
916
- if (sprite) out.push(sprite)
1053
+ if (clamped.size === 0) {
1054
+ for (let hit of spatial.raycast(RAY_ORIGIN, RAY_DIR, filter)) {
1055
+ let sprite = byNode.get(hit.node)
1056
+ if (sprite) out.push(sprite)
1057
+ }
1058
+ } else {
1059
+ // A clamped sprite draws at another size than its index column:
1060
+ // larger under a floor, smaller under a ceiling. The candidates
1061
+ // are every column containing the point (the ray) plus, when a
1062
+ // floor is on, every column within the widest floor's reach of it
1063
+ // (a box overlap - a SURFACE contact in the core, so a box lying
1064
+ // inside a big column reports nothing, which is why the ray stays
1065
+ // in the union), each then tested exactly against its drawn rect:
1066
+ // the world pose from the node's world matrix (the in-flight pose,
1067
+ // group scale composed), scaled by its clamp at this zoom - what
1068
+ // the vertex stage draws.
1069
+ if (reachDirty) {
1070
+ reach = 0
1071
+ for (let s of clamped) reach = Math.max(reach, floorReach(s._w, s._h, styleData[s._slot * STYLE_FLOATS + STYLE_MIN_PX_FIELD]!))
1072
+ reachDirty = false
1073
+ }
1074
+ let candidates = new Set<NodeId>()
1075
+ for (let hit of spatial.raycast(RAY_ORIGIN, RAY_DIR, filter)) candidates.add(hit.node)
1076
+ if (reach > 0) {
1077
+ let half = reach / zoom
1078
+ packVolume({ x: x - half, y: y - half, width: 2 * half, height: 2 * half }, "pick")
1079
+ for (let hit of spatial.overlap("box", BOX, filter)) candidates.add(hit.node)
1080
+ }
1081
+ for (let node of candidates) {
1082
+ let sprite = byNode.get(node)
1083
+ if (!sprite) continue
1084
+ spatial.worldMatrix(sprite.node!, WORLD)
1085
+ let w = Math.hypot(WORLD[0]!, WORLD[1]!)
1086
+ let h = Math.hypot(WORLD[4]!, WORLD[5]!)
1087
+ let at = sprite._slot * STYLE_FLOATS
1088
+ let scale = screenSizeScale(w, h, zoom, styleData[at + STYLE_MIN_PX_FIELD]!, styleData[at + STYLE_MAX_PX_FIELD]!)
1089
+ if (pointInSprite(x, y, WORLD[12]!, WORLD[13]!, w * scale, h * scale, Math.atan2(WORLD[1]!, WORLD[0]!))) out.push(sprite)
1090
+ }
917
1091
  }
918
1092
  // Topmost first: higher slot = drawn later = on top.
919
1093
  return out.sort((a, b) => b._slot - a._slot)
@@ -995,6 +1169,7 @@ export function createSpriteLayer(atlas: TextureId, opts?: SpriteLayerOptions):
995
1169
  dispose() {
996
1170
  if (disposed) return
997
1171
  disposed = true
1172
+ unhook()
998
1173
  for (let sprite of byNode.values()) {
999
1174
  sprite.layer = null
1000
1175
  declared.delete(sprite.node!)
@@ -1007,6 +1182,7 @@ export function createSpriteLayer(atlas: TextureId, opts?: SpriteLayerOptions):
1007
1182
  spatial.destroyNode(group.node)
1008
1183
  }
1009
1184
  layer._groups.clear()
1185
+ clamped.clear()
1010
1186
  // Corpses mid-exit go now: their slots must not outlive the buffer.
1011
1187
  for (let node of leaving) {
1012
1188
  freeing.delete(node)
@@ -1024,6 +1200,22 @@ export function createSpriteLayer(atlas: TextureId, opts?: SpriteLayerOptions):
1024
1200
  },
1025
1201
  _add(opts) {
1026
1202
  if (disposed) throw new Error("addSprite: layer is disposed")
1203
+ // The style bag with its defaults - renderOrder and the clamp at 0
1204
+ // explicitly, since a recycled slot holds the previous occupant's
1205
+ // values otherwise - resolved before a slot or node is taken, so a
1206
+ // bad frame allocates nothing. An absent key and an explicit
1207
+ // undefined both take the default (the options convention
1208
+ // everywhere; a plain spread would let undefined win).
1209
+ let style: SpriteOptions = {
1210
+ ...opts,
1211
+ frame: opts?.frame ?? fullFrame(atlases[0]!),
1212
+ tint: opts?.tint ?? [1, 1, 1, 1],
1213
+ renderOrder: opts?.renderOrder ?? 0,
1214
+ minScreenPx: opts?.minScreenPx ?? 0,
1215
+ maxScreenPx: opts?.maxScreenPx ?? 0,
1216
+ }
1217
+ let atlas = frameAtlas("addSprite", style)
1218
+ if (opts?.parent && opts.parent.layer !== layer) throw new Error("addSprite: parent group belongs to another layer")
1027
1219
  let slot = freeSlots.pop() ?? highWater++
1028
1220
  if (slot >= gpuCapacity) grow(gpuCapacity * 2)
1029
1221
  let sprite: SpriteState = {
@@ -1044,7 +1236,6 @@ export function createSpriteLayer(atlas: TextureId, opts?: SpriteLayerOptions):
1044
1236
  let node = spatial.createNode(TRANSFORM, sprite._visible)
1045
1237
  sprite.node = node
1046
1238
  if (opts?.parent) {
1047
- if (opts.parent.layer !== layer) throw new Error("addSprite: parent group belongs to another layer")
1048
1239
  spatial.setParent(node, opts.parent.node)
1049
1240
  opts.parent._children.add(sprite)
1050
1241
  } else {
@@ -1053,13 +1244,25 @@ export function createSpriteLayer(atlas: TextureId, opts?: SpriteLayerOptions):
1053
1244
  spatial.setBounds(node, COLUMN_BOUNDS)
1054
1245
  spatial.bindPoseRecord(node, pose, slot)
1055
1246
  byNode.set(node, sprite)
1056
- // renderOrder defaults to 0 explicitly: a recycled slot holds the
1057
- // previous occupant's key otherwise.
1058
- writeStyle(sprite, { frame: FULL_FRAME, tint: [1, 1, 1, 1], renderOrder: 0, ...opts })
1247
+ writeStyle(sprite, style, atlas)
1248
+ // No outline until a text run sets one: a recycled slot would
1249
+ // otherwise leak the previous occupant's.
1250
+ styleData.fill(0, slot * STYLE_FLOATS + STYLE_OUTLINE_FIELD, slot * STYLE_FLOATS + STYLE_OUTLINE_FIELD + 4)
1059
1251
  layer._schedule()
1060
1252
  return sprite
1061
1253
  },
1254
+ _outline(sprite, r, g, b, width) {
1255
+ let at = sprite._slot * STYLE_FLOATS + STYLE_OUTLINE_FIELD
1256
+ styleData[at] = r
1257
+ styleData[at + 1] = g
1258
+ styleData[at + 2] = b
1259
+ styleData[at + 3] = width
1260
+ styleDirty = true
1261
+ layer._schedule()
1262
+ },
1062
1263
  _write(sprite, opts) {
1264
+ checkClamp("setSprite", sprite, opts)
1265
+ let atlas = frameAtlas("setSprite", opts)
1063
1266
  let moved = false
1064
1267
  if (opts.x !== undefined && opts.x !== sprite._x) (sprite._x = opts.x), (moved = true)
1065
1268
  if (opts.y !== undefined && opts.y !== sprite._y) (sprite._y = opts.y), (moved = true)
@@ -1077,9 +1280,12 @@ export function createSpriteLayer(atlas: TextureId, opts?: SpriteLayerOptions):
1077
1280
  opts.tint !== undefined ||
1078
1281
  opts.flipX !== undefined ||
1079
1282
  opts.flipY !== undefined ||
1080
- opts.renderOrder !== undefined
1283
+ opts.renderOrder !== undefined ||
1284
+ opts.minScreenPx !== undefined ||
1285
+ opts.maxScreenPx !== undefined ||
1286
+ (moved && clamped.has(sprite))
1081
1287
  ) {
1082
- writeStyle(sprite, opts)
1288
+ writeStyle(sprite, opts, atlas)
1083
1289
  }
1084
1290
  if (moved || styleDirty) layer._schedule()
1085
1291
  },
@@ -1090,17 +1296,20 @@ export function createSpriteLayer(atlas: TextureId, opts?: SpriteLayerOptions):
1090
1296
  y: sprite._y,
1091
1297
  w: sprite._w,
1092
1298
  h: sprite._h,
1093
- frame: readFrame(styleData, at, sprite),
1299
+ frame: readFrame(styleData, at, sprite, atlases[styleData[at + STYLE_ATLAS_FIELD]!]!.texture),
1094
1300
  flipX: sprite._flipX,
1095
1301
  flipY: sprite._flipY,
1096
1302
  rotation: sprite._rot,
1097
1303
  tint: [styleData[at + 4]!, styleData[at + 5]!, styleData[at + 6]!, styleData[at + 7]!],
1304
+ minScreenPx: styleData[at + STYLE_MIN_PX_FIELD]!,
1305
+ maxScreenPx: styleData[at + STYLE_MAX_PX_FIELD]!,
1098
1306
  renderOrder: styleData[at + STYLE_KEY_FIELD]!,
1099
1307
  visible: sprite._visible,
1100
1308
  }
1101
1309
  },
1102
1310
  _destroy(sprite) {
1103
1311
  sprite.layer = null
1312
+ dropClamp(sprite)
1104
1313
  if (sprite._parent) {
1105
1314
  sprite._parent._children.delete(sprite)
1106
1315
  sprite._parent = null
@@ -1158,17 +1367,30 @@ export function createSpriteLayer(atlas: TextureId, opts?: SpriteLayerOptions):
1158
1367
  * notices; a scene that needs depth order sorts by world y with the
1159
1368
  * layer's `orderBy: "y"` (core-produced, zero JS per frame). Past the
1160
1369
  * layer's reservation both instance buffers double (pose sinks move in one
1161
- * core retarget); reserve with `capacity` to avoid the copies.
1370
+ * core retarget); reserve with `capacity` to avoid the copies. An option
1371
+ * left out or passed as undefined takes its default (zero pose and size,
1372
+ * the first atlas whole, an opaque white tint).
1162
1373
  */
1163
1374
  export function addSprite(layer: SpriteLayer | RecordLayer, opts?: AddSpriteOptions): Sprite {
1375
+ if (opts) checkSpriteOptions("addSprite", opts)
1164
1376
  return layer._add(opts)
1165
1377
  }
1166
1378
 
1167
1379
  /** The one write path: absent keys keep their values (the params rule). */
1168
1380
  export function setSprite(sprite: Sprite, opts: SpriteOptions): void {
1381
+ checkSpriteOptions("setSprite", opts)
1169
1382
  sprite.layer?._write(sprite, opts)
1170
1383
  }
1171
1384
 
1385
+ /** The validated sprite fields (throws - the dev validation policy),
1386
+ * checked before any state changes: a NaN tint channel or clamp bound
1387
+ * would otherwise blank the sprite with no error. The clamp pair is also
1388
+ * checked against the stored bounds by the layer's write. */
1389
+ function checkSpriteOptions(verb: string, opts: SpriteOptions): void {
1390
+ if (opts.tint !== undefined) checkTint(verb, opts.tint)
1391
+ if (opts.minScreenPx !== undefined || opts.maxScreenPx !== undefined) checkScreenSize(verb, opts.minScreenPx ?? 0, opts.maxScreenPx ?? 0)
1392
+ }
1393
+
1172
1394
  /** Read a sprite's current fields (a fresh object; mutating it does nothing). */
1173
1395
  export function getSprite(sprite: Sprite): Required<SpriteOptions> | null {
1174
1396
  return sprite.layer ? sprite.layer._read(sprite) : null
package/src/pack.ts ADDED
@@ -0,0 +1,70 @@
1
+ // Rect packing, pure: the shelf packer (next-fit decreasing height) that
2
+ // places sizes on a sheet of a given width - what extrudeRects re-places a
3
+ // hand-packed sheet with, and what a runtime atlas packer allocates from.
4
+ // Simple on purpose: a sprite sheet is hundreds of rects, not millions,
5
+ // and shelves keep every placement's x and y a sum of the sizes placed
6
+ // before it, so sizes that are multiples of g land on multiples of g (the
7
+ // alignment extrusion relies on). No imports, so tests/pack.test.ts runs
8
+ // headless on the flux binary.
9
+
10
+ export type Size = { width: number; height: number }
11
+ export type Placement = { x: number; y: number }
12
+ /** A packing: the sheet size and one placement per input size, in INPUT
13
+ * order (the packer sorts internally and maps back). */
14
+ export type Packing = { width: number; height: number; rects: Placement[] }
15
+
16
+ /**
17
+ * Pack `sizes` onto a sheet `width` wide: tallest first, filled left to
18
+ * right in shelves as tall as their first (tallest) rect, a new shelf when
19
+ * the current one is full. Returns the placements in input order and the
20
+ * height the shelves reach. Throws on a non-positive-integer size or one
21
+ * wider than the sheet.
22
+ */
23
+ export function packRects(sizes: readonly Size[], width: number): Packing {
24
+ if (!(Number.isInteger(width) && width > 0)) throw new Error(`packRects: width must be a positive integer, got ${width}`)
25
+ sizes.forEach((s, i) => {
26
+ if (!(Number.isInteger(s.width) && Number.isInteger(s.height) && s.width > 0 && s.height > 0)) {
27
+ throw new Error(`packRects: sizes[${i}] must be positive integers, got ${s.width} x ${s.height}`)
28
+ }
29
+ if (s.width > width) throw new Error(`packRects: sizes[${i}] is ${s.width} wide, past the ${width} sheet`)
30
+ })
31
+ // Tallest first, widest among equals, then input order: a total order,
32
+ // so the packing is the same whatever the input's order of ties.
33
+ let order = sizes.map((_, i) => i).sort((a, b) => sizes[b]!.height - sizes[a]!.height || sizes[b]!.width - sizes[a]!.width || a - b)
34
+ let rects: Placement[] = new Array(sizes.length)
35
+ let x = 0
36
+ let y = 0
37
+ let shelf = 0
38
+ for (let i of order) {
39
+ let s = sizes[i]!
40
+ if (x + s.width > width) {
41
+ y += shelf
42
+ x = 0
43
+ shelf = 0
44
+ }
45
+ rects[i] = { x, y }
46
+ x += s.width
47
+ if (s.height > shelf) shelf = s.height
48
+ }
49
+ return { width, height: y + shelf, rects }
50
+ }
51
+
52
+ /**
53
+ * A sheet width for `sizes`: the smallest power of two that fits the
54
+ * widest size and at least the square root of the total area, so the
55
+ * packing comes out roughly square - the width TexturePacker-class tools
56
+ * default to. A power of two is a multiple of every smaller power of two,
57
+ * which keeps an extruded sheet's edges on mip texel edges.
58
+ */
59
+ export function packWidth(sizes: readonly Size[]): number {
60
+ let widest = 0
61
+ let area = 0
62
+ for (let s of sizes) {
63
+ widest = Math.max(widest, s.width)
64
+ area += s.width * s.height
65
+ }
66
+ let side = Math.sqrt(area)
67
+ let width = 1
68
+ while (width < widest || width < side) width *= 2
69
+ return width
70
+ }
package/src/pick.ts CHANGED
@@ -1,6 +1,10 @@
1
1
  // Picking narrowphase, pure: no GPU or GUI imports, so its test
2
2
  // (tests/pick.test.ts) exercises this module headless on the flux binary.
3
- // layer.ts walks its draw order calling this per sprite (topmost first).
3
+ // records.ts walks its draw order calling pointInSprite per record
4
+ // (topmost first); layer.ts runs it over the core's candidates when a
5
+ // screen-size clamp is in play, sizing the rect with core's
6
+ // screenSizeScale (the JS twin of the vertex stages' clamp); floorReach
7
+ // bounds that candidate search.
4
8
 
5
9
  /** Exact containment test against a rotated rect (center, size, rotation). */
6
10
  export function pointInSprite(
@@ -20,3 +24,23 @@ export function pointInSprite(
20
24
  let ly = dx * s + dy * c
21
25
  return Math.abs(lx) <= w / 2 && Math.abs(ly) <= h / 2
22
26
  }
27
+
28
+ /**
29
+ * How far, in view pixels, a floored `w x h` sprite can reach from its
30
+ * center once the floor is in force: half its drawn diagonal at the floor,
31
+ * which depends on the aspect only (the smaller axis is minScreenPx, the
32
+ * larger minScreenPx times the aspect). The candidate radius a pick
33
+ * searches around the pointer, divided by the zoom; 0 when the floor is
34
+ * off or the sprite is collapsed. A ceiling only shrinks the rect, so it
35
+ * never widens the reach. The reach is highest at a size transition's
36
+ * ends (the aspect of two linearly moving axes is monotone), so the
37
+ * targets bound the flight.
38
+ */
39
+ export function floorReach(w: number, h: number, minScreenPx: number): number {
40
+ let aw = Math.abs(w)
41
+ let ah = Math.abs(h)
42
+ let smallest = Math.min(aw, ah)
43
+ if (!(minScreenPx > 0 && smallest > 0)) return 0
44
+ let aspect = Math.max(aw, ah) / smallest
45
+ return (minScreenPx * Math.hypot(1, aspect)) / 2
46
+ }