@solidrt/3d 0.0.46 → 0.0.48
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 +235 -43
- package/README.md +37 -5
- package/examples/README.md +8 -0
- package/examples/aim.tsx +119 -0
- package/examples/scene-post-effect.tsx +82 -0
- package/examples/sweep-paths.tsx +79 -0
- package/package.json +4 -3
- package/src/components.tsx +42 -9
- package/src/geometry.ts +298 -37
- package/src/glsl.ts +112 -0
- package/src/index.ts +11 -5
- package/src/material.ts +43 -6
- package/src/math.ts +334 -19
- package/src/orbit.ts +187 -27
- package/src/profile.ts +315 -0
- package/src/scene.ts +224 -46
- package/src/sweep.ts +460 -0
package/AGENTS.md
CHANGED
|
@@ -11,10 +11,11 @@ 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
|
-
`setVisible` - plain objects with dirty flags, batched to a
|
|
15
|
-
|
|
16
|
-
`
|
|
17
|
-
|
|
14
|
+
`lookAt`, `getRotation`, `setVisible` - plain objects with dirty flags, batched to a
|
|
15
|
+
microtask,
|
|
16
|
+
one `setDrawParams` (uModel, plus uNormal for materials declaring it)
|
|
17
|
+
per changed mesh and ONE `setTargetParams` (the shared uViewProj +
|
|
18
|
+
uCamPos) per camera change, however many meshes. The component
|
|
18
19
|
face (`Scene`/`Group`/`Mesh`/`PerspectiveCamera`) syncs props into that
|
|
19
20
|
core over context and renders nothing itself.
|
|
20
21
|
- Rendering is the runtime's. The target is `render: "auto"`: it
|
|
@@ -23,9 +24,23 @@ blendMode and pointer events like any element. Design rationale:
|
|
|
23
24
|
own `onFrame` writing a signal (declarative) or `setTransform` on a
|
|
24
25
|
`ref`-grabbed node (the frame-rate escape hatch - signals carry
|
|
25
26
|
structure, per-frame motion goes straight to the scene).
|
|
26
|
-
-
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
- Two named vertex layouts (`Geometry.layout`, absent = "standard"):
|
|
28
|
+
"standard" is `aPos` vec3 + `aNormal` vec3 + `aUV` vec2 - what every
|
|
29
|
+
generator emits - and "colored" appends `aColor` vec4, the per-vertex
|
|
30
|
+
data channel (a tint, baked AO, any four scalars; standard name, your
|
|
31
|
+
contents). Derive colored geometry with `withColors(geometry, fill)` -
|
|
32
|
+
fill is a flat 4-per-vertex array or a per-vertex callback receiving
|
|
33
|
+
`(index, pos, normal, uv)`. Geometry and material layouts must match
|
|
34
|
+
(layout is stride); a mismatched pair throws at add(). The whole layout
|
|
35
|
+
ships whether a material reads every attribute or not (inactive
|
|
36
|
+
attributes only keep the stride), so colored vertices cost 12 floats
|
|
37
|
+
regardless - keep data-light passes (a wireframe reading only aPos) on
|
|
38
|
+
standard geometry.
|
|
39
|
+
Indices are uint16 or uint32 - the `Geometry.indices` array type picks
|
|
40
|
+
the draw's index format, so hand-built geometry past 64k vertices just
|
|
41
|
+
uses a Uint32Array (generators emit uint16). Geometry GPU buffers are
|
|
42
|
+
lazy, shared, and app-lifetime (owner-scoped free would break sharing);
|
|
43
|
+
`disposeGeometry` frees them.
|
|
29
44
|
- Materials dedupe hard: one program + one pipeline per material CLASS
|
|
30
45
|
(unlit color, unlit map), `depth: true` + `cull: "back"`; an instance is
|
|
31
46
|
just per-entry uniforms (`uColor`) and bindings (`uMap`).
|
|
@@ -34,41 +49,144 @@ blendMode and pointer events like any element. Design rationale:
|
|
|
34
49
|
|
|
35
50
|
| Component | Props |
|
|
36
51
|
| --- | --- |
|
|
37
|
-
| `Scene` | `width`, `height` (target pixels), `clearColor?`, `label?`, `ref?(scene)` |
|
|
38
|
-
| `Group` | `position?`, `rotation?` (Euler radians,
|
|
39
|
-
| `Mesh` | `geometry`, `material`, transforms as Group, `ref?(mesh)` |
|
|
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)` |
|
|
40
55
|
| `PerspectiveCamera` | `fov?` (vertical DEGREES, default 60), `near?`, `far?`, `position?`, `lookAt?`, `up?` |
|
|
41
56
|
|
|
57
|
+
Output composition: without `output`, `Scene` emits a minimal
|
|
58
|
+
`<texture width height>` leaf and nothing else is forwarded - anything
|
|
59
|
+
more goes through `output(texture)`, which renders in place of that leaf:
|
|
60
|
+
a `<d-texture>`, a leaf with blendMode/fit/pointer/layout props, or a
|
|
61
|
+
post-effect chain (`createShaderTarget` sampling the id with a
|
|
62
|
+
covering-triangle pass; created in the callback it disposes with the
|
|
63
|
+
Scene). Return null for no leaf at all and compose `scene.texture`
|
|
64
|
+
elsewhere. Called once, untracked, inside the scene context. Scene
|
|
65
|
+
`width`/`height` are target pixels and the leaf's own width/height are
|
|
66
|
+
layout, so render and display size separate - render at 2x and display
|
|
67
|
+
smaller for supersampling.
|
|
68
|
+
|
|
42
69
|
Camera control: `createOrbitCamera(scene, { target?, azimuth?, elevation?,
|
|
43
70
|
distance?, min/maxDistance?, min/maxElevation?, orbitSpeed?, rotateSpeed?,
|
|
44
|
-
zoomSpeed
|
|
71
|
+
zoomSpeed?, zoomAnchor?, rotateAnchor?, panSpeed?, viewport?, clampTarget? })`
|
|
72
|
+
- drag-to-rotate, pinch- and wheel-to-zoom, two-finger pan, optional
|
|
73
|
+
auto-orbit. Input runs on core's `createTransform` recognizer, so drag and
|
|
74
|
+
pinch arbitrate in the app-wide gesture arena (a viewport inside a scroller
|
|
75
|
+
does not double-handle) and rotation starts after the recognizer's slop;
|
|
76
|
+
`zoomSpeed` weights both wheel and pinch. Two-finger translation pans (the
|
|
77
|
+
scene tracks the fingers 1:1 at target depth, weighted by `panSpeed`) when
|
|
78
|
+
`viewport()` supplies `{ height, fov }` for the pixel-to-world mapping -
|
|
79
|
+
without it, it rotates like one finger; `clampTarget(target)` bounds where
|
|
80
|
+
a pan may put the pivot. Zoom aims
|
|
81
|
+
at the target unless `zoomAnchor(x, y, {eye, target})` maps the pinch focal
|
|
82
|
+
/ wheel cursor to a world point (ground hit, target-depth plane, ...) - then
|
|
83
|
+
that point stays pinned under the pointer and the target slides toward it;
|
|
84
|
+
only the app can build that mapping, since fov, aspect and element placement
|
|
85
|
+
are app state. Pair it with `rotateAnchor({eye, target})`: called at gesture
|
|
86
|
+
start, its point is projected onto the view axis and re-seats the pivot
|
|
87
|
+
without moving the picture, so a drag after an anchored zoom orbits what the
|
|
88
|
+
camera looks at, not wherever the zoom left the target. Spread
|
|
45
89
|
`orbit.handlers` onto the input-owning element, call `orbit.update(dt)`
|
|
46
90
|
from your onFrame (no frame loop of its own), and use its return - true
|
|
47
|
-
when the pose changed - to gate per-frame dependents like
|
|
48
|
-
|
|
91
|
+
when the pose changed - to gate per-frame dependents like reprojecting
|
|
92
|
+
HUD overlays. `orbiting()` is reactive (HUD-safe); the pose is plain state via
|
|
49
93
|
`pose()`/`set()` (also the debug-command shape). It drives position and
|
|
50
94
|
target only; fov/near/far stay on scene.setCamera. In a component tree,
|
|
51
95
|
reach the scene via `<Scene ref>` or useScene().
|
|
52
96
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
97
|
+
Overlay projection: `scene.project(point)` maps a world point to scene
|
|
98
|
+
pixels (top-left origin, y down - the output texture's own space; `w` is
|
|
99
|
+
clip-space w, the camera-forward distance) and returns null for a point
|
|
100
|
+
at or behind the camera plane. It reflects a pending `setCamera`
|
|
101
|
+
immediately, so set-then-project in one tick is exact. `scene.viewProj(out?)`
|
|
102
|
+
copies the view-projection matrix for batch work. Never rebuild the
|
|
103
|
+
camera matrices by hand for a HUD.
|
|
104
|
+
|
|
105
|
+
Geometry: `box(w?, h?, d?)`; `plane(w?, h?)`, `circle(radius?, seg?)` and
|
|
106
|
+
`ring(inner?, outer?, seg?)` (XY, facing +z - rotate `[-Math.PI/2, 0, 0]`
|
|
107
|
+
for a floor); `sphere(radius?, wSeg?, hSeg?)`;
|
|
108
|
+
`cylinder(rTop?, rBottom?, height?, radialSeg?)` (y axis, capped; unequal
|
|
109
|
+
radii taper it) and `cone(radius?, height?, radialSeg?)`;
|
|
110
|
+
`torus(radius?, tube?, radialSeg?, tubularSeg?)` (lying flat, hole on the
|
|
111
|
+
y axis) and `torusKnot(radius?, tube?, tubularSeg?, radialSeg?, p?, q?)`
|
|
112
|
+
(standing y-up) - both oriented for the y-up world, unlike Three's z-up.
|
|
113
|
+
`withColors(geometry, fill, label?)` derives a "colored"-layout copy of
|
|
114
|
+
any standard-layout geometry (generator or hand-built), adding the
|
|
115
|
+
`aColor` vec4 channel; the source is untouched.
|
|
116
|
+
`fillColors(vertices, fill, first?, count?)` is the in-place primitive
|
|
117
|
+
under it: writes the aColor slots of a colored-layout interleave you
|
|
118
|
+
already own (a merging builder's packed buffer), reading pos/normal/uv
|
|
119
|
+
from the buffer itself - so a packer that bakes transforms while writing
|
|
120
|
+
hands the baker world-space vertices. `fill` indexes relative to
|
|
121
|
+
`first`. It trusts the buffer's layout (no tag to check); withColors is
|
|
122
|
+
the checked path.
|
|
123
|
+
|
|
124
|
+
Profile kit (2D outlines to solids, real texture UVs): a `Profile` is a
|
|
125
|
+
closed XY polygon, bare `[x, y]` points crease, `{ p, smooth }` points
|
|
126
|
+
share an averaged normal - `fillet(points, radius, segs?)` and
|
|
127
|
+
`roundRect(w?, h?, radius?, segs?)` emit those (arc corners smooth).
|
|
128
|
+
Winding is normalized, so either authoring direction works.
|
|
129
|
+
`extrude(profile, depth?, bevel?, bevelSegs?)` sweeps along z, centered,
|
|
130
|
+
with a quarter-round bevel at both rims; `lathe(profile, segs?, angle?,
|
|
131
|
+
start?)` revolves a CLOSED (x = radius, y = height) profile about the y
|
|
132
|
+
axis - watertight by construction, flat caps on partial sweeps;
|
|
133
|
+
`sweep(profile, path)` runs the profile along an open 3D polyline with
|
|
134
|
+
MITRED joints (each cross-section sits on its bend's bisector plane, so
|
|
135
|
+
bends never gape or overlap) and flat caps at both ends. The path
|
|
136
|
+
mirrors the profile convention: bare `[x, y, z]` points crease (a strap
|
|
137
|
+
folding over an edge), `{ p, smooth }` points shade continuous (tag a
|
|
138
|
+
sampled curve's points); the profile's y starts as close to world up as
|
|
139
|
+
the first segment allows, then parallel-transports without spinning.
|
|
140
|
+
Closed loops are NOT supported yet - overlap the ends by a segment to
|
|
141
|
+
fake one. `tube(path, radius?, radialSegs?)` is the round-profile
|
|
142
|
+
shorthand (wire, rope, pipe), and `pathFrames(path)` exports the
|
|
143
|
+
per-segment frames (tangents, cross-section axes, arc lengths) for
|
|
144
|
+
custom work along a path. `shape(profile)` fills one flat (facing +z,
|
|
145
|
+
like circle); `triangulate(points)` is the ear-clipping core (fan
|
|
146
|
+
fallback, never drops a cap), exported for custom flat work. These pick
|
|
147
|
+
uint16/uint32 indices by vertex count automatically.
|
|
148
|
+
|
|
57
149
|
Materials:
|
|
58
150
|
|
|
59
151
|
- `unlit({ color?, map? })` - straight `[r, g, b, a?]` 0..1, premultiplied
|
|
60
152
|
internally.
|
|
61
153
|
- `shaderMaterial({ vertex, fragment, params?, textures?, depth?,
|
|
62
154
|
depthWrite?, blend?, cull?, topology?, label? })` - your own GLSL, the
|
|
63
|
-
custom-look escape hatch. The
|
|
64
|
-
`uniform mat4 uModel` (the mesh's world matrix,
|
|
65
|
-
`uniform mat4 uViewProj` (the camera, shared
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
155
|
+
custom-look escape hatch. The STANDARD UNIFORM SET: the vertex stage
|
|
156
|
+
MUST declare and use `uniform mat4 uModel` (the mesh's world matrix,
|
|
157
|
+
per entry) and `uniform mat4 uViewProj` (the camera, shared
|
|
158
|
+
target-level params) - transform with
|
|
159
|
+
`uViewProj * uModel * vec4(aPos, 1.0)`; a source missing either throws
|
|
160
|
+
at shaderMaterial() creation. The rest is opt-in by declare-and-use:
|
|
161
|
+
`uniform vec3 uCamPos` (the camera's world position, shared and written
|
|
162
|
+
with uViewProj - the specular/fresnel view vector is
|
|
163
|
+
`normalize(uCamPos - worldPos)`) and `uniform mat4 uNormal` (the world
|
|
164
|
+
inverse-transpose, written beside uModel for this material's meshes;
|
|
165
|
+
take `mat3(uNormal)` - correct under non-uniform scale, where
|
|
166
|
+
mat3(uModel) bends normals off the surface). Attributes come from the
|
|
167
|
+
geometry's layout by name; a vertex stage reading `in vec4 aColor` opts
|
|
168
|
+
the material into the "colored" layout, and its meshes then need
|
|
169
|
+
`withColors()` geometry. Sources without `#version` get the standard
|
|
170
|
+
pipeline preamble. App-driven uniforms beyond the standard set: seed
|
|
171
|
+
via `params`, then write per mesh with
|
|
70
172
|
`setMeshParams(mesh, { name: value })` (validated names; values persist
|
|
71
|
-
across entry rebuilds; frame-rate-safe like setTransform)
|
|
173
|
+
across entry rebuilds; frame-rate-safe like setTransform) or declaratively
|
|
174
|
+
with the `Mesh` `params` prop (same merge semantics - a key that
|
|
175
|
+
disappears from the object keeps its old value; for per-frame values
|
|
176
|
+
prefer `ref` + setMeshParams from onFrame, the setTransform split).
|
|
177
|
+
|
|
178
|
+
Lighting GLSL (`@solidrt/3d/glsl`): exported string constants composed
|
|
179
|
+
into shaderMaterial sources with plain template literals - `LIT_VERTEX`
|
|
180
|
+
(the standard vertex stage: clip position plus vWorldPos/vNormal/vUv
|
|
181
|
+
varyings, normals via mat3(uNormal)), `LIT_VERTEX_COLORED` (the same
|
|
182
|
+
plus the colored layout's aColor forwarded raw as vColor - using it opts
|
|
183
|
+
the material into that layout) and the pure functions `HEMISPHERE`
|
|
184
|
+
(`hemisphere(n, sky, ground)`), `LAMBERT` (`lambert(n, l)`),
|
|
185
|
+
`BLINN_SPECULAR` (`blinnSpecular(n, v, l, shininess)`), `FRESNEL`
|
|
186
|
+
(`fresnel(n, v, power)`). Lights, colors and exponents are arguments, so
|
|
187
|
+
nothing is pinned but the function names; future lit material classes
|
|
188
|
+
compose from these same constants - customizing never means leaving the
|
|
189
|
+
system.
|
|
72
190
|
|
|
73
191
|
## Traps
|
|
74
192
|
|
|
@@ -83,17 +201,89 @@ Materials:
|
|
|
83
201
|
- Alpha does not blend in v1: pipelines are opaque (`blend: "none"`), a
|
|
84
202
|
translucent color overwrites. Transparency waits on blend factors +
|
|
85
203
|
sorting (research note, staging step 4).
|
|
86
|
-
- Rotation is
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
204
|
+
- Rotation is stored as a QUATERNION (`node.quaternion`, `[x, y, z, w]`,
|
|
205
|
+
always unit). There is exactly one rotation field: no `node.rotation`
|
|
206
|
+
shadowing it, because a second field is a second thing to go stale (an
|
|
207
|
+
aimed node whose Euler triple still reads as the old pose is the bug
|
|
208
|
+
this model deletes). Euler triples are a boundary format only -
|
|
209
|
+
`setTransform({ rotation })` and the `rotation` prop convert in,
|
|
210
|
+
`getRotation(node, out?)` converts out.
|
|
211
|
+
- Euler triples are XYZ order (x applied first: `R = Rx * Ry * Rz`),
|
|
212
|
+
Three's `Euler` default, so a triple copied from a Three scene means the
|
|
213
|
+
same thing here. This CHANGED 2026-08-11: the old `compose()` built
|
|
214
|
+
`Rz * Ry * Rx` (Three's `'ZYX'`) while its comment claimed XYZ. Every
|
|
215
|
+
rotation triple then in the repo, examples, demos and projects was
|
|
216
|
+
single-axis, which is order-independent, so the fix moved no pixels -
|
|
217
|
+
verified, not assumed. There is ONE order and no order argument: a
|
|
218
|
+
per-call order is how one triple ends up meaning two things.
|
|
219
|
+
- `getRotation` cannot recover the triple that was written, only a triple
|
|
220
|
+
meaning the same rotation (and at the poles it pins z to 0 and folds the
|
|
221
|
+
roll into x). It is for reading and debugging; anything composing or
|
|
222
|
+
interpolating rotations works with the quaternion.
|
|
223
|
+
- `eulerFromQuat` extracts y with `atan2(m02, cos(y))`, NOT Three's
|
|
224
|
+
`asin(m02)`: asin's derivative blows up at the poles, turning 1e-16 of
|
|
225
|
+
matrix error into 1e-8 of angle. Same reason its pole branch starts at
|
|
226
|
+
`cos(y) < 1e-7` rather than Three's `|m02| > 0.9999999` (which is
|
|
227
|
+
`cos(y) ~ 4.5e-4` - three orders early, and inside that band Three
|
|
228
|
+
silently discards real roll). Do not "restore parity" here.
|
|
229
|
+
- Aim with `lookAt(node, target, up?)`, never by extracting angles by
|
|
230
|
+
hand. Three's `Object3D.lookAt` semantics deliberately: `target` and
|
|
231
|
+
`up` are WORLD space (ancestor transforms are undone, and the ancestor
|
|
232
|
+
chain is refreshed on the spot rather than waiting for the sync), and
|
|
233
|
+
local +z ends up pointing at the target. To aim along a DIRECTION, add
|
|
234
|
+
it to `worldPosition(node)` - the same conversion Three asks for.
|
|
235
|
+
+z is the library's own sweep axis, so `extrude`/`sweep`/`tube` output
|
|
236
|
+
needs no correction. For a y-axis solid (`cylinder`, `cone`) use
|
|
237
|
+
`quatFromTo(q, [0, 1, 0], dir)` instead of correcting lookAt's +z.
|
|
238
|
+
Divergences from Three, both deliberate: `up` is an argument, NOT a
|
|
239
|
+
per-node field (Three's `object.up` is hidden state that costs a vector
|
|
240
|
+
on every node), and degenerate frames pick a stable perpendicular
|
|
241
|
+
instead of Three's epsilon nudge of the eye.
|
|
242
|
+
There is no `setTransform(node, { matrix })`, and lookAt is a MUTATOR,
|
|
243
|
+
not a rotation-returning function.
|
|
244
|
+
- `quatFromTo` is Three's `setFromUnitVectors`, renamed after Unity's
|
|
245
|
+
`FromToRotation` / glam's `from_rotation_arc`: the Three name states a
|
|
246
|
+
precondition instead of the operation, and ours has no such
|
|
247
|
+
precondition (it normalizes). Check Unity/glam/Godot too before copying
|
|
248
|
+
a Three name that reads as an artifact of its class layout.
|
|
249
|
+
- The composition set: `quatFromAxisAngle` (radians; normalizes the axis -
|
|
250
|
+
Three/Unity/glam all require a unit axis and silently corrupt
|
|
251
|
+
otherwise), `quatMultiply` (same order contract as the mat4 `multiply`:
|
|
252
|
+
`a * b`, b applies first; does NOT renormalize - the unit product only
|
|
253
|
+
drifts under long accumulation, and setTransform renormalizes on
|
|
254
|
+
write), `quatSlerp` (shortest path across the double cover, constant
|
|
255
|
+
angular velocity, unit output; the damped follow is
|
|
256
|
+
`quatSlerp(q, q, target, 1 - Math.exp(-k * dt))`). All aim/verb usage
|
|
257
|
+
live in `examples/aim.tsx`.
|
|
258
|
+
- `setTransform` NORMALIZES an incoming quaternion, and passing `rotation`
|
|
259
|
+
and `quaternion` in one call throws. A non-unit quaternion scales
|
|
260
|
+
geometry by `|q|^2` through `compose()` - Three leaves that trap open
|
|
261
|
+
and documents it; we close it at the one write path instead of paying
|
|
262
|
+
for a check in every compose.
|
|
263
|
+
- `lookAt` is exact for rotation and uniform scale up the chain. A
|
|
264
|
+
non-uniformly scaled ancestor shears the frame, so the aim is
|
|
265
|
+
approximate - Three has the identical limitation (both read the parent's
|
|
266
|
+
upper 3x3 as if it were a rotation), and the fix is not to special-case
|
|
267
|
+
it here but to not shear parents of things you aim.
|
|
268
|
+
- The package root's `lookAt` is the scene verb; `@solidrt/3d/math` keeps
|
|
269
|
+
its own `lookAt` (the camera view matrix) on the SUBPATH ONLY, the same
|
|
270
|
+
collision rule the Vec3 helpers follow - and the same Object3D/Matrix4
|
|
271
|
+
split Three makes under one name. Do not re-export math's from the root.
|
|
272
|
+
- Transforms have ONE write path: `setTransform`/`lookAt`/`setVisible` (or
|
|
273
|
+
the props that call them). Mutating `node.position` directly does not
|
|
274
|
+
sync. Components have no `lookAt` prop - aim through a `ref`.
|
|
275
|
+
- A camera change is ONE `setTargetParams` write (uViewProj + uCamPos are
|
|
276
|
+
target state), independent of mesh count - never reintroduce per-mesh
|
|
277
|
+
camera writes (uEye-style per-mesh params are exactly the O(scene) cost
|
|
278
|
+
the shared channel removed). Scene scale honestly: hundreds to a
|
|
93
279
|
few thousand objects, bounded by the interpreter, not the GPU.
|
|
94
280
|
- Entry rebuild order: `setGeometry`/`setMaterial` re-add the entry at the
|
|
95
281
|
list END. Irrelevant while everything is opaque + depth-tested; revisit
|
|
96
282
|
when transparency lands.
|
|
283
|
+
- `lathe` takes a CLOSED profile (a cross-section with thickness, or run
|
|
284
|
+
to the axis at x = 0) - it is a solid of revolution, NOT Three's open
|
|
285
|
+
polyline shell. An "open" outline must be closed by the author;
|
|
286
|
+
otherwise the shape is simply wrong, there is no open-profile mode.
|
|
97
287
|
- `useScene()`/`Group`/`Mesh` throw outside `<Scene>` (default-less
|
|
98
288
|
context).
|
|
99
289
|
- A `shaderMaterial` INSTANCE is the pipeline handle: identical sources
|
|
@@ -101,14 +291,16 @@ Materials:
|
|
|
101
291
|
content-keyed caches are the anti-pattern the GPU layer avoids). Create
|
|
102
292
|
one per look at app scope, share across meshes, `dispose()` when done
|
|
103
293
|
for good.
|
|
104
|
-
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
294
|
+
- The standard-set contract is checked TEXTUALLY at shaderMaterial()
|
|
295
|
+
creation (uModel and uViewProj must appear in the vertex source) and
|
|
296
|
+
strictly at add() for the per-entry names: a uModel or uNormal that is
|
|
297
|
+
declared but never USED compiles out, and the scene's entry seed then
|
|
298
|
+
throws at attach (the engine rejects unknown entry uniform names). The
|
|
299
|
+
shared names have no such backstop - a declared-but-unused uViewProj or
|
|
300
|
+
uCamPos is skipped silently (shared params tolerate zero coverage), so
|
|
301
|
+
the symptom is an untransformed or unlit render, not an error. Use what
|
|
302
|
+
you declare.
|
|
303
|
+
- The layout scan is textual the same way: any `aColor` token in the
|
|
304
|
+
vertex source - a comment counts - selects the "colored" layout, and
|
|
305
|
+
the material then rejects standard geometry at add(). Do not mention
|
|
306
|
+
aColor you do not read.
|
package/README.md
CHANGED
|
@@ -4,6 +4,8 @@ A retained 3D scene graph for SolidRT: meshes, materials and a camera,
|
|
|
4
4
|
declared as Solid components, rendered by the runtime into an ordinary
|
|
5
5
|
texture in your UI tree.
|
|
6
6
|
|
|
7
|
+
_@solidrt/3d is experimental: expect more API churn here than in the rest of SolidRT._
|
|
8
|
+
|
|
7
9
|
```tsx
|
|
8
10
|
import { createSignal, onFrame, render } from "@solidrt/core"
|
|
9
11
|
import { box, Mesh, PerspectiveCamera, Scene, unlit } from "@solidrt/3d"
|
|
@@ -27,16 +29,46 @@ The scene compiles to one depth-buffered GPU draw target: one draw entry
|
|
|
27
29
|
per mesh, one shared pipeline per material class, cross-mesh occlusion
|
|
28
30
|
from the shared depth buffer. A static scene costs zero GPU passes - the
|
|
29
31
|
runtime re-renders the target only when something changes - and a moved
|
|
30
|
-
mesh costs one uniform write.
|
|
32
|
+
mesh costs one uniform write. By default `<Scene>` composites the target
|
|
33
|
+
as a plain `<texture>` leaf; the `output` prop receives the texture id
|
|
34
|
+
and replaces that leaf - place a `<d-texture>`, add paint or pointer
|
|
35
|
+
props, chain a post-effect shader target, or return null and composite
|
|
36
|
+
`scene.texture` yourself.
|
|
31
37
|
|
|
32
38
|
There is also an imperative layer underneath (`createScene`, `createMesh`,
|
|
33
39
|
`setTransform`, ...) usable without components, plus a small math module
|
|
34
|
-
(`@solidrt/3d/math`: column-major mat4, perspective, lookAt).
|
|
40
|
+
(`@solidrt/3d/math`: column-major mat4, perspective, lookAt). To aim a
|
|
41
|
+
node, `lookAt(node, target, up?)` points its local +z at a world point,
|
|
42
|
+
Three's `Object3D.lookAt`; `worldPosition(node)` is the companion for
|
|
43
|
+
aiming along a direction, and `quatFromTo` aims any other axis. For HUD
|
|
44
|
+
overlays, `scene.project(point)` maps a world point to scene pixels.
|
|
45
|
+
|
|
46
|
+
Rotation is stored as a quaternion (`quaternion` prop, `node.quaternion`),
|
|
47
|
+
so aiming and interpolation are gimbal-free and there is no second
|
|
48
|
+
rotation field to fall out of step. Euler triples stay the easy way to
|
|
49
|
+
author one - the `rotation` prop takes radians in XYZ order, matching
|
|
50
|
+
Three's `Euler` default, and `getRotation(node)` reads one back. The
|
|
51
|
+
verbs: `quatFromAxisAngle`, `quatMultiply`, and `quatSlerp` (smooth
|
|
52
|
+
tracking, damped follows) round out `quatFromTo`; `examples/aim.tsx`
|
|
53
|
+
shows each aiming style live.
|
|
54
|
+
Custom materials get a standard uniform set - per-mesh `uModel`/`uNormal`,
|
|
55
|
+
shared `uViewProj`/`uCamPos`, each written once per change - plus your own
|
|
56
|
+
uniforms per mesh, declaratively via the `params` prop on `<Mesh>` or
|
|
57
|
+
imperatively via `setMeshParams` - and
|
|
58
|
+
`@solidrt/3d/glsl` exports the lighting pieces (hemisphere, lambert,
|
|
59
|
+
blinn, fresnel, a standard vertex stage) to compose your own lit looks
|
|
60
|
+
from plain template literals.
|
|
35
61
|
|
|
36
62
|
v1 scope: unlit color/textured materials plus `shaderMaterial` (your own
|
|
37
|
-
GLSL as a first-class material), box
|
|
38
|
-
|
|
39
|
-
|
|
63
|
+
GLSL as a first-class material), geometry generators (box, plane, circle,
|
|
64
|
+
ring, sphere, cylinder, cone, torus, torus knot), a profile kit for custom
|
|
65
|
+
solids (`extrude` with bevels, `lathe`, polyline `sweep`/`tube` with
|
|
66
|
+
mitred joints, flat `shape`, with `fillet`/`roundRect`/`triangulate`
|
|
67
|
+
helpers), a per-vertex data channel
|
|
68
|
+
(`withColors` adds an `aColor` vec4 - tint, baked AO, any four scalars -
|
|
69
|
+
to any geometry, for materials that read it), one perspective camera
|
|
70
|
+
with an orbit control (`createOrbitCamera`: drag, pinch/wheel zoom, auto-orbit).
|
|
71
|
+
Lights, transparency, model loading and picking are
|
|
40
72
|
staged next - see `okf/research/scene-graph-3d.md` for the roadmap. Full
|
|
41
73
|
usage notes and traps: [AGENTS.md](AGENTS.md); runnable examples:
|
|
42
74
|
[examples/](examples/).
|
package/examples/README.md
CHANGED
|
@@ -7,3 +7,11 @@ depends on `@solidrt/3d` (or in-repo from the package directory).
|
|
|
7
7
|
texture leaf, `<PerspectiveCamera>`, a ground plane, a spinning
|
|
8
8
|
`<Group>` of unlit meshes with real depth-buffer occlusion, geometry
|
|
9
9
|
and pipeline sharing, and the one-signal onFrame drive.
|
|
10
|
+
- `sweep-paths.tsx` - swept solids along polylines: a flat strap folding
|
|
11
|
+
over a crate (bare path points crease on the mitred bends) and a coiled
|
|
12
|
+
tube (smooth-tagged helix, one continuous mesh), lit via the exported
|
|
13
|
+
GLSL so the creased-vs-smooth normals actually show.
|
|
14
|
+
- `aim.tsx` - the rotation verbs, one pointer each tracking an orbiting
|
|
15
|
+
target: `lookAt` for a +z solid, `quatFromTo` for aiming a y-axis cone,
|
|
16
|
+
and a `quatSlerp` damped follow that visibly lags; all driven from
|
|
17
|
+
onFrame through refs, no per-frame signals.
|
package/examples/aim.tsx
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Aiming and rotation: a target orbits, and three fixed pointers track it,
|
|
2
|
+
// each through a different rotation verb.
|
|
3
|
+
// - lookAt(node, target) the z-axis rod: one call, world semantics
|
|
4
|
+
// - quatFromTo(y, direction) the cone: aiming an axis other than +z
|
|
5
|
+
// - quatSlerp damped follow the lazy cone: visibly lags, then catches up
|
|
6
|
+
// All three drive their nodes from onFrame through refs - the frame-rate
|
|
7
|
+
// escape hatch - so no per-frame signals exist; the one signal-free scene
|
|
8
|
+
// re-renders because setTransform marks the moved nodes dirty.
|
|
9
|
+
import { onFrame, pct, render } from "@solidrt/core"
|
|
10
|
+
import {
|
|
11
|
+
cone,
|
|
12
|
+
lookAt,
|
|
13
|
+
Mesh,
|
|
14
|
+
PerspectiveCamera,
|
|
15
|
+
plane,
|
|
16
|
+
quat,
|
|
17
|
+
quatFromTo,
|
|
18
|
+
quatSlerp,
|
|
19
|
+
Scene,
|
|
20
|
+
setTransform,
|
|
21
|
+
sphere,
|
|
22
|
+
tube,
|
|
23
|
+
unlit,
|
|
24
|
+
worldPosition,
|
|
25
|
+
} from "@solidrt/3d"
|
|
26
|
+
import type { MeshNode, Vec3 } from "@solidrt/3d"
|
|
27
|
+
|
|
28
|
+
const SIZE = 720
|
|
29
|
+
const Y_AXIS: Vec3 = [0, 1, 0]
|
|
30
|
+
|
|
31
|
+
function App() {
|
|
32
|
+
let target!: MeshNode
|
|
33
|
+
let rod!: MeshNode
|
|
34
|
+
let cannon!: MeshNode
|
|
35
|
+
let lazy!: MeshNode
|
|
36
|
+
|
|
37
|
+
// Allocated once; every per-frame write reuses them.
|
|
38
|
+
let targetPos: Vec3 = [0, 0, 0]
|
|
39
|
+
let dir: Vec3 = [0, 0, 0]
|
|
40
|
+
let aimQ = quat()
|
|
41
|
+
|
|
42
|
+
// A world-space direction from a node to the target: the documented
|
|
43
|
+
// recipe, worldPosition + subtract (exact here - the pointers have no
|
|
44
|
+
// transformed ancestors - and correct even if they get some).
|
|
45
|
+
let aimFrom = (node: MeshNode) => {
|
|
46
|
+
let p = worldPosition(node, dir)
|
|
47
|
+
dir[0] = targetPos[0] - p[0]
|
|
48
|
+
dir[1] = targetPos[1] - p[1]
|
|
49
|
+
dir[2] = targetPos[2] - p[2]
|
|
50
|
+
return dir
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
onFrame(tick => {
|
|
54
|
+
let t = tick / 1500
|
|
55
|
+
targetPos[0] = Math.cos(t) * 1.9
|
|
56
|
+
targetPos[1] = 1.1 + Math.sin(t * 0.7) * 0.7
|
|
57
|
+
targetPos[2] = Math.sin(t) * 1.9
|
|
58
|
+
setTransform(target, { position: targetPos })
|
|
59
|
+
|
|
60
|
+
// The rod is a +z solid (tube paths run along z): lookAt is the whole
|
|
61
|
+
// aiming story, a world-space point in, done.
|
|
62
|
+
lookAt(rod, targetPos)
|
|
63
|
+
|
|
64
|
+
// The cone points along +y, not +z, so lookAt would aim its side.
|
|
65
|
+
// quatFromTo rotates the axis you name onto the direction you want.
|
|
66
|
+
quatFromTo(aimQ, Y_AXIS, aimFrom(cannon))
|
|
67
|
+
setTransform(cannon, { quaternion: aimQ })
|
|
68
|
+
|
|
69
|
+
// Damped follow: slerp the CURRENT rotation a fixed fraction of the
|
|
70
|
+
// way toward the aimed one each frame. The 0.04 makes the lag obvious;
|
|
71
|
+
// a real app uses 1 - Math.exp(-k * dt) to stay frame-rate independent.
|
|
72
|
+
quatFromTo(aimQ, Y_AXIS, aimFrom(lazy))
|
|
73
|
+
quatSlerp(aimQ, lazy.quaternion, aimQ, 0.04)
|
|
74
|
+
setTransform(lazy, { quaternion: aimQ })
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
let ball = sphere(0.22)
|
|
78
|
+
let pointer = cone(0.3, 0.9)
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<window>
|
|
82
|
+
<view width={pct(100)} height={pct(100)} viewBox={[SIZE, SIZE]}>
|
|
83
|
+
<Scene width={SIZE} height={SIZE} clearColor={[0.07, 0.07, 0.1, 1]} label="aim">
|
|
84
|
+
<PerspectiveCamera fov={55} position={[0, 2.6, 4.6]} lookAt={[0, 0.7, 0]} />
|
|
85
|
+
<Mesh
|
|
86
|
+
geometry={plane(7, 7, "floor")}
|
|
87
|
+
material={unlit({ color: [0.16, 0.17, 0.22] })}
|
|
88
|
+
rotation={[-Math.PI / 2, 0, 0]}
|
|
89
|
+
/>
|
|
90
|
+
<Mesh
|
|
91
|
+
geometry={ball}
|
|
92
|
+
material={unlit({ color: [0.95, 0.85, 0.4] })}
|
|
93
|
+
ref={n => (target = n)}
|
|
94
|
+
/>
|
|
95
|
+
<Mesh
|
|
96
|
+
geometry={tube([[0, 0, 0], [0, 0, 1.1]], 0.09, 10, "rod")}
|
|
97
|
+
material={unlit({ color: [0.85, 0.3, 0.3] })}
|
|
98
|
+
position={[-1.4, 0.5, 0]}
|
|
99
|
+
ref={n => (rod = n)}
|
|
100
|
+
/>
|
|
101
|
+
<Mesh
|
|
102
|
+
geometry={pointer}
|
|
103
|
+
material={unlit({ color: [0.35, 0.65, 0.9] })}
|
|
104
|
+
position={[1.4, 0.5, 0]}
|
|
105
|
+
ref={n => (cannon = n)}
|
|
106
|
+
/>
|
|
107
|
+
<Mesh
|
|
108
|
+
geometry={pointer}
|
|
109
|
+
material={unlit({ color: [0.45, 0.8, 0.5] })}
|
|
110
|
+
position={[0, 0.5, -1.4]}
|
|
111
|
+
ref={n => (lazy = n)}
|
|
112
|
+
/>
|
|
113
|
+
</Scene>
|
|
114
|
+
</view>
|
|
115
|
+
</window>
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
render(() => <App />)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// The `output` prop: Scene hands you its texture id and you compose the
|
|
2
|
+
// leaf yourself, in place of the built-in `<texture>`. Here a fragment
|
|
3
|
+
// pass samples the scene and adds chromatic aberration plus a vignette,
|
|
4
|
+
// and the scene renders at 2x display size - the post pass doubles as the
|
|
5
|
+
// downsample (the default linear sampler box-averages the 2x2 quad), so
|
|
6
|
+
// the same chain is also free supersampling. Sampled textures are live
|
|
7
|
+
// dependencies: the post pass re-renders exactly when the scene target
|
|
8
|
+
// does, and a static scene still costs zero passes.
|
|
9
|
+
//
|
|
10
|
+
// The same slot takes a `<d-texture>`, a leaf with blendMode/fit/pointer
|
|
11
|
+
// props, or `() => null` (headless - composite scene.texture elsewhere).
|
|
12
|
+
import { createSignal, onFrame, pct, render } from "@solidrt/core"
|
|
13
|
+
import { createShaderTexture } from "@solidrt/core/gpu"
|
|
14
|
+
import { box, Group, Mesh, PerspectiveCamera, plane, Scene, sphere, unlit } from "@solidrt/3d"
|
|
15
|
+
|
|
16
|
+
const SIZE = 720 // display pixels (the leaf)
|
|
17
|
+
const RENDER = SIZE * 2 // target pixels (the scene)
|
|
18
|
+
|
|
19
|
+
// vUV, iResolution, and fragColor come from the standard shader-texture
|
|
20
|
+
// preamble; uSource is bound via the `textures` option.
|
|
21
|
+
const POST = `
|
|
22
|
+
uniform sampler2D uSource;
|
|
23
|
+
void main() {
|
|
24
|
+
vec2 c = vUV - 0.5;
|
|
25
|
+
float r2 = dot(c, c);
|
|
26
|
+
// Chromatic aberration: red and blue sample at slightly shifted radii.
|
|
27
|
+
vec2 shift = c * r2 * 0.04;
|
|
28
|
+
vec3 col = vec3(
|
|
29
|
+
texture(uSource, vUV + shift).r,
|
|
30
|
+
texture(uSource, vUV).g,
|
|
31
|
+
texture(uSource, vUV - shift).b);
|
|
32
|
+
// Vignette.
|
|
33
|
+
col *= 1.0 - 1.1 * r2;
|
|
34
|
+
fragColor = vec4(col, 1.0);
|
|
35
|
+
}`
|
|
36
|
+
|
|
37
|
+
function App() {
|
|
38
|
+
let [spin, setSpin] = createSignal(0)
|
|
39
|
+
onFrame(tick => setSpin(tick / 2000))
|
|
40
|
+
|
|
41
|
+
let cube = box()
|
|
42
|
+
let floor = plane(6, 6, "floor")
|
|
43
|
+
let ball = sphere(0.35)
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<window>
|
|
47
|
+
<view width={pct(100)} height={pct(100)} viewBox={[SIZE, SIZE]}>
|
|
48
|
+
<Scene
|
|
49
|
+
width={RENDER}
|
|
50
|
+
height={RENDER}
|
|
51
|
+
clearColor={[0.07, 0.07, 0.1, 1]}
|
|
52
|
+
label="scene-post"
|
|
53
|
+
output={tex => {
|
|
54
|
+
// Created inside the callback, the post target disposes with
|
|
55
|
+
// the Scene.
|
|
56
|
+
let post = createShaderTexture(POST, SIZE, SIZE, null, { textures: { uSource: tex } })
|
|
57
|
+
return <texture src={post} width={SIZE} height={SIZE} />
|
|
58
|
+
}}
|
|
59
|
+
>
|
|
60
|
+
<PerspectiveCamera fov={55} position={[0, 1.6, 3.6]} lookAt={[0, 0.3, 0]} />
|
|
61
|
+
<Mesh
|
|
62
|
+
geometry={floor}
|
|
63
|
+
material={unlit({ color: [0.16, 0.17, 0.22] })}
|
|
64
|
+
rotation={[-Math.PI / 2, 0, 0]}
|
|
65
|
+
/>
|
|
66
|
+
<Group rotation={[0, spin(), 0]}>
|
|
67
|
+
<Mesh geometry={cube} material={unlit({ color: [0.85, 0.3, 0.3] })} position={[0, 0.5, 0]} />
|
|
68
|
+
<Mesh
|
|
69
|
+
geometry={cube}
|
|
70
|
+
material={unlit({ color: [0.9, 0.8, 0.35] })}
|
|
71
|
+
position={[-1.1, 0.7, 0]}
|
|
72
|
+
scale={[0.5, 1.4, 0.5]}
|
|
73
|
+
/>
|
|
74
|
+
<Mesh geometry={ball} material={unlit({ color: [0.35, 0.65, 0.9] })} position={[1.1, 0.35, 0]} />
|
|
75
|
+
</Group>
|
|
76
|
+
</Scene>
|
|
77
|
+
</view>
|
|
78
|
+
</window>
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
render(() => <App />)
|