@solidrt/3d 0.0.56 → 0.0.58

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.
Files changed (48) hide show
  1. package/AGENTS.md +1052 -639
  2. package/README.md +2 -1
  3. package/examples/README.md +19 -8
  4. package/examples/collision.tsx +17 -4
  5. package/examples/first-person.tsx +20 -10
  6. package/examples/fleet.tsx +11 -5
  7. package/examples/model.tsx +3 -3
  8. package/examples/pick.tsx +144 -34
  9. package/examples/probe.tsx +10 -4
  10. package/examples/scene-views.tsx +49 -58
  11. package/examples/sky-lit.tsx +10 -4
  12. package/examples/skybox.tsx +10 -4
  13. package/examples/standard.tsx +10 -4
  14. package/examples/wireframe.tsx +107 -0
  15. package/package.json +2 -2
  16. package/src/collision.ts +14 -193
  17. package/src/components/context.tsx +35 -0
  18. package/src/components/directional-light.tsx +48 -0
  19. package/src/components/first-person-camera.tsx +72 -0
  20. package/src/components/group.tsx +18 -0
  21. package/src/components/hemisphere-light.tsx +24 -0
  22. package/src/components/index.ts +46 -0
  23. package/src/components/instanced-mesh.tsx +105 -0
  24. package/src/components/mesh.tsx +92 -0
  25. package/src/components/node-props.ts +90 -0
  26. package/src/components/orbit-camera.tsx +75 -0
  27. package/src/components/perspective-camera.tsx +32 -0
  28. package/src/components/point-light.tsx +52 -0
  29. package/src/components/record-mesh.tsx +68 -0
  30. package/src/components/scene.tsx +281 -0
  31. package/src/components/spot-light.tsx +65 -0
  32. package/src/components/sprite.tsx +38 -0
  33. package/src/components/view3d.tsx +152 -0
  34. package/src/first-person.ts +140 -188
  35. package/src/geometry-gpu.ts +45 -13
  36. package/src/geometry.ts +186 -5
  37. package/src/gltf.ts +97 -16
  38. package/src/index.ts +15 -10
  39. package/src/input.ts +79 -0
  40. package/src/material.ts +26 -8
  41. package/src/mesh.ts +5 -6
  42. package/src/model.ts +65 -33
  43. package/src/node.ts +100 -26
  44. package/src/orbit.ts +266 -235
  45. package/src/scene-pointer.ts +229 -101
  46. package/src/scene.ts +256 -79
  47. package/src/skeleton.ts +211 -0
  48. package/src/components.tsx +0 -1101
package/src/scene.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  // The retained scene: plain objects, no signals - the hot path (a moved
2
2
  // node) is flat imperative code, and reactivity stays at the component
3
- // boundary (components.tsx). The transform hierarchy itself lives in the
3
+ // boundary (components/). The transform hierarchy itself lives in the
4
4
  // spatial core (flux:spatial): every node in a scene has a core node, JS
5
5
  // keeps the LOCAL transform as the readable source of truth and forwards
6
6
  // each write, and the core's flush recomputes only the moved subtrees and
@@ -38,10 +38,10 @@
38
38
 
39
39
  import { addDraw, createCubeDrawTarget, createDrawTarget, depthTexture, destroyProgram, destroyRenderPipeline, destroyTexture, removeDraw, renderTarget, setDrawBuffers, setDrawOrder, setDrawParams, setDrawTextures, setTargetParams, setTargetRect, setTargetSize, setTargetTextures } from "@solidrt/core/gpu"
40
40
  import * as spatial from "flux:spatial"
41
- import type { NodeId } from "flux:spatial"
41
+ import type { Impact as CoreImpact, NodeId, QueryFilter } from "flux:spatial"
42
42
  import type { DrawId, FilterMode, ProgramId, RenderPipelineId, ShaderParams, TextureId, WrapMode } from "@solidrt/core/gpu"
43
43
  import { getOwner, onCleanup } from "@solidrt/core"
44
- import type { PointerEvent as ElementPointerEvent } from "@solidrt/core"
44
+ import type { PointerEvent as ElementPointerEvent, WheelEvent as ElementWheelEvent } from "@solidrt/core"
45
45
  import { copy, mat4, transformPoint } from "./math.ts"
46
46
  import { linearColor } from "./color.ts"
47
47
  import type { Mat4, Quat, Vec3, Vec4 } from "./math.ts"
@@ -50,7 +50,7 @@ import { cameraParams, cameraState, ensureCamera, makeCamera, updateCamera } fro
50
50
  import type { Camera, CameraState, CameraUpdate } from "./camera.ts"
51
51
  import { makeShadowSystem } from "./scene-shadows.ts"
52
52
  import { makePointerInput } from "./scene-pointer.ts"
53
- import { geometryBounds, layoutKey, validateGeometry } from "./geometry.ts"
53
+ import { geometryBounds, geometryTopology, layoutKey, validateGeometry } from "./geometry.ts"
54
54
  import type { Geometry } from "./geometry.ts"
55
55
  import { acquireGeometryBuffers, releaseGeometryBuffers } from "./geometry-gpu.ts"
56
56
  import { backgroundPipeline, missingAttributes, SKYBOX_FRAGMENT } from "./material.ts"
@@ -59,7 +59,7 @@ import type { Prefilter } from "./environment.ts"
59
59
  import type { Material } from "./material.ts"
60
60
  import { orderEntries } from "./order.ts"
61
61
  import { fillTransform, leaveScene, makeNode, worldInto } from "./node.ts"
62
- import type { SceneHooks, SceneNode, ScenePointerEvent } from "./node.ts"
62
+ import type { SceneHooks, SceneNode, ScenePointerListener } from "./node.ts"
63
63
  import { checkInstancePairing, checkMask, instanceBinding, localBounds, publishInstanceStyle } from "./mesh.ts"
64
64
  import type { InstancedMesh, InstanceNode, Mesh } from "./mesh.ts"
65
65
  import type { CastingLight, Light } from "./light.ts"
@@ -183,15 +183,52 @@ export type Overlap = { mesh: Mesh; instance?: InstanceNode; point: Vec3; normal
183
183
  * Godot's KinematicCollision3D). `instance` as on Overlap. */
184
184
  export type Impact = { mesh: Mesh; instance?: InstanceNode; time: number; point: Vec3; normal: Vec3 }
185
185
 
186
- /** Element handlers wiring a scene's pointer events: spread onto whatever
187
- * element shows `scene.texture` (the built-in `<Scene>` leaf wires them
188
- * automatically). `scene.handlers` expects the leaf laid out at the target
189
- * size; a split-resolution leaf (supersampling) uses scene.handlersFor. */
186
+ export type MoveOptions = QueryOptions & {
187
+ /** The direction floors face (default [0, 1, 0]). */
188
+ up?: Vec3
189
+ /** Largest angle (radians) between a contact normal and `up` that still
190
+ * counts as floor (default 45 degrees). Steeper contacts are walls, and
191
+ * the body slides down them. */
192
+ floorMaxAngle?: number
193
+ /** Most contacts one call slides along (default 6). */
194
+ maxSlides?: number
195
+ /** Gap kept from every surface, world units (default 0.01). */
196
+ skin?: number
197
+ /** How far below the body a floor is pulled to when the motion does not
198
+ * rise (default 0.1; 0 disables): what keeps a walker on a ramp going
199
+ * down, and what decides `floor` at the end of the move - a body that
200
+ * ends higher than this above its floor is airborne, whatever it
201
+ * touched on the way. A rising motion (a jump) never snaps. */
202
+ floorSnap?: number
203
+ }
204
+
205
+ export type MoveResult = {
206
+ /** The displacement the body gets: add it to the body's position. */
207
+ motion: Vec3
208
+ /** The unit normal of the floor the body ends the move on - within
209
+ * `floorSnap` below it, snapped onto - else null (airborne, or on a
210
+ * slope too steep to stand on). With `floorSnap: 0` it is a floor met
211
+ * during the move instead. */
212
+ floor: Vec3 | null
213
+ /** Whether a wall (a contact steeper than a floor and flatter than a
214
+ * ceiling) or a ceiling was met. */
215
+ wall: boolean
216
+ ceiling: boolean
217
+ /** Every contact met, in order, the floor snap's last. */
218
+ hits: Impact[]
219
+ }
220
+
221
+ /** Element handlers wiring a scene's (or a view's) pointer events: spread
222
+ * onto whatever element shows its texture (the built-in `<Scene>` and
223
+ * `<View3d>` leaves wire them automatically). `scene.handlers` expects
224
+ * the leaf laid out at the target size; a split-resolution leaf
225
+ * (supersampling) uses scene.handlersFor. */
190
226
  export type SceneHandlers = {
191
227
  onPointerDown(event: ElementPointerEvent): void
192
228
  onPointerMove(event: ElementPointerEvent): void
193
229
  onPointerUp(event: ElementPointerEvent): void
194
230
  onPointerLeave(event: ElementPointerEvent): void
231
+ onWheel(event: ElementWheelEvent): void
195
232
  }
196
233
 
197
234
  /**
@@ -447,7 +484,7 @@ export type ViewOptions = {
447
484
  }
448
485
 
449
486
  /** A second rendering of a scene from its own camera; see Scene.createView. */
450
- export type View = {
487
+ export type ViewHandle = {
451
488
  /** The view's output, an ordinary texture id. */
452
489
  texture: TextureId
453
490
  /** The view target's depth as a sampler-only texture id when created
@@ -469,6 +506,22 @@ export type View = {
469
506
  /** Replace the view's layer mask (bitmask): entries for newly admitted
470
507
  * meshes attach, masked-out ones detach. */
471
508
  setLayers(mask: number): void
509
+ /** The camera ray through one of the VIEW's pixels, hits nearest first:
510
+ * scene.pick through this view's camera and size, so a mesh under a
511
+ * minimap is picked where the minimap shows it (the scene's query
512
+ * filters apply: visibility, the view's own layer mask is not one). */
513
+ pick(x: number, y: number): Hit[]
514
+ /** Element pointer handlers for the element showing `texture`: the
515
+ * scene's dispatch with THIS VIEW as the root of the walk - nodes get
516
+ * their ordinary handlers, picked through the view's camera, the
517
+ * view's `listen` is the last stop, `x`/`y` are view pixels. The
518
+ * `<View3d>` leaf carries them; an `output` leaf spreads them itself. */
519
+ handlers: SceneHandlers
520
+ /** handlers for a leaf laid out at another size; exactly
521
+ * scene.handlersFor. */
522
+ handlersFor(layout: () => { width: number; height: number }): SceneHandlers
523
+ /** A listener at this view's root; exactly scene.listen. */
524
+ listen(listener: ScenePointerListener): () => void
472
525
  /** Destroy the view's target (its entries die with it). Idempotent;
473
526
  * views also die with their scene. */
474
527
  dispose(): void
@@ -708,6 +761,21 @@ export type Scene = {
708
761
  * a wall. A zero motion touches nothing. Same testing as overlap().
709
762
  */
710
763
  sweep(volume: Volume, motion: Vec3, opts?: QueryOptions): Impact[]
764
+ /**
765
+ * Move a body `motion` through the scene's colliders, sliding along
766
+ * what it hits (Godot's move_and_slide, Unity's CharacterController.Move):
767
+ * a capsule for a character, a sphere for a ball, a box for a crate.
768
+ * The body first pushes out of anything it starts inside, then sweeps
769
+ * and slides up to `maxSlides` times, then, unless the motion rises,
770
+ * snaps down onto a floor within `floorSnap` - the floor it reports is
771
+ * the one it ends on. One core call per body per frame; the loop's
772
+ * sweeps and overlaps never leave the core. Colliders are whatever
773
+ * `opts` selects (`layers`/`meshes`, as for sweep). Gravity is the
774
+ * caller's: fold the fall into `motion` each frame, zero it while
775
+ * `floor` is set (the snap keeps reporting the floor while the body
776
+ * stands still); a walkable floor absorbs the vertical part.
777
+ */
778
+ moveAndSlide(volume: Volume, motion: Vec3, opts?: MoveOptions): MoveResult
711
779
  /**
712
780
  * pick()'s ray half (Unity's ScreenPointToRay, Godot's
713
781
  * project_ray_origin/normal): the camera ray through a scene pixel,
@@ -719,17 +787,25 @@ export type Scene = {
719
787
  */
720
788
  screenRay(x: number, y: number): ScreenRay
721
789
  /**
722
- * Element pointer handlers driving the mesh event fields
723
- * (onPointerDown/Move/Up/Enter/Leave on nodes): spread onto the element
724
- * that shows `scene.texture`. The `<Scene>` component's built-in leaf
725
- * carries them automatically; with `output` (or imperative use), spread
726
- * them yourself: `<texture src={scene.texture} {...scene.handlers} />`.
727
- * Semantics mirror element pointer events: nearest hit wins, down/move/
728
- * up bubble mesh -> ancestors, pointer-down captures the mesh until up
729
- * (moves keep flowing to it off-mesh, the platform's captured-drag
730
- * rule), enter/leave pair on hover changes. Hover reacts to pointer
731
- * MOTION - a mesh animating under a still pointer fires nothing until
732
- * the pointer moves (the element hit-test has the same limit).
790
+ * Element pointer handlers driving the node event fields
791
+ * (onPointerDown/Move/Up/Enter/Leave/Wheel/Tap on nodes) and the
792
+ * scene's listeners: spread onto the element that shows
793
+ * `scene.texture`. The `<Scene>` component's built-in leaf carries them
794
+ * automatically; with `output` (or imperative use), spread them
795
+ * yourself: `<texture src={scene.texture} {...scene.handlers} />`.
796
+ * The element event model one tree deeper, with the scene as the root
797
+ * of the walk: the nearest hit is the target (the struck instance of an
798
+ * instanced mesh, else the mesh), down/move/up/wheel bubble through its
799
+ * ancestors and end at the scene's listeners (`listen`); over empty
800
+ * space the walk is the scene alone. stopPropagation stops the walk,
801
+ * and a stopped down claims the whole press. Capture is per pointer to
802
+ * the press target, the scene included: a drag keeps delivering to what
803
+ * it pressed, with `point`/`distance` null while the ray misses it.
804
+ * Enter/leave pair on the struck node alone, on hover changes. Taps are
805
+ * synthesized (a same-target release within the slop, alone for the
806
+ * press, `tapCount` for repeats). Hover reacts to pointer MOTION - a
807
+ * mesh animating under a still pointer fires nothing until the pointer
808
+ * moves (the element hit-test has the same limit).
733
809
  *
734
810
  * Coordinates assume the leaf is LAID OUT at the target size - true for
735
811
  * the built-in leaf and a d-texture at natural size, under any ancestor
@@ -743,6 +819,19 @@ export type Scene = {
743
819
  * layout just works: `scene.handlersFor(() => ({ width: w(), height:
744
820
  * h() }))`. */
745
821
  handlersFor(layout: () => { width: number; height: number }): SceneHandlers
822
+ /**
823
+ * Add a listener at the root of the pointer walk (the `<Scene>`
824
+ * component's onPointerDown/Move/Up, onWheel and onTap props do this):
825
+ * it sees every event the node chain lets through, hit or miss -
826
+ * `event.mesh` is the hit it bubbled from, null over empty space - with
827
+ * `x`/`y` in scene pixels. Listeners all run, in registration order
828
+ * (the root is the last stop, nothing is left to claim); a stopped DOWN
829
+ * claims the whole press for the chain, so that pointer's move, up and
830
+ * tap never arrive. Returns the remover. A pointer feed listens here
831
+ * through feedPointer, so a camera control bound to the feed respects a
832
+ * mesh's claim.
833
+ */
834
+ listen(listener: ScenePointerListener): () => void
746
835
  /**
747
836
  * A second rendering of this scene: its own draw target and camera,
748
837
  * the same meshes and lights. Each mesh gets one entry in the view's
@@ -756,7 +845,7 @@ export type Scene = {
756
845
  * or pointer events. Views die with the scene; `view.dispose()` drops
757
846
  * one early.
758
847
  */
759
- createView(opts: ViewOptions): View
848
+ createView(opts: ViewOptions): ViewHandle
760
849
  /**
761
850
  * A reflection probe: the scene rendered into a cube map from a point,
762
851
  * six faces at 90 degrees - Three's CubeCamera, Unity's and Godot's
@@ -849,23 +938,44 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
849
938
  // map turns a hit's core node back into the mesh, or the instance of an
850
939
  // instanced mesh (each instance node is its own leaf).
851
940
  let byNode = new Map<NodeId, Mesh | InstanceNode>()
852
- // The per-query filter raycast/overlap/sweep share: a hit's core node
853
- // back to its mesh (and instance), or null when the query mask excludes
854
- // the mesh (skipped like an invisible one; the mask defaults to the
855
- // scene's, so an undrawn layer needs an explicit opts.layers to report)
856
- // or the include-list leaves it out.
857
- let admitted = (qopts: QueryOptions | undefined, site: string) => {
858
- let mask = qopts?.layers !== undefined ? checkMask(qopts.layers, site) : sceneMask
859
- let include = qopts?.meshes !== undefined ? new Set(qopts.meshes) : null
860
- return (node: NodeId): { mesh: Mesh; instance: InstanceNode | null } | null => {
861
- let leaf = byNode.get(node)
862
- if (leaf === undefined) return null
863
- let instance = leaf.kind === "instance" ? leaf : null
864
- let mesh = instance !== null ? instance.mesh : (leaf as Mesh)
865
- if (mesh === null || (mesh.layers & mask) === 0) return null
866
- if (include !== null && !include.has(mesh)) return null
867
- return { mesh, instance }
941
+ // The filter raycast/overlap/sweep hand the core: the scene root (the
942
+ // arena is shared with other scenes and 2d layers), the query mask (the
943
+ // scene's by default, so an undrawn layer needs an explicit opts.layers
944
+ // to report) and, for an include-list, the meshes' nodes - an instanced
945
+ // mesh's instances each being a leaf of their own.
946
+ // One filter object per scene, refilled per query (the bindings read it
947
+ // synchronously), so a query allocates nothing but its hits.
948
+ let filter: QueryFilter = {}
949
+ let queryFilter = (qopts: QueryOptions | undefined, site: string): QueryFilter => {
950
+ filter.root = root._node!
951
+ filter.layers = qopts?.layers !== undefined ? checkMask(qopts.layers, site) : sceneMask
952
+ if (qopts?.meshes === undefined) {
953
+ filter.nodes = undefined
954
+ } else {
955
+ let nodes: NodeId[] = []
956
+ for (let mesh of qopts.meshes) {
957
+ if (mesh._node !== null) nodes.push(mesh._node)
958
+ if (mesh._instances !== null) nodes.push(...instanceGroup(mesh as InstancedMesh))
959
+ }
960
+ filter.nodes = nodes
868
961
  }
962
+ return filter
963
+ }
964
+ // A hit's core node back to its mesh (and instance).
965
+ let leafOf = (node: NodeId): { mesh: Mesh; instance: InstanceNode | null } | null => {
966
+ let leaf = byNode.get(node)
967
+ if (leaf === undefined) return null
968
+ let instance = leaf.kind === "instance" ? leaf : null
969
+ let mesh = instance !== null ? instance.mesh : (leaf as Mesh)
970
+ if (mesh === null) return null
971
+ return { mesh, instance }
972
+ }
973
+ let impactOf = (h: CoreImpact): Impact | null => {
974
+ let leaf = leafOf(h.node)
975
+ if (leaf === null) return null
976
+ let impact: Impact = { mesh: leaf.mesh, time: h.time, point: h.point, normal: h.normal }
977
+ if (leaf.instance !== null) impact.instance = leaf.instance
978
+ return impact
869
979
  }
870
980
  // Nodes whose transform changed since the last sync (deduped by the
871
981
  // _moved flag): what the light and transparent-order bookkeeping
@@ -1070,44 +1180,46 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1070
1180
  let clip: Vec4 = [0, 0, 0, 0]
1071
1181
  let pickOrigin: Vec3 = [0, 0, 0]
1072
1182
 
1073
- // The camera ray through a scene pixel, into the pickOrigin/pickDir
1183
+ // The camera ray through a pixel of `cam` over a tw x th target, into the
1184
+ // pickOrigin/pickDir
1074
1185
  // scratches. The direction keeps a camera-forward component of 1
1075
1186
  // (perspective: (cx, cy, -1) in the camera frame; ortho: unit forward),
1076
1187
  // so origin + w * direction is the world point at camera-forward
1077
1188
  // distance w - what unproject() banks on. pick()/screenRay()/
1078
1189
  // unproject() all cast exactly this ray.
1079
- let pixelRay = (x: number, y: number): void => {
1080
- ensureCamera(camera, width, height)
1081
- let v = camera.view
1082
- let o = camera.ortho
1190
+ let pixelRayOf = (cam: Camera, tw: number, th: number, x: number, y: number): void => {
1191
+ ensureCamera(cam, tw, th)
1192
+ let v = cam.view
1193
+ let o = cam.ortho
1083
1194
  if (o === null) {
1084
1195
  // The camera-frame ray through the pixel, inverting project()'s
1085
1196
  // mapping: the baked y-down clip flip is why pixel y converts with
1086
1197
  // no negation there and one here.
1087
- let f = 1 / Math.tan(((camera.fov * Math.PI) / 180) / 2)
1088
- let cx = (((x / width) * 2 - 1) * (width / height)) / f
1089
- let cy = -((y / height) * 2 - 1) / f
1198
+ let f = 1 / Math.tan(((cam.fov * Math.PI) / 180) / 2)
1199
+ let cx = (((x / tw) * 2 - 1) * (tw / th)) / f
1200
+ let cy = -((y / th) * 2 - 1) / f
1090
1201
  // The view's upper 3x3 rows are the camera axes, so its transpose
1091
1202
  // carries the camera-space direction (cx, cy, -1) to world.
1092
1203
  pickDir[0] = cx * v[0] + cy * v[1] - v[2]
1093
1204
  pickDir[1] = cx * v[4] + cy * v[5] - v[6]
1094
1205
  pickDir[2] = cx * v[8] + cy * v[9] - v[10]
1095
- pickOrigin[0] = camera.eye[0]
1096
- pickOrigin[1] = camera.eye[1]
1097
- pickOrigin[2] = camera.eye[2]
1206
+ pickOrigin[0] = cam.eye[0]
1207
+ pickOrigin[1] = cam.eye[1]
1208
+ pickOrigin[2] = cam.eye[2]
1098
1209
  return
1099
1210
  }
1100
1211
  // Orthographic: every ray runs along the camera's forward axis; the
1101
1212
  // pixel picks where on the camera plane it starts (top row = top).
1102
- let cx = o.left + (x / width) * (o.right - o.left)
1103
- let cy = o.top + (y / height) * (o.bottom - o.top)
1104
- pickOrigin[0] = camera.eye[0] + cx * v[0] + cy * v[1]
1105
- pickOrigin[1] = camera.eye[1] + cx * v[4] + cy * v[5]
1106
- pickOrigin[2] = camera.eye[2] + cx * v[8] + cy * v[9]
1213
+ let cx = o.left + (x / tw) * (o.right - o.left)
1214
+ let cy = o.top + (y / th) * (o.bottom - o.top)
1215
+ pickOrigin[0] = cam.eye[0] + cx * v[0] + cy * v[1]
1216
+ pickOrigin[1] = cam.eye[1] + cx * v[4] + cy * v[5]
1217
+ pickOrigin[2] = cam.eye[2] + cx * v[8] + cy * v[9]
1107
1218
  pickDir[0] = -v[2]
1108
1219
  pickDir[1] = -v[6]
1109
1220
  pickDir[2] = -v[10]
1110
1221
  }
1222
+ let pixelRay = (x: number, y: number): void => pixelRayOf(camera, width, height, x, y)
1111
1223
 
1112
1224
  // Views (scene.createView): more targets drawing the same meshes from
1113
1225
  // their own cameras. A view holds one entry per mesh in its target,
@@ -1177,7 +1289,7 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1177
1289
  // so added live it would draw at the seeded identity until then. The
1178
1290
  // mismatch branch in sync() turns it on in the same pass that writes
1179
1291
  // uModel.
1180
- mesh._entry = addDraw(texture, mesh.material.pipeline(mesh.geometry.layout), entrySeed(mesh.material, mesh._params), {
1292
+ mesh._entry = addDraw(texture, mesh.material.pipeline(mesh.geometry.layout, geometryTopology(mesh.geometry)), entrySeed(mesh.material, mesh._params), {
1181
1293
  buffer: bufs.buffer,
1182
1294
  indexBuffer: bufs.index,
1183
1295
  indexFormat: bufs.indexFormat,
@@ -1207,6 +1319,9 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1207
1319
  if (v.entries.has(mesh)) return
1208
1320
  let inst = mesh._instances
1209
1321
  if (v.shadowFilter !== null && !v.shadowFilter(mesh)) return
1322
+ // Lines and points cast nothing: a one-pixel wireframe in a shadow
1323
+ // map is speckle on the floor, never a shadow anyone wanted.
1324
+ if (v.shadowFilter !== null && geometryTopology(mesh.geometry) !== "triangles") return
1210
1325
  if ((mesh.layers & v.mask) === 0) return
1211
1326
  let material = viewMaterial(v, mesh)
1212
1327
  // An override pipeline cannot know a populated mesh's record layout,
@@ -1219,7 +1334,7 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1219
1334
  checkInstancePairing(material, inst, "Shadow material")
1220
1335
  }
1221
1336
  let bufs = mesh._buffers!
1222
- let entry = addDraw(v.texture, material.pipeline(mesh.geometry.layout), entrySeed(material, v.override !== null ? null : mesh._params), {
1337
+ let entry = addDraw(v.texture, material.pipeline(mesh.geometry.layout, geometryTopology(mesh.geometry)), entrySeed(material, v.override !== null ? null : mesh._params), {
1223
1338
  buffer: bufs.buffer,
1224
1339
  indexBuffer: bufs.index,
1225
1340
  indexFormat: bufs.indexFormat,
@@ -1487,6 +1602,10 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1487
1602
  },
1488
1603
  _setLayers(mesh) {
1489
1604
  if (mesh._buffers === null || disposed) return
1605
+ if (mesh._node !== null) spatial.setLayers(mesh._node, mesh.layers)
1606
+ if (mesh._instances !== null) {
1607
+ for (let n of instanceGroup(mesh as InstancedMesh)) spatial.setLayers(n, mesh.layers)
1608
+ }
1490
1609
  if ((mesh.layers & sceneMask) !== 0) attachScene(mesh)
1491
1610
  else detachScene(mesh)
1492
1611
  for (let v of views) {
@@ -1598,6 +1717,7 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1598
1717
  if (mesh === null || mesh._buffers === null || mesh._node === null || instance._node === null) return
1599
1718
  spatial.setBounds(instance._node, geometryBounds(mesh.geometry))
1600
1719
  spatial.setShape(instance._node, mesh._buffers.shape)
1720
+ spatial.setLayers(instance._node, mesh.layers)
1601
1721
  // The record is the instance's placement inside the mesh (the mesh
1602
1722
  // node is the anchor), staged by the core at the next flush.
1603
1723
  spatial.bindMatrixRecord(instance._node, mesh._instances.buffer, instance._slot, mesh._node)
@@ -1674,13 +1794,9 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1674
1794
  lightsDirty = true
1675
1795
  hooks._schedule()
1676
1796
 
1677
- // Pointer dispatch (scene.handlers): capture/hover bookkeeping and the
1678
- // bubble walk live in scene-pointer.ts; all it needs of the scene is
1679
- // pick() and the target size.
1680
- let pointer = makePointerInput({
1681
- pick: (x, y) => scene.pick(x, y),
1682
- targetSize: () => ({ width, height }),
1683
- })
1797
+ // The scene's root listeners (scene.listen): the last stop of the
1798
+ // pointer walk scene-pointer.ts runs behind scene.handlers.
1799
+ let listeners = new Set<ScenePointerListener>()
1684
1800
 
1685
1801
  let scene: Scene = {
1686
1802
  texture,
@@ -1830,7 +1946,7 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1830
1946
  // microtask still runs and finds nothing dirty - harmless.)
1831
1947
  if (scheduled) sync()
1832
1948
  if (disposed) return []
1833
- let admit = admitted(rayOpts, "raycast")
1949
+ let f = queryFilter(rayOpts, "raycast")
1834
1950
  let hits: Hit[] = []
1835
1951
  rayOriginScratch[0] = origin[0]
1836
1952
  rayOriginScratch[1] = origin[1]
@@ -1838,8 +1954,8 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1838
1954
  rayDirScratch[0] = direction[0]
1839
1955
  rayDirScratch[1] = direction[1]
1840
1956
  rayDirScratch[2] = direction[2]
1841
- for (let h of spatial.raycast(rayOriginScratch, rayDirScratch)) {
1842
- let leaf = admit(h.node)
1957
+ for (let h of spatial.raycast(rayOriginScratch, rayDirScratch, f)) {
1958
+ let leaf = leafOf(h.node)
1843
1959
  if (leaf === null) continue
1844
1960
  let hit: Hit = { mesh: leaf.mesh, distance: h.distance, point: h.point, normal: h.normal }
1845
1961
  if (leaf.instance !== null) hit.instance = leaf.instance
@@ -1852,11 +1968,11 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1852
1968
  overlap(volume, qopts) {
1853
1969
  if (scheduled) sync()
1854
1970
  if (disposed) return []
1855
- let admit = admitted(qopts, "overlap")
1971
+ let f = queryFilter(qopts, "overlap")
1856
1972
  let kind = packVolume(volume, "overlap")
1857
1973
  let out: Overlap[] = []
1858
- for (let h of spatial.overlap(kind, kind === "box" ? boxScratch : capsuleScratch)) {
1859
- let leaf = admit(h.node)
1974
+ for (let h of spatial.overlap(kind, kind === "box" ? boxScratch : capsuleScratch, f)) {
1975
+ let leaf = leafOf(h.node)
1860
1976
  if (leaf === null) continue
1861
1977
  let contact: Overlap = { mesh: leaf.mesh, point: h.point, normal: h.normal, depth: h.depth }
1862
1978
  if (leaf.instance !== null) contact.instance = leaf.instance
@@ -1867,27 +1983,53 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1867
1983
  sweep(volume, motion, qopts) {
1868
1984
  if (scheduled) sync()
1869
1985
  if (disposed) return []
1870
- let admit = admitted(qopts, "sweep")
1986
+ let f = queryFilter(qopts, "sweep")
1871
1987
  let kind = packVolume(volume, "sweep")
1872
1988
  motionScratch[0] = motion[0]
1873
1989
  motionScratch[1] = motion[1]
1874
1990
  motionScratch[2] = motion[2]
1875
1991
  let out: Impact[] = []
1876
- for (let h of spatial.sweep(kind, kind === "box" ? boxScratch : capsuleScratch, motionScratch)) {
1877
- let leaf = admit(h.node)
1878
- if (leaf === null) continue
1879
- let impact: Impact = { mesh: leaf.mesh, time: h.time, point: h.point, normal: h.normal }
1880
- if (leaf.instance !== null) impact.instance = leaf.instance
1881
- out.push(impact)
1992
+ for (let h of spatial.sweep(kind, kind === "box" ? boxScratch : capsuleScratch, motionScratch, f)) {
1993
+ let impact = impactOf(h)
1994
+ if (impact !== null) out.push(impact)
1882
1995
  }
1883
1996
  return out
1884
1997
  },
1885
- handlers: pointer.handlers,
1886
- handlersFor: pointer.handlersFor,
1998
+ moveAndSlide(volume, motion, mopts) {
1999
+ if (scheduled) sync()
2000
+ let kind = packVolume(volume, "moveAndSlide")
2001
+ motionScratch[0] = motion[0]
2002
+ motionScratch[1] = motion[1]
2003
+ motionScratch[2] = motion[2]
2004
+ if (disposed) return { motion: [motion[0], motion[1], motion[2]], floor: null, wall: false, ceiling: false, hits: [] }
2005
+ let f = queryFilter(mopts, "moveAndSlide")
2006
+ let r = spatial.moveAndSlide(kind, kind === "box" ? boxScratch : capsuleScratch, motionScratch, mopts, f)
2007
+ let hits: Impact[] = []
2008
+ for (let h of r.hits) {
2009
+ let impact = impactOf(h)
2010
+ if (impact !== null) hits.push(impact)
2011
+ }
2012
+ return { motion: r.motion, floor: r.floor, wall: r.wall, ceiling: r.ceiling, hits }
2013
+ },
2014
+ get handlers() {
2015
+ return pointer.handlers
2016
+ },
2017
+ handlersFor(layout) {
2018
+ return pointer.handlersFor(layout)
2019
+ },
2020
+ listen(listener) {
2021
+ listeners.add(listener)
2022
+ return () => {
2023
+ listeners.delete(listener)
2024
+ }
2025
+ },
1887
2026
  createView(vopts) {
1888
2027
  if (disposed) throw new Error("createView: the scene is disposed")
1889
2028
  let v = makeView(vopts, null)
1890
- return {
2029
+ // The view's own walk: its listeners, its camera's pick, and a
2030
+ // dispatch with its own capture and hover bookkeeping.
2031
+ let viewListeners = new Set<ScenePointerListener>()
2032
+ let handle: ViewHandle = {
1891
2033
  texture: v.texture,
1892
2034
  depthTexture: vopts.depth === "texture" && vopts.into === undefined ? depthTexture(v.texture) : null,
1893
2035
  setCamera(update) {
@@ -1927,10 +2069,35 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1927
2069
  }
1928
2070
  hooks._schedule()
1929
2071
  },
2072
+ pick(x, y) {
2073
+ if (v.disposed) return []
2074
+ pixelRayOf(v.camera, v.width, v.height, x, y)
2075
+ return scene.raycast(pickOrigin, pickDir)
2076
+ },
2077
+ get handlers() {
2078
+ return viewPointer.handlers
2079
+ },
2080
+ handlersFor(layout) {
2081
+ return viewPointer.handlersFor(layout)
2082
+ },
2083
+ listen(listener) {
2084
+ viewListeners.add(listener)
2085
+ return () => {
2086
+ viewListeners.delete(listener)
2087
+ }
2088
+ },
1930
2089
  dispose() {
2090
+ viewListeners.clear()
1931
2091
  disposeView(v)
1932
2092
  },
1933
2093
  }
2094
+ let viewPointer = makePointerInput({
2095
+ pick: (x, y) => handle.pick(x, y),
2096
+ targetSize: () => ({ width: v.width, height: v.height }),
2097
+ root: handle,
2098
+ listeners: viewListeners,
2099
+ })
2100
+ return handle
1934
2101
  },
1935
2102
  createReflectionProbe(popts) {
1936
2103
  if (disposed) throw new Error("createReflectionProbe: the scene is disposed")
@@ -1950,6 +2117,7 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1950
2117
  dispose() {
1951
2118
  if (disposed) return
1952
2119
  disposed = true
2120
+ listeners.clear()
1953
2121
  // Full tree-side teardown, not just the target: every node leaves
1954
2122
  // the scene (entries' geometry-buffer references and pick leaves
1955
2123
  // dropped, core nodes freed), so a disposed scene leaves no
@@ -1977,6 +2145,15 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1977
2145
  }
1978
2146
  },
1979
2147
  }
2148
+ // Pointer dispatch (scene.handlers): capture/hover bookkeeping, the
2149
+ // walk and the tap tracker live in scene-pointer.ts; all it needs of
2150
+ // the scene is pick(), the target size, and the root it ends at.
2151
+ let pointer = makePointerInput({
2152
+ pick: (x, y) => scene.pick(x, y),
2153
+ targetSize: () => ({ width, height }),
2154
+ root: scene,
2155
+ listeners,
2156
+ })
1980
2157
  // A reflection probe over layer mask `mask` (see createReflectionProbe):
1981
2158
  // the public object plus `finish`, which keeps the prefiltered chain and
1982
2159
  // drops everything else - the bake's one-shot use.