@solidrt/3d 0.0.48 → 0.0.50
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 +162 -13
- package/README.md +19 -5
- package/examples/README.md +8 -0
- package/examples/pick.tsx +98 -0
- package/examples/scene-background.tsx +43 -0
- package/package.json +3 -3
- package/src/bvh.ts +258 -0
- package/src/components.tsx +66 -5
- package/src/geometry.ts +29 -0
- package/src/index.ts +5 -5
- package/src/material.ts +163 -55
- package/src/math.ts +42 -0
- package/src/order.ts +51 -0
- package/src/scene.ts +516 -28
package/AGENTS.md
CHANGED
|
@@ -11,7 +11,7 @@ blendMode and pointer events like any element. Design rationale:
|
|
|
11
11
|
|
|
12
12
|
- Two layers. The imperative core is Solid-free: `createScene`,
|
|
13
13
|
`createMesh(geometry, material)`, `add`/`remove`, `setTransform`,
|
|
14
|
-
`lookAt`, `getRotation`, `setVisible` - plain objects with dirty flags, batched to a
|
|
14
|
+
`lookAt`, `getRotation`, `setVisible`, `setRenderOrder` - plain objects with dirty flags, batched to a
|
|
15
15
|
microtask,
|
|
16
16
|
one `setDrawParams` (uModel, plus uNormal for materials declaring it)
|
|
17
17
|
per changed mesh and ONE `setTargetParams` (the shared uViewProj +
|
|
@@ -42,16 +42,22 @@ blendMode and pointer events like any element. Design rationale:
|
|
|
42
42
|
lazy, shared, and app-lifetime (owner-scoped free would break sharing);
|
|
43
43
|
`disposeGeometry` frees them.
|
|
44
44
|
- Materials dedupe hard: one program + one pipeline per material CLASS
|
|
45
|
-
(unlit color, unlit map), `depth: true` +
|
|
45
|
+
(unlit color, unlit map, each opaque or transparent), `depth: true` +
|
|
46
|
+
`cull: "back"`; an instance is
|
|
46
47
|
just per-entry uniforms (`uColor`) and bindings (`uMap`).
|
|
48
|
+
- The pure pieces (`math.ts`, `bvh.ts`, `order.ts`) have check rigs in
|
|
49
|
+
`checks/`, run headless on flux from the repo root:
|
|
50
|
+
`bunx srt bundle -f --stdout packages/3d/checks/<name>-check.ts | target/release/flux - [seed]`.
|
|
51
|
+
They print PASS or FAIL lines and throw on failure (the flux binary exits
|
|
52
|
+
0 either way - read the output). Extend the rig when you change the module.
|
|
47
53
|
|
|
48
54
|
## Components
|
|
49
55
|
|
|
50
56
|
| Component | Props |
|
|
51
57
|
| --- | --- |
|
|
52
|
-
| `Scene` | `width`, `height` (target pixels), `clearColor?`, `label?`, `ref?(scene)`, `output?(texture)` |
|
|
53
|
-
| `Group` | `position?`, `rotation?` (Euler radians, XYZ order), `quaternion?` (either, not both), `scale?` (number = uniform), `visible?`, `ref?(node)` |
|
|
54
|
-
| `Mesh` | `geometry`, `material`, transforms as Group, `params?` (per-mesh uniforms, merge semantics - no unset), `ref?(mesh)` |
|
|
58
|
+
| `Scene` | `width`, `height` (target pixels), `clearColor?`, `background?` (fragment GLSL), `label?`, `ref?(scene)`, `output?(texture)`, `events?` (mesh pointer events, default on) |
|
|
59
|
+
| `Group` | `position?`, `rotation?` (Euler radians, XYZ order), `quaternion?` (either, not both), `scale?` (number = uniform), `visible?`, pointer events (below), `ref?(node)` |
|
|
60
|
+
| `Mesh` | `geometry`, `material`, transforms as Group, `params?` (per-mesh uniforms, merge semantics - no unset), pointer events (below), `ref?(mesh)` |
|
|
55
61
|
| `PerspectiveCamera` | `fov?` (vertical DEGREES, default 60), `near?`, `far?`, `position?`, `lookAt?`, `up?` |
|
|
56
62
|
|
|
57
63
|
Output composition: without `output`, `Scene` emits a minimal
|
|
@@ -102,6 +108,40 @@ immediately, so set-then-project in one tick is exact. `scene.viewProj(out?)`
|
|
|
102
108
|
copies the view-projection matrix for batch work. Never rebuild the
|
|
103
109
|
camera matrices by hand for a HUD.
|
|
104
110
|
|
|
111
|
+
Picking: `scene.pick(x, y)` is project()'s inverse - the camera ray
|
|
112
|
+
through a scene pixel, returning `Hit[]` (`{ mesh, distance, point }`,
|
|
113
|
+
world units, nearest first; every hit along the ray, not just the front
|
|
114
|
+
one). `scene.raycast(origin, direction)` is the world-space primitive
|
|
115
|
+
under it. The volume tier: hits test each mesh's local bounding box,
|
|
116
|
+
transformed exactly under any node transform (non-uniform scale
|
|
117
|
+
included), so results are conservative - a ray through a knot's hole
|
|
118
|
+
still hits (no `face`/`uv` fields until a triangle tier exists).
|
|
119
|
+
Broadphase is a dynamic AABB tree (BVH) the sync walk keeps current from
|
|
120
|
+
its own dirty set - maintenance is O(changed), a query O(log meshes) -
|
|
121
|
+
so per-pointer-move picking puts no ceiling on scene size. Both methods
|
|
122
|
+
flush pending writes first (the lookAt/project immediacy contract), and
|
|
123
|
+
both skip invisible meshes.
|
|
124
|
+
|
|
125
|
+
Mesh pointer events - the element vocabulary one tree deeper:
|
|
126
|
+
`onPointerDown/Move/Up/Enter/Leave` as plain fields on any node (and as
|
|
127
|
+
Mesh/Group props). The nearest hit mesh is the target; down/move/up
|
|
128
|
+
bubble mesh -> ancestor groups (`stopPropagation()` stops the walk);
|
|
129
|
+
enter/leave fire on the mesh alone, pairing on hover changes. A
|
|
130
|
+
pointer-down CAPTURES its mesh until the up: moves and the up keep
|
|
131
|
+
dispatching to it off-mesh (the platform's captured-drag rule), with
|
|
132
|
+
`point`/`distance` null while the ray misses it. The event carries the
|
|
133
|
+
element fields (pointerId, pointerType, button, modifiers) plus `mesh`,
|
|
134
|
+
`currentTarget`, `point`, `distance`, and `x`/`y` in scene pixels.
|
|
135
|
+
Wiring: the built-in `<Scene>` leaf carries `scene.handlers`
|
|
136
|
+
automatically (opt out: `events={false}`); an `output` leaf or
|
|
137
|
+
imperative composition spreads `{...scene.handlers}` onto the element
|
|
138
|
+
showing the texture. `scene.handlers` assumes that leaf is LAID OUT at
|
|
139
|
+
the target size - true for the built-in leaf and a d-texture at natural
|
|
140
|
+
size, under any ancestor transforms or viewBox fits (the hit test
|
|
141
|
+
undoes them; localX/localY arrive in the leaf's layout frame). A leaf
|
|
142
|
+
laid out at a different size (the supersampling pattern) uses
|
|
143
|
+
`scene.handlersFor(() => ({ width, height }))` with its layout size.
|
|
144
|
+
|
|
105
145
|
Geometry: `box(w?, h?, d?)`; `plane(w?, h?)`, `circle(radius?, seg?)` and
|
|
106
146
|
`ring(inner?, outer?, seg?)` (XY, facing +z - rotate `[-Math.PI/2, 0, 0]`
|
|
107
147
|
for a floor); `sphere(radius?, wSeg?, hSeg?)`;
|
|
@@ -160,7 +200,10 @@ Materials:
|
|
|
160
200
|
at shaderMaterial() creation. The rest is opt-in by declare-and-use:
|
|
161
201
|
`uniform vec3 uCamPos` (the camera's world position, shared and written
|
|
162
202
|
with uViewProj - the specular/fresnel view vector is
|
|
163
|
-
`normalize(uCamPos - worldPos)`)
|
|
203
|
+
`normalize(uCamPos - worldPos)`), `uniform vec3 uCamRight` / `uCamUp`
|
|
204
|
+
(the camera's world-space view axes, shared likewise - a billboard is
|
|
205
|
+
`center + uCamRight * x + uCamUp * y`; do NOT rebuild them from
|
|
206
|
+
uViewProj rows, that carries the clip flip) and `uniform mat4 uNormal` (the world
|
|
164
207
|
inverse-transpose, written beside uModel for this material's meshes;
|
|
165
208
|
take `mat3(uNormal)` - correct under non-uniform scale, where
|
|
166
209
|
mat3(uModel) bends normals off the surface). Attributes come from the
|
|
@@ -174,6 +217,29 @@ Materials:
|
|
|
174
217
|
with the `Mesh` `params` prop (same merge semantics - a key that
|
|
175
218
|
disappears from the object keeps its old value; for per-frame values
|
|
176
219
|
prefer `ref` + setMeshParams from onFrame, the setTransform split).
|
|
220
|
+
Scene-wide values (a clock, a sun direction, fog) go through
|
|
221
|
+
`scene.setParams({ uTime })` instead - one write for every mesh.
|
|
222
|
+
- `shaderMaterialClass({ vertex, fragment, ...pipeline state })` - the
|
|
223
|
+
class/instance split for your own GLSL: compiles once, and
|
|
224
|
+
`cls.instance({ params?, textures? })` returns a Material sharing that
|
|
225
|
+
pipeline with its own values. `dispose()` lives on the class alone.
|
|
226
|
+
`shaderMaterial(opts)` is exactly a class with one instance (its
|
|
227
|
+
`dispose` forwards to the class).
|
|
228
|
+
|
|
229
|
+
Background: `scene.setBackground(source | null)`, the `background` option
|
|
230
|
+
on createScene, and the reactive `Scene` prop. Fragment GLSL drawn as the
|
|
231
|
+
FIRST entry of the scene's own pass (attributeless fullscreen triangle,
|
|
232
|
+
depth off) - one target instead of a backdrop texture stacked under the
|
|
233
|
+
scene, with no separate resize plumbing. The source gets the
|
|
234
|
+
shader-target fragment contract exactly (vUV 0..1 top-left origin,
|
|
235
|
+
iResolution, fragColor; no `#version` line = the standard preamble), so
|
|
236
|
+
a `createShaderTexture` backdrop ports verbatim. Three's
|
|
237
|
+
`scene.background = color` is `clearColor` here; a texture-id form can
|
|
238
|
+
widen the signature later (a branded TextureId is a number, so
|
|
239
|
+
`string | TextureId` disambiguates at runtime). No app-driven uniforms:
|
|
240
|
+
a background is static art - anything animated is a mesh's own
|
|
241
|
+
shaderMaterial (or, until blend factors land, a separate shader texture
|
|
242
|
+
underneath, which translucent grounds also still need).
|
|
177
243
|
|
|
178
244
|
Lighting GLSL (`@solidrt/3d/glsl`): exported string constants composed
|
|
179
245
|
into shaderMaterial sources with plain template literals - `LIT_VERTEX`
|
|
@@ -197,10 +263,30 @@ system.
|
|
|
197
263
|
and show mesh interiors.
|
|
198
264
|
- `visible: false` keeps the entry, drawn with `instanceCount: 0` (a
|
|
199
265
|
cheap off switch). Hidden meshes skip uModel writes; the fresh matrix is
|
|
200
|
-
written on unhide.
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
266
|
+
written on unhide. A freshly attached entry starts off the same way and
|
|
267
|
+
sync() turns it on when it writes uModel - never add one live: it has no
|
|
268
|
+
world matrix yet, and drawn before the sync microtask it flashes at the
|
|
269
|
+
world origin for a frame.
|
|
270
|
+
- Transparency is an EXPLICIT material flag, Three's rule: `unlit({ color:
|
|
271
|
+
[r, g, b, 0.5] })` still draws opaque; `unlit({ ..., transparent: true })`
|
|
272
|
+
(or `shaderMaterial({ transparent: true })`) builds the pipeline with
|
|
273
|
+
`blend: "alpha"` and `depthWrite: false` (depth test stays on, so it hides
|
|
274
|
+
behind opaques without occluding other translucents). The one inference:
|
|
275
|
+
a `shaderMaterial` with any `blend` but "none" is transparent unless told
|
|
276
|
+
`transparent: false` - every blended draw belongs after the opaques, and
|
|
277
|
+
back-to-front is harmless for add/multiply. The scene owns the
|
|
278
|
+
order: background, opaque meshes by `renderOrder` then add order,
|
|
279
|
+
transparent meshes by `renderOrder` then back-to-front by the CENTER of
|
|
280
|
+
the mesh's world bounds in view space (not the origin: off-origin geometry
|
|
281
|
+
sorts by where it is; not the nearest bounds point: a big translucent
|
|
282
|
+
ground plane would cover the small translucents on it) - one `setDrawOrder` from sync() whenever the list changed, a
|
|
283
|
+
renderOrder changed, or (with two or more transparent meshes) the camera
|
|
284
|
+
or a transparent mesh moved, and skipped when the resort lands on the
|
|
285
|
+
permutation already issued. Per-mesh sort only: one non-convex translucent
|
|
286
|
+
mesh still overlaps itself in vertex order, and two large interpenetrating
|
|
287
|
+
translucents can sort wrong (center distance, not per-pixel) - that is the
|
|
288
|
+
engine contract, no OIT. A `shaderMaterial({ transparent: true })`
|
|
289
|
+
fragment must write PREMULTIPLIED output (`vec4(rgb * a, a)`).
|
|
204
290
|
- Rotation is stored as a QUATERNION (`node.quaternion`, `[x, y, z, w]`,
|
|
205
291
|
always unit). There is exactly one rotation field: no `node.rotation`
|
|
206
292
|
shadowing it, because a second field is a second thing to go stale (an
|
|
@@ -277,9 +363,36 @@ system.
|
|
|
277
363
|
camera writes (uEye-style per-mesh params are exactly the O(scene) cost
|
|
278
364
|
the shared channel removed). Scene scale honestly: hundreds to a
|
|
279
365
|
few thousand objects, bounded by the interpreter, not the GPU.
|
|
366
|
+
- SCENE-WIDE uniforms go through that same shared channel via
|
|
367
|
+
`scene.setParams({ uTime })`, and this is the single highest-leverage
|
|
368
|
+
pattern in the library. It merges an app-owned name in beside
|
|
369
|
+
uViewProj/uCamPos/uCamRight/uCamUp - names merge, a target tolerates
|
|
370
|
+
zero coverage, neither side clobbers the other. One write per frame
|
|
371
|
+
however many meshes read it, with the motion itself in vertex shaders
|
|
372
|
+
off that one clock. `params`/`setMeshParams` is the PER-MESH answer and
|
|
373
|
+
is O(meshes) per frame; reach for it only when the value genuinely
|
|
374
|
+
differs per mesh. (`scene.texture` IS the draw target id, so
|
|
375
|
+
`setTargetParams(scene.texture, ...)` is the same write - setParams is
|
|
376
|
+
the sanctioned spelling.)
|
|
377
|
+
- Vec3/Quat arguments are COPIED IN everywhere (`setTransform`, `lookAt`,
|
|
378
|
+
`setCamera`, params), so ONE scratch array reused every frame is safe -
|
|
379
|
+
allocating three arrays per node per frame is pure waste. The node's own
|
|
380
|
+
`position`/`quaternion`/`scale` are the live arrays: read them, do not
|
|
381
|
+
hand them out and do not mutate them (that write does not sync).
|
|
382
|
+
- `setTransform` early-outs on an unchanged value (rotation compared AFTER
|
|
383
|
+
euler conversion), so driving every node unconditionally from `onFrame`
|
|
384
|
+
costs only the compare for nodes that did not move. Compares are exact,
|
|
385
|
+
like `setVisible`.
|
|
386
|
+
- Per-generator conventions - orientation, UV mapping, which axis a solid
|
|
387
|
+
stands on, what a cap looks like - live on each generator's doc comment,
|
|
388
|
+
not here. They are consistent (`plane`/`circle`/`ring` face +z, `torus`
|
|
389
|
+
lies flat with the hole on y, discs and cylinder caps get a PLANAR disc
|
|
390
|
+
map inscribed in the unit square) but the doc comment is the source.
|
|
280
391
|
- Entry rebuild order: `setGeometry`/`setMaterial` re-add the entry at the
|
|
281
|
-
list END
|
|
282
|
-
|
|
392
|
+
list END and dirty the order, so the next sync() re-sorts and the mesh
|
|
393
|
+
keeps its place. `_transparent` on the mesh is the flag AS ATTACHED
|
|
394
|
+
(setMaterial swaps `mesh.material` before the rebuild, so _detach must
|
|
395
|
+
not read the new material's flag).
|
|
283
396
|
- `lathe` takes a CLOSED profile (a cross-section with thickness, or run
|
|
284
397
|
to the axis at x = 0) - it is a solid of revolution, NOT Three's open
|
|
285
398
|
polyline shell. An "open" outline must be closed by the author;
|
|
@@ -290,7 +403,14 @@ system.
|
|
|
290
403
|
compile twice - no dedupe by source value (deliberate; hidden
|
|
291
404
|
content-keyed caches are the anti-pattern the GPU layer avoids). Create
|
|
292
405
|
one per look at app scope, share across meshes, `dispose()` when done
|
|
293
|
-
for good.
|
|
406
|
+
for good. Looks that differ only in params/textures are ONE
|
|
407
|
+
`shaderMaterialClass` and many `instance()`s - the app-owned split, not
|
|
408
|
+
a cache. A class instance has no `dispose` of its own; disposing the
|
|
409
|
+
class invalidates every instance.
|
|
410
|
+
- A parameterised class whose variants (mapped/unmapped, ...) are SEPARATE
|
|
411
|
+
classes must have every variant reference every shared uniform it is
|
|
412
|
+
seeded with: a declared-but-unused per-entry name compiles out and
|
|
413
|
+
throws at add(). Open item: `okf/backlog/gpu-inactive-uniform-two-tier.md`.
|
|
294
414
|
- The standard-set contract is checked TEXTUALLY at shaderMaterial()
|
|
295
415
|
creation (uModel and uViewProj must appear in the vertex source) and
|
|
296
416
|
strictly at add() for the per-entry names: a uModel or uNormal that is
|
|
@@ -304,3 +424,32 @@ system.
|
|
|
304
424
|
vertex source - a comment counts - selects the "colored" layout, and
|
|
305
425
|
the material then rejects standard geometry at add(). Do not mention
|
|
306
426
|
aColor you do not read.
|
|
427
|
+
- Picking is the VOLUME tier: a hit means the ray crossed the mesh's
|
|
428
|
+
transformed bounding box, not its surface. Never present `point` as a
|
|
429
|
+
surface point (it is the box-entry point), and never add a
|
|
430
|
+
triangle-accurate path in JS - per-triangle rays at mesh scale are
|
|
431
|
+
interpreter-hostile; that tier is core work (BVH descent per the
|
|
432
|
+
differentiators ladder).
|
|
433
|
+
- `scene.handlers` vs `handlersFor`: localX/localY arrive in the leaf's
|
|
434
|
+
LAYOUT frame (every ancestor transform and viewBox fit is already
|
|
435
|
+
undone by the element hit test). `handlers` therefore assumes leaf
|
|
436
|
+
layout == target pixels; scaling by `getBoundingBox` would be WRONG -
|
|
437
|
+
the box composes transforms, and it would double-correct the built-in
|
|
438
|
+
leaf under a viewBox. Only a leaf whose layout size deliberately
|
|
439
|
+
differs from the target (supersampling) needs `handlersFor`, fed the
|
|
440
|
+
layout size the app itself set.
|
|
441
|
+
- Hover (enter/leave) reacts to pointer MOTION only: a mesh animating
|
|
442
|
+
under a still pointer fires nothing until the next move - the same
|
|
443
|
+
limit the element hit test has (hit-test-per-frame is an open platform
|
|
444
|
+
item). Do not poll pick() per frame to fake it.
|
|
445
|
+
- Geometry local bounds cache on the Geometry (like its GPU buffers):
|
|
446
|
+
geometry is immutable after creation. Mutating `vertices` after a mesh
|
|
447
|
+
used them leaves stale bounds AND a stale GPU buffer - make a new
|
|
448
|
+
Geometry instead.
|
|
449
|
+
- The background covers the whole target with depth off, drawn first: it
|
|
450
|
+
REPLACES the clearColor visually (the clear still runs; you just never
|
|
451
|
+
see it), and a `transparent: true` mesh blends over it in-pass since the
|
|
452
|
+
background is always entry zero.
|
|
453
|
+
- The background pipeline/program are SCENE-OWNED (unlike shared
|
|
454
|
+
material pipelines): setBackground(null), replacement, and dispose()
|
|
455
|
+
destroy them. Do not hand the background's pipeline to anything else.
|
package/README.md
CHANGED
|
@@ -51,10 +51,23 @@ Three's `Euler` default, and `getRotation(node)` reads one back. The
|
|
|
51
51
|
verbs: `quatFromAxisAngle`, `quatMultiply`, and `quatSlerp` (smooth
|
|
52
52
|
tracking, damped follows) round out `quatFromTo`; `examples/aim.tsx`
|
|
53
53
|
shows each aiming style live.
|
|
54
|
+
|
|
55
|
+
Meshes take pointer events like elements do: `onPointerDown/Move/Up/
|
|
56
|
+
Enter/Leave` props on `<Mesh>` and `<Group>`, with bubbling, capture on
|
|
57
|
+
drag, and hover enter/leave pairs - hit testing runs over a BVH the
|
|
58
|
+
scene maintains incrementally, so events put no ceiling on scene size.
|
|
59
|
+
Underneath sit `scene.pick(x, y)` (the camera ray through a pixel,
|
|
60
|
+
`project()`'s inverse) and `scene.raycast(origin, direction)`; hits are
|
|
61
|
+
bounding-box accurate in v1. A scene also takes a `background` - fragment
|
|
62
|
+
GLSL drawn inside its own pass behind the meshes, replacing the stacked
|
|
63
|
+
backdrop-texture pattern.
|
|
54
64
|
Custom materials get a standard uniform set - per-mesh `uModel`/`uNormal`,
|
|
55
|
-
shared `uViewProj`/`uCamPos`, each written once per
|
|
56
|
-
|
|
57
|
-
|
|
65
|
+
shared `uViewProj`/`uCamPos`/`uCamRight`/`uCamUp`, each written once per
|
|
66
|
+
change - plus your own uniforms: scene-wide via `scene.setParams` (one write
|
|
67
|
+
however many meshes read it), or per mesh, declaratively via the `params`
|
|
68
|
+
prop on `<Mesh>` or imperatively via `setMeshParams`. `shaderMaterialClass`
|
|
69
|
+
compiles one program and hands out `instance()` materials that differ only
|
|
70
|
+
in params/textures. And
|
|
58
71
|
`@solidrt/3d/glsl` exports the lighting pieces (hemisphere, lambert,
|
|
59
72
|
blinn, fresnel, a standard vertex stage) to compose your own lit looks
|
|
60
73
|
from plain template literals.
|
|
@@ -67,8 +80,9 @@ mitred joints, flat `shape`, with `fillet`/`roundRect`/`triangulate`
|
|
|
67
80
|
helpers), a per-vertex data channel
|
|
68
81
|
(`withColors` adds an `aColor` vec4 - tint, baked AO, any four scalars -
|
|
69
82
|
to any geometry, for materials that read it), one perspective camera
|
|
70
|
-
with an orbit control (`createOrbitCamera`: drag, pinch/wheel zoom, auto-orbit)
|
|
71
|
-
|
|
83
|
+
with an orbit control (`createOrbitCamera`: drag, pinch/wheel zoom, auto-orbit),
|
|
84
|
+
mesh picking with pointer events, and scene backgrounds.
|
|
85
|
+
Lights, transparency and model loading are
|
|
72
86
|
staged next - see `okf/research/scene-graph-3d.md` for the roadmap. Full
|
|
73
87
|
usage notes and traps: [AGENTS.md](AGENTS.md); runnable examples:
|
|
74
88
|
[examples/](examples/).
|
package/examples/README.md
CHANGED
|
@@ -15,3 +15,11 @@ depends on `@solidrt/3d` (or in-repo from the package directory).
|
|
|
15
15
|
target: `lookAt` for a +z solid, `quatFromTo` for aiming a y-axis cone,
|
|
16
16
|
and a `quatSlerp` damped follow that visibly lags; all driven from
|
|
17
17
|
onFrame through refs, no per-frame signals.
|
|
18
|
+
- `pick.tsx` - mesh pointer events: hover tints (enter/leave), click
|
|
19
|
+
pops (down), a Group hearing its children's clicks through bubbling
|
|
20
|
+
and one mesh stopping the walk; a STATIC scene rendered only when an
|
|
21
|
+
event changes something, with hit testing over the scene's BVH.
|
|
22
|
+
- `scene-background.tsx` - a fragment-GLSL background drawn inside the
|
|
23
|
+
scene's own pass (`<Scene background>`): one target, no stacked
|
|
24
|
+
backdrop texture, no resize plumbing; the source is shader-target
|
|
25
|
+
compatible verbatim.
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Mesh pointer events: hover to tint (enter/leave), click to pop (down),
|
|
2
|
+
// with a Group seeing its children's clicks through bubbling and one mesh
|
|
3
|
+
// stopping the walk. The scene is STATIC - no onFrame - so it renders only
|
|
4
|
+
// when an event changes something: picking is demand-driven like
|
|
5
|
+
// everything else. Events flow because the built-in <Scene> leaf carries
|
|
6
|
+
// scene.handlers (default on); hit testing runs over the scene's BVH, so a
|
|
7
|
+
// pointer move costs O(log meshes).
|
|
8
|
+
//
|
|
9
|
+
// The volume tier is honest about its shape: hits test each mesh's
|
|
10
|
+
// bounding box, so hovering just outside the ball's silhouette (inside its
|
|
11
|
+
// box corners) still counts. Triangle-accurate hits are a later tier.
|
|
12
|
+
|
|
13
|
+
import { createSignal, pct, render } from "@solidrt/core"
|
|
14
|
+
import { box, cone, Group, Mesh, PerspectiveCamera, plane, Scene, setMeshParams, setTransform, sphere, torus, unlit } from "@solidrt/3d"
|
|
15
|
+
import type { Geometry, ScenePointerEvent, Vec3 } from "@solidrt/3d"
|
|
16
|
+
|
|
17
|
+
const SIZE = 720
|
|
18
|
+
|
|
19
|
+
type Color = [number, number, number]
|
|
20
|
+
|
|
21
|
+
let [hovered, setHovered] = createSignal("nothing")
|
|
22
|
+
let popped = new Set<object>()
|
|
23
|
+
|
|
24
|
+
/** A pickable mesh: unlit color that brightens on hover, scale-pop on
|
|
25
|
+
* click. setMeshParams writes raw uniform values, so the tint premultiplies
|
|
26
|
+
* here (alpha 1: the rgb passes through). */
|
|
27
|
+
function Pickable(p: { name: string; color: Color; geometry: Geometry; position: Vec3; scale?: number; stop?: boolean }) {
|
|
28
|
+
let lift = (up: number): [number, number, number, number] => [
|
|
29
|
+
Math.min(p.color[0] + up, 1),
|
|
30
|
+
Math.min(p.color[1] + up, 1),
|
|
31
|
+
Math.min(p.color[2] + up, 1),
|
|
32
|
+
1,
|
|
33
|
+
]
|
|
34
|
+
return (
|
|
35
|
+
<Mesh
|
|
36
|
+
geometry={p.geometry}
|
|
37
|
+
material={unlit({ color: p.color })}
|
|
38
|
+
position={p.position}
|
|
39
|
+
scale={p.scale}
|
|
40
|
+
onPointerEnter={(e: ScenePointerEvent) => {
|
|
41
|
+
setHovered(p.name)
|
|
42
|
+
setMeshParams(e.mesh, { uColor: lift(0.25) })
|
|
43
|
+
console.log(`enter ${p.name}`)
|
|
44
|
+
}}
|
|
45
|
+
onPointerLeave={(e: ScenePointerEvent) => {
|
|
46
|
+
setHovered("nothing")
|
|
47
|
+
setMeshParams(e.mesh, { uColor: lift(0) })
|
|
48
|
+
console.log(`leave ${p.name}`)
|
|
49
|
+
}}
|
|
50
|
+
onPointerDown={(e: ScenePointerEvent) => {
|
|
51
|
+
let pop = !popped.has(e.mesh)
|
|
52
|
+
if (pop) popped.add(e.mesh)
|
|
53
|
+
else popped.delete(e.mesh)
|
|
54
|
+
setTransform(e.mesh, { scale: pop ? (p.scale ?? 1) * 1.25 : (p.scale ?? 1) })
|
|
55
|
+
let pt = e.point!
|
|
56
|
+
console.log(`down ${p.name} at ${pt[0].toFixed(2)},${pt[1].toFixed(2)},${pt[2].toFixed(2)} d=${e.distance!.toFixed(2)}`)
|
|
57
|
+
// The cone demonstrates stopping the bubble: its group never hears it.
|
|
58
|
+
if (p.stop) e.stopPropagation()
|
|
59
|
+
}}
|
|
60
|
+
/>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function App() {
|
|
65
|
+
let floor = plane(8, 8, "floor")
|
|
66
|
+
let crate = box()
|
|
67
|
+
let ball = sphere(0.45)
|
|
68
|
+
let spike = cone(0.45, 1)
|
|
69
|
+
let ring = torus(0.4, 0.14)
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<window>
|
|
73
|
+
<view width={pct(100)} height={pct(100)} viewBox={[SIZE, SIZE]}>
|
|
74
|
+
<Scene width={SIZE} height={SIZE} clearColor={[0.07, 0.07, 0.1, 1]} label="pick">
|
|
75
|
+
<PerspectiveCamera fov={55} position={[0, 2.6, 5]} lookAt={[0, 0.4, 0]} />
|
|
76
|
+
<Mesh geometry={floor} material={unlit({ color: [0.15, 0.16, 0.2] })} rotation={[-Math.PI / 2, 0, 0]} />
|
|
77
|
+
{/* The group hears every child click that is not stopped. */}
|
|
78
|
+
<Group onPointerDown={() => console.log("group saw down")}>
|
|
79
|
+
<Pickable name="crate" color={[0.85, 0.3, 0.3]} geometry={crate} position={[-1.6, 0.4, 0]} scale={0.8} />
|
|
80
|
+
<Pickable name="cone" color={[0.35, 0.65, 0.9]} geometry={spike} position={[1.6, 0.5, 0]} stop />
|
|
81
|
+
</Group>
|
|
82
|
+
<Pickable name="ball" color={[0.9, 0.8, 0.35]} geometry={ball} position={[0, 0.45, 0]} />
|
|
83
|
+
<Pickable name="ring" color={[0.5, 0.85, 0.5]} geometry={ring} position={[0.9, 0.35, -1.6]} />
|
|
84
|
+
</Scene>
|
|
85
|
+
<view position="absolute" x={0} y={0} padding={16} gap={4}>
|
|
86
|
+
<text color="#eef4ff" fontSize={22} fontWeight={700}>
|
|
87
|
+
{`hover: ${hovered()}`}
|
|
88
|
+
</text>
|
|
89
|
+
<text color="#8fa6c8" fontSize={13}>
|
|
90
|
+
hover tints, click pops - a static scene, rendered only on change
|
|
91
|
+
</text>
|
|
92
|
+
</view>
|
|
93
|
+
</view>
|
|
94
|
+
</window>
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
render(() => <App />)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// A scene background: fragment GLSL drawn as the first entry of the
|
|
2
|
+
// scene's own pass - no second target, no stacked texture layers, no
|
|
3
|
+
// resize plumbing. The source below uses only the shader-target contract
|
|
4
|
+
// (vUV, iResolution, fragColor), so it would work unchanged in
|
|
5
|
+
// createShaderTexture; here it costs nothing extra - the one scene pass
|
|
6
|
+
// paints backdrop and meshes together, and a static scene still renders
|
|
7
|
+
// zero passes when idle.
|
|
8
|
+
|
|
9
|
+
import { pct, render } from "@solidrt/core"
|
|
10
|
+
import { glsl } from "@solidrt/core/gpu"
|
|
11
|
+
import { box, Mesh, PerspectiveCamera, Scene, sphere, torusKnot, unlit } from "@solidrt/3d"
|
|
12
|
+
|
|
13
|
+
const SIZE = 720
|
|
14
|
+
|
|
15
|
+
// A radial night-sky gradient with hash grain so the ramp does not band.
|
|
16
|
+
let BACKDROP = glsl`
|
|
17
|
+
void main() {
|
|
18
|
+
float d = distance(vUV, vec2(0.5, 0.35));
|
|
19
|
+
vec3 near = vec3(0.10, 0.13, 0.22);
|
|
20
|
+
vec3 far = vec3(0.02, 0.03, 0.06);
|
|
21
|
+
vec3 col = mix(near, far, smoothstep(0.05, 0.9, d));
|
|
22
|
+
float n = fract(sin(dot(vUV * iResolution, vec2(12.9898, 78.233))) * 43758.5453);
|
|
23
|
+
col += (n - 0.5) * 0.012;
|
|
24
|
+
fragColor = vec4(col, 1.0);
|
|
25
|
+
}
|
|
26
|
+
`
|
|
27
|
+
|
|
28
|
+
function App() {
|
|
29
|
+
return (
|
|
30
|
+
<window>
|
|
31
|
+
<view width={pct(100)} height={pct(100)} viewBox={[SIZE, SIZE]}>
|
|
32
|
+
<Scene width={SIZE} height={SIZE} background={BACKDROP} label="backdrop-demo">
|
|
33
|
+
<PerspectiveCamera fov={55} position={[0, 1.8, 4.4]} lookAt={[0, 0.4, 0]} />
|
|
34
|
+
<Mesh geometry={torusKnot(0.7, 0.2, 128, 16)} material={unlit({ color: [0.85, 0.55, 0.25] })} position={[0, 0.9, 0]} />
|
|
35
|
+
<Mesh geometry={box()} material={unlit({ color: [0.3, 0.5, 0.8] })} position={[-1.5, 0.4, -0.5]} scale={0.8} />
|
|
36
|
+
<Mesh geometry={sphere(0.4)} material={unlit({ color: [0.4, 0.75, 0.45] })} position={[1.5, 0.4, -0.5]} />
|
|
37
|
+
</Scene>
|
|
38
|
+
</view>
|
|
39
|
+
</window>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
render(() => <App />)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@solidrt/3d",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.50",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"author": "Antoine van Wel",
|
|
6
6
|
"type": "module",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"AGENTS.md"
|
|
17
17
|
],
|
|
18
18
|
"peerDependencies": {
|
|
19
|
-
"@solidjs/signals": "2.0.0-
|
|
20
|
-
"@solidrt/core": "0.0.
|
|
19
|
+
"@solidjs/signals": "2.0.0-rc.0",
|
|
20
|
+
"@solidrt/core": "0.0.50"
|
|
21
21
|
}
|
|
22
22
|
}
|