@solidrt/3d 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/AGENTS.md CHANGED
@@ -91,7 +91,9 @@ Two layers. The imperative core is Solid-free: `createScene`,
91
91
  over the spatial core (`flux:spatial`, `alloy/src/spatial/`): every node
92
92
  in a scene has a core node, JS keeps the LOCAL position/quaternion/scale
93
93
  as the readable truth and forwards each write, and the core's flush
94
- (one call per microtask) recomputes only the moved subtrees and writes
94
+ (one call per frame, in the publish pass of core's `onBeforeRender`
95
+ ahead of the paint; at the microtask for a write made outside a frame)
96
+ recomputes only the moved subtrees and writes
95
97
  each entry's uModel (plus uNormal for materials declaring it) and its
96
98
  visibility switch itself - a move costs its subtree, never the scene.
97
99
  ONE `setTargetParams` (the shared uViewProj + uCamPos) per camera
@@ -215,7 +217,10 @@ mesh: `frustumCulled: false` (Three's name; `setCulling`) for geometry
215
217
  a vertex stage moves beyond its box - a fullscreen quad, a custom
216
218
  displacement - and `cullMargin` (world units, Godot's
217
219
  `extra_cull_margin`) for bounded displacement such as wind. Sprites
218
- cull by their quad's reach at any facing. A SKINNED part is culled by
220
+ cull by their quad's reach at any facing; a sprite whose material (or
221
+ `params`) floors its screen size exceeds that reach far away, so the
222
+ floor coming on switches the mesh's culling off, one way. A SKINNED
223
+ part is culled by
219
224
  the union of its joints' boxes (the bake computes each joint's
220
225
  influence box in joint space, `ModelSkin.jointBounds`, .sol3m VERSION
221
226
  5; the joint nodes carry them as culling-only bounds, outside the
@@ -399,7 +404,8 @@ unmount lets them go in; nearest declaring ancestor wins) and cascades
399
404
  nothing unless they declare `from`/`exit` -
400
405
  a squad of instances spawning in is one `stagger` on their group, and
401
406
  for the nodes straight under the scene root it is `<Scene stagger>` /
402
- `createScene({ stagger })` (a declaration on `scene.root`). A populated
407
+ `createScene({ stagger })` / `scene.setStagger(ms)` (a declaration on
408
+ `scene.root`). A populated
403
409
  mesh unmounts as `destroy(mesh)` then `disposeInstances(mesh)`, and the
404
410
  dispose waits for a mesh still animating out, so an `<Instance>`'s exit
405
411
  plays through its `<InstancedMesh>`'s unmount (`model.dispose` waits the
@@ -987,7 +993,15 @@ box's twelve triangles, so its hits carry the struck face's `normal` and
987
993
  no `face`/`uv` - and a ray from inside it meets the far side, the
988
994
  surface contract overlap/sweep share. Both methods
989
995
  flush pending writes first (the lookAt/project immediacy contract), and
990
- both skip invisible meshes.
996
+ both skip invisible meshes. A sprite under a screen-size clamp
997
+ (`sprite({ minScreenPx, maxScreenPx })`) draws at a size the index does
998
+ not know - the vertex stage scales it by the pixels per unit at its
999
+ depth under the camera drawing it - so `pick` (which has a camera; a
1000
+ view's pick its own) replaces the ray's hits on clamped sprites with an
1001
+ exact ray-quad test at the drawn size, the quad rebuilt as the stage
1002
+ builds it (camera right/up, or a fixed-y sprite's yaw basis), hits
1003
+ carrying the plane distance and a normal facing the ray. `raycast` is a
1004
+ world query and keeps the index's unit-box answer.
991
1005
 
992
1006
  ### Collision
993
1007
 
@@ -1312,7 +1326,7 @@ opts out of the scene's fog (all four library materials take it).
1312
1326
  #### sprite
1313
1327
 
1314
1328
  `sprite({ color?, map?, transparent?, blend?, billboard?, shape?,
1315
- falloff? })` - unlit on a quad
1329
+ falloff?, minScreenPx?, maxScreenPx? })` - unlit on a quad
1316
1330
  that turns to face the camera IN THE VERTEX STAGE (off the shared
1317
1331
  uCamRight/uCamUp, or uCamPos for `billboard: "fixed-y"`, which yaws
1318
1332
  only and stays upright on world y - Godot's BILLBOARD_FIXED_Y, the
@@ -1326,7 +1340,18 @@ facing), so hits carry no normal/face/uv. `shape: "radial"` is a
1326
1340
  procedural falloff over the quad's inscribed disc, `(1 - 2|uv -
1327
1341
  0.5|)^falloff` (default 1; 2 a soft puff) multiplying color and alpha,
1328
1342
  so a glow, flare or puff needs no texture; it composes with a `map`
1329
- and with `blend: "add"`. `examples/sprites.tsx`.
1343
+ and with `blend: "add"`. `minScreenPx`/`maxScreenPx` clamp the quad's
1344
+ smaller axis on SCREEN (pixels; 0 = off), scaled uniformly about its
1345
+ center in the vertex stage by the pixels one world unit covers at the
1346
+ quad's depth (depth alone, from the shared uniforms, so perspective
1347
+ and orthographic alike): a floor is the marker that stays findable at
1348
+ distance, a ceiling the label that must not balloon, equal bounds a
1349
+ constant screen size (Three's `sizeAttenuation: false`, `scale` then
1350
+ the aspect alone). The clamp is core's `screenSizeScale`, shared with
1351
+ @solidrt/2d's sprites; the per-entry `uScreenPx`, so a per-mesh
1352
+ `params` can override it. `scene.pick`/`view.pick` test the drawn
1353
+ quad (below), and a floored sprite's mesh turns its frustum culling
1354
+ off (one way; `setCulling` turns it back on). `examples/sprites.tsx`.
1330
1355
 
1331
1356
  #### shaderMaterial
1332
1357
 
@@ -1398,10 +1423,14 @@ vertex stream stepped per instance: on the mesh it is an `InstanceStream`
1398
1423
  (the layout, a byte mirror, a mesh-owned GPU buffer), written through
1399
1424
  `instanceAttribute(mesh, name)` - the accessor `geometryAttribute`
1400
1425
  returns, record index in, values as the shader sees them, the codec
1401
- packing the bytes - and published with `updateRecords(mesh, { stream?,
1402
- first?, count? })`, which is `updateVertices` for records: one coalesced
1403
- buffer write per dirty stream at the scene's sync, so ten moved records
1404
- of ten thousand cost ten. Its meshes come from `createInstancedMesh`
1426
+ packing the bytes - or in bulk through the mirror itself, `records(mesh,
1427
+ stream?)` (a Float32Array over an all-float layout, bytes otherwise;
1428
+ read it at use time, growth replaces it), and published with
1429
+ `updateRecords(mesh, { stream?, first?, count? })`, which is
1430
+ `updateVertices` for records: one coalesced buffer write per dirty
1431
+ stream at the scene's sync, so ten moved records of ten thousand cost
1432
+ ten - the same `records`/`updateRecords` pair as @solidrt/2d's records
1433
+ layer. Its meshes come from `createInstancedMesh`
1405
1434
  (the first buffer is the core-written matrix, the rest are streams; the
1406
1435
  second is the STYLE record, below) or `createRecordMesh` (every buffer a
1407
1436
  stream); a `createMesh` mesh is rejected at add(). `instanceStyle: [..]`
@@ -1519,11 +1548,13 @@ through `instanceAttribute`. `count` picks how many draw (default all).
1519
1548
  `setRecords(mesh, records, count?)` rewrites from the start (count
1520
1549
  defaults to the records written; more than capacity GROWS: capacity
1521
1550
  doubles into replacement buffers, the entry is re-pointed via
1522
- `setDrawBuffers`, the old ones are freed), `instanceAttribute` +
1523
- `updateRecords(mesh, { first, count })` rewrites a few (the range is
1524
- against capacity, so write ahead and dial after), and
1525
- `setRecordCount(mesh, n)` is the population dial (clamped to capacity;
1526
- frame-rate-safe). Records are opaque data (position/yaw/tint/whatever
1551
+ `setDrawBuffers`, the old ones are freed), `records(mesh)` (the mirror)
1552
+ or `instanceAttribute` + `updateRecords(mesh, { first, count })`
1553
+ rewrites a few (the range is against capacity, so write ahead and dial
1554
+ after), and `setRecordCount(mesh, n)` is the population dial (clamped to
1555
+ capacity; frame-rate-safe). The @solidrt/2d records layer speaks the
1556
+ same three verbs over its sprites, `setRecords` alone staying the
1557
+ mesh's (a layer's population is addSprite's). Records are opaque data (position/yaw/tint/whatever
1527
1558
  your shader reads), so the
1528
1559
  library cannot know where they place instances: a record mesh has NO
1529
1560
  picking leaf unless you pass `bounds` (local, covering the population) -
@@ -3014,8 +3045,9 @@ successors) as ordinary content, the model-loading split repeated:
3014
3045
  meshes skip uModel writes; the fresh matrix is
3015
3046
  written on unhide. A freshly attached entry starts off the same way and
3016
3047
  the core's flush turns it on when it writes uModel - never add one
3017
- live: it has no world matrix yet, and drawn before the sync microtask it
3018
- flashes at the world origin for a frame.
3048
+ live: it has no world matrix yet, and drawn before the sync (which runs
3049
+ ahead of the paint for a write made in a frame, at the microtask
3050
+ otherwise) it would flash at the world origin for a frame.
3019
3051
  - Instancing pairs strictly at add(), like layout: an instanced material
3020
3052
  needs a createInstancedMesh or createRecordMesh mesh and vice versa,
3021
3053
  and every instance buffer the material declares must be a layout the
@@ -3039,8 +3071,14 @@ successors) as ordinary content, the model-loading split repeated:
3039
3071
  `data` and the scene's sync publishes the `dirty` range (`updateRecords`
3040
3072
  marks it for accessor writes) - so a write on a mesh outside any scene
3041
3073
  shows once it is added (the dirty range waits), and reading the GPU
3042
- buffer back mid-frame can lag the mirror by one sync. Growth replaces
3043
- `data`: hold the accessor, not the view.
3074
+ buffer back between the write and the sync lags the mirror. The sync
3075
+ runs in the publish pass of core's `onBeforeRender`, ahead of the
3076
+ frame's paint (at the microtask for a write made outside a frame), so
3077
+ a `setRecords` made in onFrame is in that frame's picture with its
3078
+ count, its buffers and its bytes together - the count alone
3079
+ (`setRecordCount`) reaches the engine at once. Growth replaces
3080
+ `data`: hold the accessor, or re-read `records(mesh)` at use time,
3081
+ never a hoisted view.
3044
3082
  - The sugar is for FEW records, the mirror is for MANY. `setInstanceStyle`
3045
3083
  over an all-float layout is indexed stores; over a packed layout (the
3046
3084
  stock `float16x4` color included) it is a codec call per component,
@@ -3050,10 +3088,11 @@ successors) as ordinary content, the model-loading split repeated:
3050
3088
  color, 9 ms through the accessor, 5 ms through the mirror - and that
3051
3089
  5 ms is the app's own per-instance tint math, the write itself is
3052
3090
  free. A population restyled per frame writes the mirror in bulk:
3053
- a typed view over `stream.data` (a Float32Array as handed out, a
3054
- `Float16Array` over the stock color, indexed by slot times components)
3055
- filled in a plain loop, then ONE `updateRecords(mesh, { first, count
3056
- })` - zero per-component calls, the same shape as `setRecords`.
3091
+ `records(mesh)` (a Float32Array over an all-float layout, bytes
3092
+ otherwise - a `Float16Array` over the stock color, indexed by slot
3093
+ times components) filled in a plain loop, then ONE `updateRecords(mesh,
3094
+ { first, count })` - zero per-component calls, the same shape as
3095
+ `setRecords`.
3057
3096
  - Instanced casters: `castShadow` on a populated mesh needs a depth pass
3058
3097
  with the instance placement in it - the stock materials' `instanced`
3059
3098
  carries one, a custom class needs `shadowVertex` (see shadows below);
package/README.md CHANGED
@@ -60,9 +60,12 @@ drag, and hover enter/leave pairs - hit testing runs over a BVH the
60
60
  scene maintains incrementally, so events put no ceiling on scene size.
61
61
  Underneath sit `scene.pick(x, y)` (the camera ray through a pixel,
62
62
  `project()`'s inverse) and `scene.raycast(origin, direction)`; hits are
63
- bounding-box accurate in v1. A scene also takes a `background` - fragment
64
- GLSL drawn inside its own pass behind the meshes, replacing the stacked
65
- backdrop-texture pattern.
63
+ per triangle, carrying the face, its uv and a normal facing the ray.
64
+ `scene.overlap`, `scene.sweep` and `scene.moveAndSlide` ask the same
65
+ index what a volume touches, where a moving one first touches, and how a
66
+ character body slides through it. A scene also takes a `background` -
67
+ fragment GLSL drawn inside its own pass behind the meshes, or a skybox
68
+ cube - replacing the stacked backdrop-texture pattern.
66
69
  Custom materials get a standard uniform set - per-mesh `uModel`/`uNormal`,
67
70
  shared `uViewProj`/`uCamPos`/`uCamRight`/`uCamUp`, each written once per
68
71
  change - plus your own uniforms: scene-wide via `scene.setParams` (one write
@@ -83,27 +86,27 @@ every fleet only JS can step (`examples/instanced.tsx`). And
83
86
  blinn, fresnel, a standard vertex stage) to compose your own lit looks
84
87
  from plain template literals.
85
88
 
86
- v1 scope: unlit color/textured materials plus `shaderMaterial` (your own
87
- GLSL as a first-class material), sprites (`<Sprite>` with a `sprite()`
88
- material: a quad facing the camera in the vertex stage, full or fixed-y
89
- billboarding), geometry generators (box, plane, circle,
90
- ring, sphere, cylinder, cone, torus, torus knot, the platonic solids with
91
- `detail` subdivision, so the icosahedron is also the icosphere), a profile
92
- kit for custom
93
- solids (`extrude` with bevels, `lathe`, polyline `sweep`/`tube` with
94
- mitred joints, flat `shape`, with `fillet`/`roundRect`/`triangulate`
95
- helpers), geometry as data (`transformGeometry` bakes a placement into
96
- vertices and `mergeGeometries` concatenates parts, so a static scene is
97
- one mesh per material), open vertex layouts (`withAttribute` appends any
98
- named channel to a geometry's interleave, `withColors` is the `aColor`
99
- vec4 case - tint, baked AO, any four scalars - and materials read channels
100
- by name), one perspective camera
101
- with an orbit control (`<OrbitCamera>` as a Scene child, or
102
- `createOrbitCamera` imperatively; drag, pinch/wheel zoom and a pad reach
103
- it through an input map the app binds, auto-orbit),
104
- mesh picking with pointer events, scene backgrounds, transparency, and
105
- lights (`lit` material with hemisphere ambient, up to four directional
106
- light nodes, Blinn-Phong highlight and triplanar mapping).
107
- Model loading and shadows are staged next - see `okf/notes/3d-roadmap.md`
108
- for the ranked list. Full usage notes and traps: [AGENTS.md](AGENTS.md);
109
- runnable examples: [examples/](examples/).
89
+ In the package: materials (`unlit`, Blinn-Phong `phong`, metal/rough
90
+ `standard` with surface maps and image-based lighting, `sprite` for
91
+ camera-facing quads with a screen-size clamp, and `shaderMaterial` for
92
+ your own GLSL as a first-class material), lights as graph nodes
93
+ (directional, spot, point and hemisphere, up to eight, with cascaded
94
+ directional, spot and six-face point shadows), geometry generators (box,
95
+ plane, circle, ring, sphere, capsule, cylinder, cone, torus, torus knot,
96
+ the platonic solids with `detail` subdivision, so the icosahedron is also
97
+ the icosphere), a profile kit for custom solids (`extrude` with bevels,
98
+ `lathe`, polyline `sweep`/`tube` with mitred joints, flat `polygon`, with
99
+ `fillet`/`roundRect`/`triangulate` helpers), geometry as data
100
+ (`transformGeometry` bakes a placement into vertices, `mergeGeometries`
101
+ concatenates parts, `withAttribute`/`withColors` append named channels
102
+ materials read by name, morph targets), models (`loadGltf`/`loadModel`,
103
+ skins, a clip mixer with root motion, a baked `.sol3m` container with
104
+ compressed textures), Gaussian splats, level of detail, views of one
105
+ scene (split screen, a minimap, a reflection probe), environment and sky
106
+ (skybox, equirect panoramas, baked environments, fog, bloom, tone
107
+ mapping), camera controls (`<OrbitCamera>` and `<FirstPersonCamera>`
108
+ driven by an input map the app binds, shots and blends between them),
109
+ picking with pointer events, and the collision queries. Full usage notes
110
+ and traps: [AGENTS.md](AGENTS.md); runnable examples:
111
+ [examples/](examples/); the ranked list of what is next:
112
+ `okf/notes/3d-roadmap.md`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/3d",
3
- "version": "0.0.67",
3
+ "version": "0.0.68",
4
4
  "license": "MIT",
5
5
  "funding": "https://github.com/sponsors/wellawaretech",
6
6
  "author": "Antoine van Wel",
@@ -20,10 +20,10 @@
20
20
  "AGENTS.md"
21
21
  ],
22
22
  "devDependencies": {
23
- "@solidrt/test": "0.0.67"
23
+ "@solidrt/test": "0.0.68"
24
24
  },
25
25
  "peerDependencies": {
26
26
  "@solidjs/signals": "2.0.0-rc.13",
27
- "@solidrt/core": "0.0.67"
27
+ "@solidrt/core": "0.0.68"
28
28
  }
29
29
  }
@@ -2,11 +2,11 @@ import { onCleanup, untrack, useContext } from "@solidrt/core"
2
2
  import type { ParentComponent } from "@solidrt/core"
3
3
  import { SceneContext, provide } from "./context.tsx"
4
4
  import { syncNode } from "./node-props.ts"
5
- import type { TransformProps, BubblingPointerEventProps } from "./node-props.ts"
5
+ import type { TransformProps, BubblingNodePointerProps } from "./node-props.ts"
6
6
  import { add, createGroup, destroy } from "../node.ts"
7
7
  import type { SceneNode } from "../node.ts"
8
8
 
9
- export type GroupProps = TransformProps & BubblingPointerEventProps & { ref?: (node: SceneNode) => void }
9
+ export type GroupProps = TransformProps & BubblingNodePointerProps & { ref?: (node: SceneNode) => void }
10
10
 
11
11
  /** A transform node: children inherit its position/rotation/scale. Its
12
12
  * pointer events are the ones bubbling up from a hit descendant; a group
@@ -16,7 +16,7 @@
16
16
 
17
17
  export { useScene } from "./context.tsx"
18
18
  export type { CameraTarget } from "./context.tsx"
19
- export type { BubblingPointerEventProps, PointerEventProps, TransformProps } from "./node-props.ts"
19
+ export type { BubblingNodePointerProps, NodePointerProps, TransformProps } from "./node-props.ts"
20
20
  export { Scene } from "./scene.tsx"
21
21
  export type { ScenePointerProps, SceneProps } from "./scene.tsx"
22
22
  export { View3d } from "./view3d.tsx"
@@ -2,7 +2,7 @@ import { createContext, createEffect, onCleanup, untrack, useContext } from "@so
2
2
  import type { ParentComponent } from "@solidrt/core"
3
3
  import { SceneContext, provide } from "./context.tsx"
4
4
  import { syncNode } from "./node-props.ts"
5
- import type { TransformProps, PointerEventProps } from "./node-props.ts"
5
+ import type { TransformProps, NodePointerProps } from "./node-props.ts"
6
6
  import { syncMesh } from "./mesh.tsx"
7
7
  import type { PopulatedMeshProps } from "./mesh.tsx"
8
8
  import { add, destroy, setMorphWeights } from "../node.ts"
@@ -92,7 +92,7 @@ export let InstancedMesh: ParentComponent<InstancedMeshProps> = props => {
92
92
  )
93
93
  }
94
94
 
95
- export type InstanceProps = TransformProps & PointerEventProps & {
95
+ export type InstanceProps = TransformProps & NodePointerProps & {
96
96
  /** The instance's style record (setInstanceStyle as a prop): one value
97
97
  * per component of the material's second instance buffer, in order -
98
98
  * `[r, g, b, a]` under a stock material's `instanceColors`. Reactive;
@@ -3,7 +3,7 @@ import type { ParentComponent } from "@solidrt/core"
3
3
  import { LodContext, SceneContext, provide } from "./context.tsx"
4
4
  import type { LodRegistry } from "./context.tsx"
5
5
  import { syncNode } from "./node-props.ts"
6
- import type { TransformProps, BubblingPointerEventProps } from "./node-props.ts"
6
+ import type { TransformProps, BubblingNodePointerProps } from "./node-props.ts"
7
7
  import { syncMesh } from "./mesh.tsx"
8
8
  import type { PopulatedMeshProps } from "./mesh.tsx"
9
9
  import { InstancedMeshContext } from "./instanced-mesh.tsx"
@@ -15,7 +15,7 @@ import { disposeInstances, setCastShadow } from "../mesh.ts"
15
15
  import type { InstancedMesh as InstancedMeshNode } from "../mesh.ts"
16
16
 
17
17
  export type LodProps = TransformProps &
18
- BubblingPointerEventProps & {
18
+ BubblingNodePointerProps & {
19
19
  /** The cross-fade band fraction (createLod's `fade`); default 0. */
20
20
  fade?: number
21
21
  ref?: (node: SceneNode) => void
@@ -2,7 +2,7 @@ import { createEffect, onCleanup, untrack, useContext } from "@solidrt/core"
2
2
  import type { VoidComponent } from "@solidrt/core"
3
3
  import { SceneContext } from "./context.tsx"
4
4
  import { syncNode } from "./node-props.ts"
5
- import type { TransformProps, PointerEventProps } from "./node-props.ts"
5
+ import type { TransformProps, NodePointerProps } from "./node-props.ts"
6
6
  import { add, destroy, setMorphWeights } from "../node.ts"
7
7
  import type { MorphWeights } from "../node.ts"
8
8
  import { createMesh, setCastShadow, setCulling, setEnvironment, setGeometry, setLayers, setMaterial, setMeshParams, setRenderOrder } from "../mesh.ts"
@@ -12,7 +12,7 @@ import type { ShaderParams } from "@solidrt/core/gpu"
12
12
  import type { Geometry } from "../geometry.ts"
13
13
  import type { Material } from "../material.ts"
14
14
 
15
- export type MeshProps = TransformProps & PointerEventProps & {
15
+ export type MeshProps = TransformProps & NodePointerProps & {
16
16
  geometry: Geometry
17
17
  material: Material
18
18
  /** Per-mesh uniforms for a custom material (setMeshParams as a prop).
@@ -1,7 +1,7 @@
1
1
  import { createEffect, onCleanup, useContext } from "@solidrt/core"
2
2
  import { LodContext } from "./context.tsx"
3
3
  import { setTransform, setTransition, setVisible } from "../node.ts"
4
- import type { NodePointerEvent, NodeTapEvent, NodeWheelEvent, SceneNode, TransitionEndEvent } from "../node.ts"
4
+ import type { HoverHandlers, NodePointerEvent, NodeTapEvent, NodeWheelEvent, SceneNode, TransitionEndEvent } from "../node.ts"
5
5
  import type { NodeTransition } from "flux:spatial"
6
6
  import type { Quat, Vec3 } from "../math.ts"
7
7
 
@@ -36,7 +36,7 @@ export type TransformProps = {
36
36
  * the built-in <Scene> leaf does (opt out with events={false}); an `output`
37
37
  * leaf spreads them itself.
38
38
  */
39
- export type PointerEventProps = BubblingPointerEventProps & {
39
+ export type NodePointerProps = BubblingNodePointerProps & {
40
40
  /** Hover: enter/leave pair on the struck node alone, so only a hit
41
41
  * target - a mesh or an instance - takes them; `<Group>` and `<Lod>`
42
42
  * bubble and omit the pair. */
@@ -46,7 +46,7 @@ export type PointerEventProps = BubblingPointerEventProps & {
46
46
 
47
47
  /** The pointer props every node takes: the events that bubble from the
48
48
  * hit up through the Groups above it, without the hover pair. */
49
- export type BubblingPointerEventProps = {
49
+ export type BubblingNodePointerProps = {
50
50
  onPointerDown?: (event: NodePointerEvent) => void
51
51
  onPointerMove?: (event: NodePointerEvent) => void
52
52
  onPointerUp?: (event: NodePointerEvent) => void
@@ -60,7 +60,7 @@ export type BubblingPointerEventProps = {
60
60
  onTap?: (event: NodeTapEvent) => void
61
61
  }
62
62
 
63
- export function syncNode(node: SceneNode, props: TransformProps & PointerEventProps): void {
63
+ export function syncNode(node: SceneNode & HoverHandlers, props: TransformProps & NodePointerProps): void {
64
64
  // One effect for the transform and the handlers: re-assigning eight
65
65
  // handler fields on a transform write is free, an effect of its own is
66
66
  // not (a node component's mount cost is mostly its effects and its
@@ -63,7 +63,7 @@ export type SceneProps = ScenePointerProps & {
63
63
  /** Stagger (ms) for the nodes straight under the scene root: their
64
64
  * enters and exits beginning in one frame are spaced by `index *
65
65
  * stagger`, the whole-scene form of `<Group transition={{ stagger }}>`
66
- * (see SceneOptions.stagger). Set at creation. */
66
+ * (see SceneOptions.stagger). Live (scene.setStagger). */
67
67
  stagger?: number
68
68
  /** Scene-wide fog (scene.setFog): linear `{ color, near, far }` or exp2
69
69
  * `{ color, density }`, optionally thinning above `height` by
@@ -256,6 +256,11 @@ export let Scene: ParentComponent<SceneProps> = props => {
256
256
  l => scene.setLayers(l ?? 1),
257
257
  { defer: true },
258
258
  )
259
+ createEffect(
260
+ () => props.stagger,
261
+ ms => scene.setStagger(ms ?? null),
262
+ { defer: true },
263
+ )
259
264
  untrack(() => props.ref)?.(scene)
260
265
  // The pointer feed the app handed in listens at the scene's root, and
261
266
  // children reach it through useScene().pointer. Fixed at creation, like
@@ -1,7 +1,7 @@
1
1
  import { onCleanup, untrack, useContext } from "@solidrt/core"
2
2
  import type { VoidComponent } from "@solidrt/core"
3
3
  import { SceneContext } from "./context.tsx"
4
- import type { TransformProps, PointerEventProps } from "./node-props.ts"
4
+ import type { TransformProps, NodePointerProps } from "./node-props.ts"
5
5
  import { syncMesh } from "./mesh.tsx"
6
6
  import { add, destroy } from "../node.ts"
7
7
  import { createSprite } from "../mesh.ts"
@@ -9,7 +9,7 @@ import type { Mesh as MeshNode } from "../mesh.ts"
9
9
  import type { ShaderParams } from "@solidrt/core/gpu"
10
10
  import type { Material } from "../material.ts"
11
11
 
12
- export type SpriteProps = TransformProps & PointerEventProps & {
12
+ export type SpriteProps = TransformProps & NodePointerProps & {
13
13
  /** A `sprite()` material (any material draws, only a sprite one turns). */
14
14
  material: Material
15
15
  /** Per-mesh uniforms, merge semantics - as on Mesh. */
@@ -29,7 +29,9 @@
29
29
  //
30
30
  // Pose is plain mutable state; a nudge or a verb pushes it at once (a
31
31
  // mouse move under lock needs no frame loop), and update(dt) integrates
32
- // the rates and the glide from the app's own onFrame. Only `active` is
32
+ // the rates and the glide from the app's own onFrame (onBeforeRender when
33
+ // the camera rides something the frame moves, so it sees the frame's
34
+ // final pose). Only `active` is
33
35
  // reactive: the frame-loop gate, true while any rate reads non-zero (a
34
36
  // held key, a deflected stick) or a glide is in flight, so a still scene
35
37
  // renders nothing new. update() returns whether the pose changed since
package/src/index.ts CHANGED
@@ -6,8 +6,8 @@
6
6
  // PerspectiveCamera) on top. See AGENTS.md for the model and the traps.
7
7
 
8
8
  export { add, createGroup, destroy, getMorphNames, getMorphWeights, getRotation, getTransform, lookAt, remove, setMorphWeights, setTransform, setTransition, setVisible, worldPosition } from "./node.ts"
9
- export type { LodConfig, MorphWeights, NodePointerEvent, NodeTapEvent, NodeWheelEvent, SceneEventBase, SceneNode, ScenePointerEvent, ScenePointerListener, SceneTapEvent, SceneWheelEvent, TransformUpdate, TransitionEndEvent } from "./node.ts"
10
- export { addInstance, createInstancedMesh, createMesh, createRecordMesh, createSprite, disposeInstances, setCastShadow, setCulling, setDrawRange, setEnvironment, setGeometry, setInstanceStyle, setLayers, setMaterial, setMeshParams, setRecordCount, setRecords, setRenderOrder, instanceAttribute, updateRecords, INSTANCE_FLOATS } from "./mesh.ts"
9
+ export type { HoverHandlers, LodConfig, MorphWeights, NodePointerEvent, NodeTapEvent, NodeWheelEvent, SceneEventBase, SceneNode, ScenePointerEvent, ScenePointerListener, SceneTapEvent, SceneWheelEvent, TransformUpdate, TransitionEndEvent } from "./node.ts"
10
+ export { addInstance, createInstancedMesh, createMesh, createRecordMesh, createSprite, disposeInstances, setCastShadow, setCulling, setDrawRange, setEnvironment, setGeometry, setInstanceStyle, setLayers, setMaterial, setMeshParams, setRecordCount, setRecords, setRenderOrder, instanceAttribute, records, updateRecords, INSTANCE_FLOATS } from "./mesh.ts"
11
11
  export type { InstancedMesh as InstancedMeshNode, InstancedMeshOptions, InstanceNode, InstanceOrderOptions, InstanceSlots, InstanceStream, Mesh as MeshNode, MeshInstances, RecordMesh as RecordMeshNode, RecordMeshOptions, UpdateRecordsOptions } from "./mesh.ts"
12
12
  export { createDirectionalLight, createHemisphereLight, createPointLight, createSpotLight, setLight } from "./light.ts"
13
13
  // The shader-source caps, also on the /glsl subpath next to the sources they size.
@@ -62,8 +62,8 @@ export type {
62
62
  MeshProps,
63
63
  OrbitCameraProps,
64
64
  PerspectiveCameraProps,
65
- PointerEventProps,
66
- BubblingPointerEventProps,
65
+ NodePointerProps,
66
+ BubblingNodePointerProps,
67
67
  GroupProps,
68
68
  PointLightProps,
69
69
  RecordMeshProps,
package/src/material.ts CHANGED
@@ -20,6 +20,7 @@
20
20
  // @solidrt/core/gpu) stays first-class beneath both.
21
21
 
22
22
  import {
23
+ checkScreenSize,
23
24
  compileShader,
24
25
  createRenderPipeline,
25
26
  destroyProgram,
@@ -28,6 +29,7 @@ import {
28
29
  glsl,
29
30
  linkProgram,
30
31
  programAttributes,
32
+ SCREEN_SIZE_GLSL,
31
33
  } from "@solidrt/core/gpu"
32
34
  import type {
33
35
  BlendMode,
@@ -97,6 +99,10 @@ export type Material = {
97
99
  * like them, so the solids fill the depth before the fragments that
98
100
  * cannot early-z. */
99
101
  cutout?: boolean
102
+ /** How a `sprite()` material's quad faces the camera (set by sprite()):
103
+ * what the scene's pick rebuilds to test a screen-size-clamped sprite
104
+ * at its drawn size. Absent on every other material. */
105
+ billboard?: "full" | "fixed-y"
100
106
  /** The instance buffers, when the material's pipeline declares them
101
107
  * (shaderMaterialClass's `instanceBuffers`; the stock materials'
102
108
  * `instanced`), one layout per buffer after the geometry's. Such a
@@ -945,6 +951,27 @@ export type SpriteOptions = Omit<UnlitOptions, "instanced" | "instanceColors" |
945
951
  * soft puff, 0.5 a hard-edged disc), the per-entry `uFalloff`. Needs
946
952
  * `shape`. */
947
953
  falloff?: number
954
+ /**
955
+ * The screen-size clamp's floor, pixels (default 0: off): however far
956
+ * the camera is, the quad draws at least this big on its smaller axis,
957
+ * scaled uniformly about its center so its aspect holds - a marker or
958
+ * handle that stays findable at distance. The vertex stage scales by
959
+ * the pixels one world unit covers at the quad's depth (the depth
960
+ * alone, no foreshortening), so it is right under perspective and
961
+ * orthographic cameras alike, and `pick` tests the drawn quad. The
962
+ * per-entry `uScreenPx.x`; a per-mesh `params` may override the pair.
963
+ * A floored sprite's mesh turns its frustum culling off (its quad can
964
+ * exceed its box far away). Finite, >= 0.
965
+ */
966
+ minScreenPx?: number
967
+ /**
968
+ * The clamp's ceiling, pixels (default 0: off): the quad draws at most
969
+ * this big on its smaller axis however close the camera comes. Equal
970
+ * to `minScreenPx` it is a constant screen size, Three's
971
+ * `sizeAttenuation: false`, and `scale` then carries the aspect alone.
972
+ * The per-entry `uScreenPx.y`. Never below a floor that is on.
973
+ */
974
+ maxScreenPx?: number
948
975
  }
949
976
 
950
977
  // The radial shape as a tier-2 surface on the unlit fragment: the
@@ -962,11 +989,29 @@ const RADIAL_SURFACE = glsl`
962
989
 
963
990
  // The billboard vertex stages: the unit quad's corners placed along the
964
991
  // camera axes at the mesh's world position, with the quad's size read
965
- // off uModel's column lengths so `scale` sizes the sprite like any mesh.
966
- // The rotation part of uModel is otherwise ignored (the camera decides
967
- // the facing). Fixed-y takes the yaw from the camera-to-center direction
968
- // flattened onto XZ; straight above or below there is no yaw to take, so
969
- // the quad falls back to facing +z rather than dividing by zero.
992
+ // off uModel's column lengths so `scale` sizes the sprite like any mesh,
993
+ // then clamped on screen (core's screenSizeScale over uScreenPx, the
994
+ // material's minScreenPx/maxScreenPx; identity at [0, 0]). The rotation
995
+ // part of uModel is otherwise ignored (the camera decides the facing).
996
+ // Fixed-y takes the yaw from the camera-to-center direction flattened
997
+ // onto XZ; straight above or below there is no yaw to take, so the quad
998
+ // falls back to facing +z rather than dividing by zero. The scene's pick
999
+ // rebuilds exactly this quad for a clamped sprite (scene.ts
1000
+ // clampedSpriteHit); the two must agree.
1001
+ //
1002
+ // The pixels one world unit covers at the quad's depth: the center and
1003
+ // the center one camera-up unit away, projected, are half a viewport
1004
+ // height apart per NDC unit. Depth alone, no foreshortening, from the
1005
+ // shared uniforms only, and the same under an orthographic camera (clip
1006
+ // w is 1 there).
1007
+ const SPRITE_PX_PER_UNIT = glsl`
1008
+ float spritePxPerUnit(vec3 center) {
1009
+ vec4 c = uViewProj * vec4(center, 1.0);
1010
+ vec4 u = uViewProj * vec4(center + uCamUp, 1.0);
1011
+ return abs(u.y / u.w - c.y / c.w) * 0.5 * uViewport.y;
1012
+ }
1013
+ `
1014
+
970
1015
  const SPRITE_VERTEX_SRC = glsl`
971
1016
  in vec3 aPos;
972
1017
  in vec2 aUV;
@@ -976,10 +1021,15 @@ const SPRITE_VERTEX_SRC = glsl`
976
1021
  uniform mat4 uViewProj;
977
1022
  uniform vec3 uCamRight;
978
1023
  uniform vec3 uCamUp;
1024
+ uniform vec2 uViewport;
1025
+ uniform vec2 uScreenPx;
1026
+ ${SCREEN_SIZE_GLSL}
1027
+ ${SPRITE_PX_PER_UNIT}
979
1028
 
980
1029
  void main() {
981
1030
  vec3 center = uModel[3].xyz;
982
1031
  vec2 size = vec2(length(uModel[0].xyz), length(uModel[1].xyz));
1032
+ size *= screenSizeScale(size, spritePxPerUnit(center), uScreenPx);
983
1033
  vec3 world = center + uCamRight * (aPos.x * size.x) + uCamUp * (aPos.y * size.y);
984
1034
  vWorldPos = world;
985
1035
  gl_Position = uViewProj * vec4(world, 1.0);
@@ -995,10 +1045,16 @@ const SPRITE_FIXED_Y_VERTEX_SRC = glsl`
995
1045
  uniform mat4 uModel;
996
1046
  uniform mat4 uViewProj;
997
1047
  uniform vec3 uCamPos;
1048
+ uniform vec3 uCamUp;
1049
+ uniform vec2 uViewport;
1050
+ uniform vec2 uScreenPx;
1051
+ ${SCREEN_SIZE_GLSL}
1052
+ ${SPRITE_PX_PER_UNIT}
998
1053
 
999
1054
  void main() {
1000
1055
  vec3 center = uModel[3].xyz;
1001
1056
  vec2 size = vec2(length(uModel[0].xyz), length(uModel[1].xyz));
1057
+ size *= screenSizeScale(size, spritePxPerUnit(center), uScreenPx);
1002
1058
  vec3 toCam = uCamPos - center;
1003
1059
  toCam.y = 0.0;
1004
1060
  float len = length(toCam);
@@ -1021,6 +1077,11 @@ let spriteClasses = new Map<string, ShaderMaterialClass>()
1021
1077
  * plane. Unlike unlit, `transparent` defaults to TRUE - sprites are cutouts
1022
1078
  * far more often than not (Three's SpriteMaterial default) - pass false
1023
1079
  * for an opaque one. Culling is off: a camera-facing quad has no back.
1080
+ * `minScreenPx`/`maxScreenPx` clamp the quad's size on screen (the
1081
+ * per-entry uScreenPx, so sprites sharing the material share the clamp
1082
+ * and one draw; a per-mesh `params` override takes a mesh its own); the
1083
+ * scene's pick tests the drawn quad, and a floored sprite's mesh is never
1084
+ * frustum-culled.
1024
1085
  */
1025
1086
  export function sprite(opts: SpriteOptions = {}): Material {
1026
1087
  // Excluded by the type and checked for a JS caller: the billboard stage
@@ -1039,6 +1100,9 @@ export function sprite(opts: SpriteOptions = {}): Material {
1039
1100
  if (opts.shape !== undefined && !radial) throw new Error('sprite: shape must be "radial", got ' + opts.shape)
1040
1101
  if (opts.falloff !== undefined && !radial) throw new Error("sprite: falloff needs shape")
1041
1102
  if (opts.falloff !== undefined && !(Number.isFinite(opts.falloff) && opts.falloff > 0)) throw new Error("sprite: falloff must be a positive number, got " + opts.falloff)
1103
+ let minScreenPx = opts.minScreenPx ?? 0
1104
+ let maxScreenPx = opts.maxScreenPx ?? 0
1105
+ checkScreenSize("sprite", minScreenPx, maxScreenPx)
1042
1106
  let key = [map, transparent, blend, fixedY, fog, radial].join("|")
1043
1107
  let cls = spriteClasses.get(key)
1044
1108
  if (cls === undefined) {
@@ -1058,9 +1122,11 @@ export function sprite(opts: SpriteOptions = {}): Material {
1058
1122
  })
1059
1123
  spriteClasses.set(key, cls)
1060
1124
  }
1061
- let params: ShaderParams = { uColor }
1125
+ let params: ShaderParams = { uColor, uScreenPx: [minScreenPx, maxScreenPx] }
1062
1126
  if (radial) params.uFalloff = opts.falloff ?? 1
1063
- return cls.instance({ params, textures: map ? { uMap: opts.map! } : undefined })
1127
+ let material = cls.instance({ params, textures: map ? { uMap: opts.map! } : undefined })
1128
+ material.billboard = fixedY ? "fixed-y" : "full"
1129
+ return material
1064
1130
  }
1065
1131
 
1066
1132
  /** The attributes `material` reads that `layout` does not carry (name and
package/src/mesh.ts CHANGED
@@ -15,9 +15,9 @@ import type { Material } from "./material.ts"
15
15
  import type { TransformUpdate } from "./math.ts"
16
16
  import * as spatial from "flux:spatial"
17
17
  import { activateMorph, afterFree, createWeightsTexture, enterScene, leaveScene, makeNode, rebindMorph, remove, resetMorph, setTransform } from "./node.ts"
18
- import type { SceneNode } from "./node.ts"
18
+ import type { HoverHandlers, SceneNode } from "./node.ts"
19
19
 
20
- export type Mesh = SceneNode & {
20
+ export type Mesh = SceneNode & HoverHandlers & {
21
21
  kind: "mesh"
22
22
  geometry: Geometry
23
23
  material: Material
@@ -176,9 +176,10 @@ export type InstanceSlots = { slots: (InstanceNode | null)[]; free: number[] }
176
176
  * attribute list the material declared for it (any vertex format,
177
177
  * tightly packed), `data` the JS mirror of `capacity` records - a
178
178
  * Float32Array over an all-float layout, a Uint8Array otherwise
179
- * (vertexView's rule) - and `buffer` the mesh-owned GPU copy. Writes go
180
- * through instanceAttribute's accessor (or setInstanceStyle / setRecords,
181
- * which encode for you), land in the mirror, and publish as ONE
179
+ * (vertexView's rule; `records(mesh, stream)` hands it out for bulk
180
+ * writes) - and `buffer` the mesh-owned GPU copy. Writes go through
181
+ * instanceAttribute's accessor (or setInstanceStyle / setRecords, which
182
+ * encode for you) or the mirror itself, land in the mirror, and publish as ONE
182
183
  * coalesced buffer write per stream at the scene's next sync over the
183
184
  * `dirty` byte range - a frame-rate path, like setTransform. Growth
184
185
  * replaces `data` and `buffer` (the accessor follows; a held `data`
@@ -226,7 +227,7 @@ export type RecordMesh = Mesh & { _instances: MeshInstances & { matrix: null; no
226
227
  * Slot-bound to its mesh: created by addInstance, destroyed by
227
228
  * destroy; the generic add/remove reject it.
228
229
  */
229
- export type InstanceNode = SceneNode & {
230
+ export type InstanceNode = SceneNode & HoverHandlers & {
230
231
  kind: "instance"
231
232
  /** The owning mesh; null once removed (the handle is inert). */
232
233
  mesh: InstancedMesh | null
@@ -287,9 +288,35 @@ export function createSprite(material: Material): Mesh {
287
288
  if (spriteQuad === undefined) spriteQuad = plane({ label: "sprite" })
288
289
  let mesh = createMesh(spriteQuad, material)
289
290
  mesh._sprite = true
291
+ uncullFlooredSprite(mesh)
290
292
  return mesh
291
293
  }
292
294
 
295
+ /**
296
+ * The screen-size clamp a sprite mesh draws with, `[minPx, maxPx]` (0 =
297
+ * off): the mesh's own `uScreenPx` param over its material's (a sprite
298
+ * material always carries one). null when neither bound is on, or the
299
+ * material is not a sprite material.
300
+ */
301
+ export function spriteScreenPx(mesh: Mesh): [number, number] | null {
302
+ let px = mesh._params?.uScreenPx ?? mesh.material.params.uScreenPx
303
+ if (px === undefined || typeof px === "number") return null
304
+ let min = px[0] ?? 0
305
+ let max = px[1] ?? 0
306
+ return min > 0 || max > 0 ? [min, max] : null
307
+ }
308
+
309
+ // A floored sprite draws larger than its unit box far from the camera,
310
+ // so the frustum test would hide it while its quad is on screen: a floor
311
+ // coming on (at creation, a material swap, a params override) switches
312
+ // the mesh's culling off. Only ever off - an app that wants it back says
313
+ // so with setCulling.
314
+ function uncullFlooredSprite(mesh: Mesh): void {
315
+ if (!mesh._sprite || !mesh.frustumCulled) return
316
+ let px = spriteScreenPx(mesh)
317
+ if (px !== null && px[0] > 0) setCulling(mesh, { frustumCulled: false })
318
+ }
319
+
293
320
  /** The local box picking and sorting work from: explicit instance bounds
294
321
  * when the mesh is instanced (null without them - no leaf, no hits), the
295
322
  * unit box for a sprite, the geometry's own bounds otherwise. */
@@ -866,6 +893,30 @@ export function instanceAttribute(mesh: InstancedMesh | RecordMesh, name: string
866
893
  return null
867
894
  }
868
895
 
896
+ /**
897
+ * The mirror of record stream `stream` (default the first: a record
898
+ * mesh's records, an instanced mesh's style records) - the raw power
899
+ * path for a population written in bulk: `capacity` records at the
900
+ * layout's stride, a Float32Array over an all-float layout, bytes
901
+ * otherwise (write those through instanceAttribute's codecs, or a typed
902
+ * view of your own over the format). Write it, then updateRecords the
903
+ * range. Read it AT USE TIME: growth (setRecords past capacity,
904
+ * addInstance past it) replaces the mirror, and a hoisted view becomes a
905
+ * dead copy whose writes publish nothing. Throws on a transferred
906
+ * population (no mirror) and a disposed one. @solidrt/2d's
907
+ * records(layer).
908
+ */
909
+ export function records(mesh: InstancedMesh | RecordMesh, stream = 0): ArrayBufferView {
910
+ let inst: MeshInstances | null = mesh._instances
911
+ if (inst === null) throw new Error("records: the mesh's instances are disposed")
912
+ if (inst.transfer) throw new Error("records: the records were transferred at creation (transfer: true) - there is no mirror to read or write")
913
+ let s = inst.streams[stream]
914
+ if (!Number.isInteger(stream) || s === undefined) {
915
+ throw new Error("records: stream " + stream + " is out of range; the mesh has record streams 0.." + (inst.streams.length - 1))
916
+ }
917
+ return s.data
918
+ }
919
+
869
920
  /** Options of `updateRecords`: the stream (default 0) and the record
870
921
  * range (default the whole stream, capacity wide). */
871
922
  export type UpdateRecordsOptions = { stream?: number; first?: number; count?: number }
@@ -1336,6 +1387,7 @@ export function setDrawRange(mesh: Mesh, first: number, count?: number): void {
1336
1387
  export function setMaterial(mesh: Mesh, material: Material): void {
1337
1388
  if (mesh.material === material) return
1338
1389
  mesh.material = material
1390
+ uncullFlooredSprite(mesh)
1339
1391
  rebuildEntry(mesh)
1340
1392
  }
1341
1393
 
@@ -1359,5 +1411,6 @@ function rebuildEntry(mesh: Mesh): void {
1359
1411
  export function setMeshParams(mesh: Mesh, params: ShaderParams): void {
1360
1412
  if (mesh._params === null) mesh._params = {}
1361
1413
  Object.assign(mesh._params, params)
1414
+ if (params.uScreenPx !== undefined) uncullFlooredSprite(mesh)
1362
1415
  mesh._scene?._setParams(mesh, params)
1363
1416
  }
package/src/node.ts CHANGED
@@ -180,6 +180,15 @@ export type SceneHooks = {
180
180
  * cross-fade band. Re-applied whenever the node enters a scene. */
181
181
  export type LodConfig = { levels: { node: SceneNode | null; size: number }[]; fade: number }
182
182
 
183
+ /** The hover pair, on the nodes a ray can strike (Mesh, InstanceNode):
184
+ * enter/leave fire on the struck node alone and never bubble, so a group
185
+ * or a light carries none - the 2d package's Sprite against its
186
+ * SpriteGroup. */
187
+ export type HoverHandlers = {
188
+ onPointerEnter?: (event: NodePointerEvent) => void
189
+ onPointerLeave?: (event: NodePointerEvent) => void
190
+ }
191
+
183
192
  export type SceneNode = {
184
193
  kind: "group" | "mesh" | "light" | "instance"
185
194
  parent: SceneNode | null
@@ -198,14 +207,13 @@ export type SceneNode = {
198
207
  * nearest hit - the struck instance of an instanced mesh, else the mesh
199
208
  * - bubble through its ancestors and end at the scene's listeners
200
209
  * (stopPropagation stops the walk, and a stopped down claims the whole
201
- * press); enter/leave fire on the struck node alone. Events flow once
202
- * the element showing the scene carries `scene.handlers`. */
210
+ * press); enter/leave are the struck node's own (HoverHandlers, on Mesh
211
+ * and InstanceNode). Events flow once the element showing the scene
212
+ * carries `scene.handlers`. */
203
213
  onPointerDown?: (event: NodePointerEvent) => void
204
214
  onPointerMove?: (event: NodePointerEvent) => void
205
215
  onPointerUp?: (event: NodePointerEvent) => void
206
216
  onPointerCancel?: (event: NodePointerEvent) => void
207
- onPointerEnter?: (event: NodePointerEvent) => void
208
- onPointerLeave?: (event: NodePointerEvent) => void
209
217
  /** The wheel over the node (NodeWheelEvent: `deltaX`/`deltaY`), bubbling
210
218
  * like down/move/up. */
211
219
  onWheel?: (event: NodeWheelEvent) => void
package/src/orbit.ts CHANGED
@@ -101,7 +101,9 @@
101
101
  // at once (the next paint carries the new camera, no frame loop needed
102
102
  // for a drag), and update(dt) integrates the rates, the auto-orbit, the
103
103
  // motion, the follow, the lanes and the occlusion return from the app's
104
- // own onFrame - the control registers no frame loop of its own. Only
104
+ // own onFrame (onBeforeRender when it follows something the frame moves,
105
+ // so it sees the frame's final pose) - the control registers no frame
106
+ // loop of its own. Only
105
107
  // `orbiting` and `active` are reactive: slow UI state a HUD reads, and
106
108
  // the frame-loop gate. update() returns whether the camera changed since
107
109
  // the previous update (pushes included), so per-frame dependents
@@ -303,8 +305,10 @@ export type OrbitCamera = {
303
305
  active(): boolean
304
306
  /** Integrate the axis rates, the auto-orbit, the motion, the follow,
305
307
  * the lanes and the occlusion return over dt seconds and push any
306
- * camera change. Call from onFrame; returns whether the camera changed
307
- * since the previous update (nudges and verbs included). */
308
+ * camera change. Call from onFrame - or from onBeforeRender when the
309
+ * control follows something the frame moves, so it reads that frame's
310
+ * final pose; returns whether the camera changed since the previous
311
+ * update (nudges and verbs included). */
308
312
  update(dt: number): boolean
309
313
  /** The input abstraction: `rotate` (vec2, element heights of drag /
310
314
  * turns per second), `zoom` (axis, octaves, positive in), `pan` (vec2,
@@ -325,9 +329,12 @@ export type OrbitCamera = {
325
329
  * camera's right and up (or the ground plane, `panPlane`), through
326
330
  * `clampPose`, and push. */
327
331
  panBy(right: number, up: number): void
328
- /** Re-seat the pivot at a world point without moving the picture: the
329
- * point is projected onto the view axis and becomes the target at that
330
- * depth. Points at or behind the eye are ignored. */
332
+ /** Re-seat the pivot (the orbit target, the point the camera turns
333
+ * about - the 2d camera's pose point, which that control places ON
334
+ * SCREEN by its `pivot` fraction and moves in world space by its pose)
335
+ * at a world point without moving the picture: the point is projected
336
+ * onto the view axis and becomes the target at that depth. Points at
337
+ * or behind the eye are ignored. */
331
338
  setPivot(point: Vec3): void
332
339
  /** Re-seat the pivot at a world point EXACTLY, without moving the
333
340
  * picture: the target becomes the point, the distance its depth, and
@@ -116,7 +116,7 @@ export function makePointerInput(deps: PointerInputDeps): PointerInput {
116
116
  let sameHit = (hit: Hit, target: Target): boolean => hit.mesh === target.mesh && (hit.instance ?? null) === target.instance
117
117
  let sameTarget = (a: Target | null, b: Target | null): boolean => (a === null ? b === null : b !== null && a.mesh === b.mesh && a.instance === b.instance)
118
118
  // The node the walk starts at, and the one enter/leave fire on.
119
- let nodeOf = (target: Target): SceneNode => target.instance ?? target.mesh
119
+ let nodeOf = (target: Target): Mesh | InstanceNode => target.instance ?? target.mesh
120
120
  // The captured target's own hit, if the ray still strikes it.
121
121
  let hitOn = (target: Target | null, x: number, y: number): Hit | null => {
122
122
  if (target === null) return null
@@ -99,7 +99,7 @@ export type ShadowSystemDeps<V extends ShadowView> = {
99
99
  disposeView(view: V): void
100
100
  /** The light set changed shape: the scene owes a writeLights. */
101
101
  markLightsDirty(): void
102
- /** Schedule the scene's sync microtask. */
102
+ /** Schedule the scene's sync. */
103
103
  schedule(): void
104
104
  }
105
105
 
package/src/scene.ts CHANGED
@@ -13,15 +13,17 @@
13
13
  // coverage (stored and skipped until a declaring material arrives), so no
14
14
  // bookkeeping tracks who reads them. scene.setParams merges app-owned
15
15
  // names into the same set.
16
- // Mutations batch to a microtask, so a burst of writes (a whole subtree
17
- // moved, many effects in one flush) syncs once.
16
+ // Mutations batch, so a burst of writes (a whole subtree moved, many
17
+ // effects in one flush) syncs once: in the publish pass of core's
18
+ // onBeforeRender, ahead of the frame's paint, or at a microtask for a
19
+ // write made outside a frame.
18
20
  //
19
21
  // Rendering itself belongs to the runtime: the target is an ordinary
20
22
  // `render: "auto"` draw target that re-renders when its entries change, so
21
23
  // a static scene costs zero passes and this module registers no frame
22
24
  // loop. Continuous animation is the app's onFrame writing transforms -
23
- // each write lands in the core, the microtask flushes it, and the frame
24
- // renders once.
25
+ // each write lands in the core, the sync ahead of the paint flushes it,
26
+ // and the frame renders once.
25
27
  //
26
28
  // Still in JS this stage (see okf/backlog/spatial-core.md): the picking
27
29
  // broadphase and its leaves, the transparent sort's centers and the light
@@ -36,11 +38,11 @@
36
38
  // and pointer subsystems are scene-shadows.ts / scene-pointer.ts,
37
39
  // built here with the scene's seams as their deps.
38
40
 
39
- import { addDraw, createCubeDrawTarget, createDrawTarget, depthTexture, destroyProgram, destroyRenderPipeline, destroyTexture, removeDraw, renderTarget, setDrawBuffers, setDrawParams, setDrawRange, setDrawTextures, setTargetParams, setTargetRect, setTargetSize, setTargetTextures } from "@solidrt/core/gpu"
41
+ import { addDraw, createCubeDrawTarget, createDrawTarget, depthTexture, destroyProgram, destroyRenderPipeline, destroyTexture, removeDraw, renderTarget, screenSizeScale, setDrawBuffers, setDrawParams, setDrawRange, setDrawTextures, setTargetParams, setTargetRect, setTargetSize, setTargetTextures } from "@solidrt/core/gpu"
40
42
  import * as spatial from "flux:spatial"
41
43
  import type { BindDrawOptions, Impact as CoreImpact, NodeId, QueryFilter } from "flux:spatial"
42
44
  import type { BufferId, DrawId, FilterMode, InstanceOrder, ProgramId, RenderPipelineId, ShaderParams, TextureBindings, TextureId, WrapMode } from "@solidrt/core/gpu"
43
- import { getOwner, onCleanup } from "@solidrt/core"
45
+ import { getOwner, onBeforeRender, onCleanup, runWithOwner } from "@solidrt/core"
44
46
  import type { PointerEvent as ElementPointerEvent, WheelEvent as ElementWheelEvent } from "@solidrt/core"
45
47
  import { copy, mat4, transformPoint } from "./math.ts"
46
48
  import { linearColor, premultipliedColor } from "./color.ts"
@@ -61,9 +63,9 @@ import type { EnvironmentOptions, Prefilter } from "./environment.ts"
61
63
 
62
64
  export type { EnvironmentOptions } from "./environment.ts"
63
65
  import type { Material } from "./material.ts"
64
- import { activateMorph, fillTransform, freeLeaving, leaveScene, makeNode, setTransition } from "./node.ts"
66
+ import { activateMorph, fillTransform, freeLeaving, leaveScene, makeNode, setTransition, worldInto } from "./node.ts"
65
67
  import type { SceneHooks, SceneNode, ScenePointerListener } from "./node.ts"
66
- import { checkInstancePairing, checkMask, checkOrderPairing, drawCount, instanceBinding, localBounds, markLiveRecords, publishRecords } from "./mesh.ts"
68
+ import { checkInstancePairing, checkMask, checkOrderPairing, drawCount, instanceBinding, localBounds, markLiveRecords, publishRecords, spriteScreenPx } from "./mesh.ts"
67
69
  import type { InstancedMesh, InstanceNode, Mesh, MeshInstances, ResolvedInstanceOrder } from "./mesh.ts"
68
70
  import type { CastingLight, Light } from "./light.ts"
69
71
 
@@ -470,7 +472,7 @@ export type SceneOptions = {
470
472
  * { stagger })` at creation: every node added straight under the root
471
473
  * that enters or leaves in one frame is spaced by `index * stagger`,
472
474
  * the whole-scene form of a Group's stagger (nested groups declaring
473
- * their own win for what is under them). */
475
+ * their own win for what is under them). See setStagger. */
474
476
  stagger?: number
475
477
  /** `autoFree: false` opts out of owner-scoped auto-dispose (then call dispose yourself). */
476
478
  autoFree?: boolean
@@ -482,7 +484,8 @@ export type SceneOptions = {
482
484
  /** Where a per-scene budget error goes: the light cap and the
483
485
  * shadow-slot budget are checked over the settled set at the sync and
484
486
  * reported once per change of the set, the scene rendering on with
485
- * what fits. Without a handler the sync throws it, uncaught (a
487
+ * what fits. Without a handler the sync throws it, and it is only
488
+ * reported (logged by the frame's publish pass, or uncaught from the
486
489
  * microtask): the `<Scene>` component hands one in and rethrows inside
487
490
  * the tree, so the app's error boundary shows it. */
488
491
  onError?: (error: Error) => void
@@ -827,6 +830,9 @@ export type Scene = {
827
830
  * measured projected size, for the scene, its views and its shadow
828
831
  * tiles. Below 1 switches to far levels sooner. */
829
832
  setLodBias(bias: number): void
833
+ /** Stagger (ms) on the scene's root node, live (SceneOptions.stagger
834
+ * is its creation form): null removes it. The 2d layer's setStagger. */
835
+ setStagger(ms: number | null): void
830
836
  /** The level a LOD group draws in the scene's own render (the thing to
831
837
  * read while tuning thresholds): the index into its levels, nearest
832
838
  * first, `levels.length` when culled past the last one, null before
@@ -1583,8 +1589,9 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
1583
1589
  let inst = mesh._instances
1584
1590
  let bufs = mesh._buffers!
1585
1591
  // The entry starts switched off: it has no world matrix yet - the walk
1586
- // in sync() computes one - and _schedule() defers that to a microtask,
1587
- // so added live it would draw at the seeded identity until then. The
1592
+ // in sync() computes one - and _schedule() defers that to the sync
1593
+ // (the frame's publish pass, or a microtask), so added live it would
1594
+ // draw at the seeded identity until then. The
1588
1595
  // mismatch branch in sync() turns it on in the same pass that writes
1589
1596
  // uModel.
1590
1597
  let morph = morphEntry(mesh.material, mesh)
@@ -2270,6 +2277,21 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
2270
2277
  // first frame.
2271
2278
  lightsDirty = true
2272
2279
  hooks._schedule()
2280
+ // The publish pass of every frame runs the pending sync ahead of the
2281
+ // paint, so a write made anywhere in the frame's JS is in that frame's
2282
+ // picture: the count, the buffers and the bytes of a record write land
2283
+ // together, and nothing shows a frame late. The microtask stays for
2284
+ // writes made outside a frame. Registered outside any owner: the scene's
2285
+ // own dispose unhooks it (an autoFree: false scene outlives the owner it
2286
+ // was created in).
2287
+ let unhook = runWithOwner(null, () =>
2288
+ onBeforeRender(
2289
+ () => {
2290
+ if (scheduled) sync()
2291
+ },
2292
+ { publish: true },
2293
+ ),
2294
+ )
2273
2295
 
2274
2296
  // The scene's root listeners (scene.listen): the last stop of the
2275
2297
  // pointer walk scene-pointer.ts runs behind scene.handlers.
@@ -2303,6 +2325,101 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
2303
2325
  return hits
2304
2326
  }
2305
2327
 
2328
+ // Sprites under a screen-size clamp (the sprite material's
2329
+ // minScreenPx/maxScreenPx) draw at a size the index does not know: the
2330
+ // vertex stage scales the quad by the pixels one unit covers at its
2331
+ // depth under THE CAMERA DRAWING IT. So a pick - which has a camera -
2332
+ // replaces the ray's hits on them with an exact ray-quad test at the
2333
+ // drawn size: the quad's basis as the stage builds it (camera right and
2334
+ // up, or a fixed-y sprite's yaw basis), the scale from core's
2335
+ // screenSizeScale over the stage's own pixels-per-unit derivation. The
2336
+ // world-space raycast keeps the index's answer. Reads pickOrigin/pickDir
2337
+ // as pixelRayOf left them; `th` is the target's pixel height the stage
2338
+ // sees as uViewport.y.
2339
+ let spriteWorld = mat4()
2340
+ let spriteClip: Vec4 = [0, 0, 0, 0]
2341
+ let spriteProbe: Vec3 = [0, 0, 0]
2342
+ let shown = (node: SceneNode | null): boolean => {
2343
+ for (; node !== null; node = node.parent) if (!node.visible) return false
2344
+ return true
2345
+ }
2346
+ let clampedSpriteHit = (mesh: Mesh, px: [number, number], cam: Camera, th: number): Hit | null => {
2347
+ let m = worldInto(spriteWorld, mesh)
2348
+ let cx = m[12], cy = m[13], cz = m[14]
2349
+ let w = Math.hypot(m[0], m[1], m[2])
2350
+ let h = Math.hypot(m[4], m[5], m[6])
2351
+ let v = cam.view
2352
+ let upX = v[1], upY = v[5], upZ = v[9]
2353
+ spriteProbe[0] = cx
2354
+ spriteProbe[1] = cy
2355
+ spriteProbe[2] = cz
2356
+ transformPoint(spriteClip, cam.viewProj, spriteProbe)
2357
+ let cw = spriteClip[3]
2358
+ if (!(cw > 1e-6)) return null
2359
+ let centerNdcY = spriteClip[1] / cw
2360
+ spriteProbe[0] = cx + upX
2361
+ spriteProbe[1] = cy + upY
2362
+ spriteProbe[2] = cz + upZ
2363
+ transformPoint(spriteClip, cam.viewProj, spriteProbe)
2364
+ let pxPerUnit = Math.abs(spriteClip[1] / spriteClip[3] - centerNdcY) * 0.5 * th
2365
+ let s = screenSizeScale(w, h, pxPerUnit, px[0], px[1])
2366
+ let halfW = (w * s) / 2
2367
+ let halfH = (h * s) / 2
2368
+ let rx: number, ry: number, rz: number
2369
+ if (mesh.material.billboard === "fixed-y") {
2370
+ let tx = cam.eye[0] - cx
2371
+ let tz = cam.eye[2] - cz
2372
+ let len = Math.hypot(tx, tz)
2373
+ if (len > 1e-6) {
2374
+ rx = tz / len
2375
+ ry = 0
2376
+ rz = -tx / len
2377
+ } else {
2378
+ rx = 1
2379
+ ry = 0
2380
+ rz = 0
2381
+ }
2382
+ upX = 0
2383
+ upY = 1
2384
+ upZ = 0
2385
+ } else {
2386
+ rx = v[0]
2387
+ ry = v[4]
2388
+ rz = v[8]
2389
+ }
2390
+ // The quad's normal, right x up; the ray meets the plane at t.
2391
+ let nx = ry * upZ - rz * upY
2392
+ let ny = rz * upX - rx * upZ
2393
+ let nz = rx * upY - ry * upX
2394
+ let ox = pickOrigin[0], oy = pickOrigin[1], oz = pickOrigin[2]
2395
+ let dx = pickDir[0], dy = pickDir[1], dz = pickDir[2]
2396
+ let denom = dx * nx + dy * ny + dz * nz
2397
+ if (Math.abs(denom) < 1e-12) return null
2398
+ let t = ((cx - ox) * nx + (cy - oy) * ny + (cz - oz) * nz) / denom
2399
+ if (t <= 0) return null
2400
+ let hx = ox + t * dx, hy = oy + t * dy, hz = oz + t * dz
2401
+ let lx = (hx - cx) * rx + (hy - cy) * ry + (hz - cz) * rz
2402
+ let ly = (hx - cx) * upX + (hy - cy) * upY + (hz - cz) * upZ
2403
+ if (Math.abs(lx) > halfW || Math.abs(ly) > halfH) return null
2404
+ // Facing the ray, like every core hit; distance along the normalized ray.
2405
+ let flip = denom > 0 ? -1 : 1
2406
+ return { mesh, distance: t * Math.hypot(dx, dy, dz), point: [hx, hy, hz], normal: [nx * flip, ny * flip, nz * flip] }
2407
+ }
2408
+ let clampSpriteHits = (hits: Hit[], cam: Camera, th: number): Hit[] => {
2409
+ let out: Hit[] | null = null
2410
+ for (let mesh of meshes) {
2411
+ if (!mesh._sprite) continue
2412
+ let px = spriteScreenPx(mesh)
2413
+ if (px === null) continue
2414
+ if (out === null) out = hits.filter(hit => !(hit.mesh._sprite && spriteScreenPx(hit.mesh) !== null))
2415
+ if ((mesh.layers & sceneMask) === 0 || !shown(mesh)) continue
2416
+ let hit = clampedSpriteHit(mesh, px, cam, th)
2417
+ if (hit !== null) out.push(hit)
2418
+ }
2419
+ if (out === null) return hits
2420
+ return out.sort((a, b) => a.distance - b.distance)
2421
+ }
2422
+
2306
2423
  let scene: Scene = {
2307
2424
  texture: resolve === null ? texture : resolve.target,
2308
2425
  hdrTexture: texture,
@@ -2323,6 +2440,10 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
2323
2440
  hooks._schedule()
2324
2441
  },
2325
2442
  size: () => ({ width, height }),
2443
+ setStagger(ms) {
2444
+ if (disposed) return
2445
+ setTransition(root, ms === null ? null : { stagger: ms })
2446
+ },
2326
2447
  setParams(params) {
2327
2448
  if (disposed) return
2328
2449
  Object.assign(sceneParams, params)
@@ -2443,7 +2564,7 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
2443
2564
  },
2444
2565
  pick(x, y) {
2445
2566
  pixelRay(x, y)
2446
- return raycastAs(texture, pickOrigin, pickDir, undefined)
2567
+ return clampSpriteHits(raycastAs(texture, pickOrigin, pickDir, undefined), camera, height)
2447
2568
  },
2448
2569
  unproject(x, y, w, out = [0, 0, 0]) {
2449
2570
  pixelRay(x, y)
@@ -2590,7 +2711,7 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
2590
2711
  pick(x, y) {
2591
2712
  if (v.disposed) return []
2592
2713
  pixelRayOf(v.camera, v.width, v.height, x, y)
2593
- return raycastAs(v.texture, pickOrigin, pickDir, undefined)
2714
+ return clampSpriteHits(raycastAs(v.texture, pickOrigin, pickDir, undefined), v.camera, v.height)
2594
2715
  },
2595
2716
  project(point) {
2596
2717
  return projectWith(v.camera, v.width, v.height, point)
@@ -2656,6 +2777,7 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
2656
2777
  dispose() {
2657
2778
  if (disposed) return
2658
2779
  disposed = true
2780
+ unhook()
2659
2781
  listeners.clear()
2660
2782
  // Full tree-side teardown, not just the target: every node leaves
2661
2783
  // the scene (entries' geometry-buffer references and pick leaves