@solidrt/3d 0.0.51 → 0.0.52
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 +332 -91
- package/README.md +13 -9
- package/demos/README.md +15 -0
- package/demos/assets/icon.svg +23 -0
- package/demos/package.json +9 -0
- package/demos/src/the-third-dimension.tsx +866 -0
- package/demos/tsconfig.json +15 -0
- package/examples/README.md +29 -2
- package/examples/aim.tsx +5 -5
- package/examples/instanced.tsx +4 -4
- package/examples/lit.tsx +66 -0
- package/examples/model.glb +0 -0
- package/examples/model.tsx +51 -0
- package/examples/pick.tsx +5 -5
- package/examples/scene-background.tsx +3 -3
- package/examples/scene-basic.tsx +3 -3
- package/examples/scene-post-effect.tsx +3 -3
- package/examples/scene-views.tsx +95 -0
- package/examples/shadows.tsx +86 -0
- package/examples/sprites.tsx +95 -0
- package/examples/sweep-paths.tsx +11 -27
- package/package.json +4 -3
- package/src/components.tsx +110 -14
- package/src/geometry-gpu.ts +13 -2
- package/src/geometry.ts +331 -127
- package/src/glsl.ts +83 -3
- package/src/gltf.ts +437 -0
- package/src/index.ts +17 -12
- package/src/material.ts +352 -47
- package/src/math.ts +69 -0
- package/src/model-file.ts +122 -0
- package/src/model.ts +105 -0
- package/src/orbit.ts +13 -9
- package/src/order.ts +12 -5
- package/src/profile.ts +4 -8
- package/src/scene.ts +1075 -282
- package/src/sweep.ts +21 -36
- package/src/bvh.ts +0 -258
package/AGENTS.md
CHANGED
|
@@ -4,38 +4,108 @@ A retained 3D scene graph above `@solidrt/core/gpu`. Meshes, materials and
|
|
|
4
4
|
a camera compile to ONE depth-buffered draw target (`createDrawTarget` +
|
|
5
5
|
one `addDraw` entry per mesh); the scene's output is an ordinary texture
|
|
6
6
|
id composited as a `<texture>` leaf, so it takes layout, transforms,
|
|
7
|
-
blendMode and pointer events like any element.
|
|
8
|
-
`okf/research/scene-graph-3d.md` in the repo.
|
|
7
|
+
blendMode and pointer events like any element.
|
|
9
8
|
|
|
10
9
|
## The model
|
|
11
10
|
|
|
12
11
|
- Two layers. The imperative core is Solid-free: `createScene`,
|
|
13
12
|
`createMesh(geometry, material)`, `add`/`remove`, `setTransform`,
|
|
14
|
-
`lookAt`, `getRotation`, `setVisible`, `setRenderOrder` - plain objects
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
13
|
+
`lookAt`, `getRotation`, `setVisible`, `setRenderOrder` - plain objects
|
|
14
|
+
over the spatial core (`flux:spatial`, `alloy/src/spatial/`): every node
|
|
15
|
+
in a scene has a core node, JS keeps the LOCAL position/quaternion/scale
|
|
16
|
+
as the readable truth and forwards each write, and the core's flush
|
|
17
|
+
(one call per microtask) recomputes only the moved subtrees and writes
|
|
18
|
+
each entry's uModel (plus uNormal for materials declaring it) and its
|
|
19
|
+
visibility switch itself - a move costs its subtree, never the scene.
|
|
20
|
+
ONE `setTargetParams` (the shared uViewProj + uCamPos) per camera
|
|
21
|
+
change, however many meshes. World matrices live in the core only:
|
|
22
|
+
`worldPosition`/`lookAt`/picking read them back (`worldMatrix`, pending
|
|
23
|
+
writes included). See okf/backlog/spatial-core.md for what still runs
|
|
24
|
+
in JS and why. The component face (`Scene`/`Group`/`Mesh`/
|
|
25
|
+
`PerspectiveCamera`) syncs props into that core over context and renders
|
|
26
|
+
nothing itself.
|
|
21
27
|
- Rendering is the runtime's. The target is `render: "auto"`: it
|
|
22
28
|
re-renders when entries change, so a STATIC scene costs zero passes and
|
|
23
29
|
the library registers no frame loop. Continuous animation is the app's
|
|
24
30
|
own `onFrame` writing a signal (declarative) or `setTransform` on a
|
|
25
31
|
`ref`-grabbed node (the frame-rate escape hatch - signals carry
|
|
26
32
|
structure, per-frame motion goes straight to the scene).
|
|
27
|
-
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
- VIEWS: `scene.createView({ width, height, overrideMaterial?, depth?,
|
|
34
|
+
clearColor?, ... })` renders the same scene into a second target from
|
|
35
|
+
its own camera (`view.setCamera`, the scene's CameraUpdate shape). Each
|
|
36
|
+
mesh gets one entry in the view's target bound as one more draw sink of
|
|
37
|
+
its CORE node, so the one flush writes every target - the app writes
|
|
38
|
+
nothing per view. Geometry buffers and (without an override) materials
|
|
39
|
+
are shared; the light set and `scene.setParams` names fan out to every
|
|
40
|
+
view, `view.setParams` is the view's own channel; the scene background
|
|
41
|
+
is not mirrored; a view has no picking. `overrideMaterial` (Three's
|
|
42
|
+
`scene.overrideMaterial`, scoped to the view) draws every mesh with one
|
|
43
|
+
material - a depth pass, a normal/id visualizer - skips instanced
|
|
44
|
+
meshes (the override cannot know their record layout) and draws in add
|
|
45
|
+
order. `depth: "texture"` exposes `view.depthTexture`, the shadow-map
|
|
46
|
+
input. `ortho: { left, right, top, bottom }` on any camera swaps
|
|
47
|
+
perspective for `orthographic()` (`fov` ignored; `ortho: null` returns);
|
|
48
|
+
the scene's own camera takes it too, and pick() follows.
|
|
49
|
+
`examples/scene-views.tsx` is the shape.
|
|
50
|
+
- SHADOWS are a view: `<DirectionalLight castShadow shadow={{ mapSize?,
|
|
51
|
+
bias?, normalBias?, camera? }}>` (`createDirectionalLight({ castShadow,
|
|
52
|
+
shadow })`, `setLight`) makes the scene own an internal
|
|
53
|
+
`createView({ depth: "texture", overrideMaterial: depth pass })` drawing
|
|
54
|
+
the `castShadow` meshes (`<Mesh castShadow>`, `setCastShadow`) from an
|
|
55
|
+
orthographic camera at the light's WORLD position along its world
|
|
56
|
+
direction, `shadow.camera` (+-5, 0.5..500 by default) as the frustum.
|
|
57
|
+
Any directional light may cast (each map is a pass, capped by
|
|
58
|
+
MAX_LIGHTS = MAX_SHADOWS): shadow slot i is directional light i's -
|
|
59
|
+
the map's depth id binds as the target-level `uShadowMap<i>` of the
|
|
60
|
+
scene and every non-shadow view (a white texel when light i does not
|
|
61
|
+
cast), `uShadowMatrix[i]` is its view's own view-projection (the whole
|
|
62
|
+
array is one write per shadow-camera move), `uShadowCast[i]` says
|
|
63
|
+
whether it casts, `uShadowBias[i]`/`uShadowNormalBias[i]` its knobs;
|
|
64
|
+
`SHADOW_SLOTS` in glsl declares the set. Every `lit` material RECEIVES by default
|
|
65
|
+
(Godot's and Three's default); `lit({ receiveShadow: false })` opts a
|
|
66
|
+
material out and drops the map from its program - a material option,
|
|
67
|
+
as with vertexColors/triplanar, because the material picks the program
|
|
68
|
+
(Godot's `disable_receive_shadows`). The factor is `SHADOW`'s 3x3 PCF
|
|
69
|
+
on each casting light's own term. `examples/shadows.tsx` (three
|
|
70
|
+
casting lights) is the shape.
|
|
71
|
+
- RETARGETED motion is native: `setTransition(node, { position:
|
|
72
|
+
{ duration: 400 }, ... })` makes setTransform writes TARGETS the core
|
|
73
|
+
animates toward every frame (position/scale per lane, rotation along
|
|
74
|
+
the quaternion geodesic - a spring keeps its velocity through
|
|
75
|
+
retargets), so a mesh gliding to a slot or a camera rig easing costs
|
|
76
|
+
one JS write per target change, zero per frame. The declaration lives
|
|
77
|
+
on the SceneNode and re-applies on every scene enter; the pose a node
|
|
78
|
+
enters with always snaps. Each natural settle calls the node's
|
|
79
|
+
`onTransitionEnd` (plain field like the pointer handlers) with
|
|
80
|
+
`{ component }`; the raw "spatialTransitionEnd" engine event
|
|
81
|
+
(srt:events, carrying the CORE node id `_node`) stays for flux:spatial
|
|
82
|
+
consumers.
|
|
83
|
+
- One interleaved vertex buffer per geometry, described by an open layout
|
|
84
|
+
(`Geometry.layout`, absent = "standard"): an ordered attribute list that
|
|
85
|
+
always starts with the standard prefix `aPos` vec3 + `aNormal` vec3 +
|
|
86
|
+
`aUV` vec2 (what every generator emits) and may carry any named channels
|
|
87
|
+
after it. `withAttribute(geometry, { name, format }, fill)` appends one
|
|
88
|
+
(Three's `setAttribute` for an interleave); "colored" names the common
|
|
89
|
+
case, the prefix plus `aColor` vec4 - the per-vertex data channel (a
|
|
90
|
+
tint, baked AO, any four scalars; standard name, your contents) - and
|
|
91
|
+
`withColors(geometry, fill)` is its spelling. Fill is a flat
|
|
92
|
+
size-per-vertex array or a per-vertex callback receiving `(index, pos,
|
|
93
|
+
normal, uv)`. Materials read attributes BY NAME: a material's vertex
|
|
94
|
+
stage may declare any subset of its geometry's channels, and a channel
|
|
95
|
+
the program reads that the geometry lacks (name + format) throws at
|
|
96
|
+
add(). What a program reads is the ENGINE's word (`material.attributes()`
|
|
97
|
+
= `programAttributes` reflection of the linked program, instance
|
|
98
|
+
attributes excluded), not a parse of the GLSL: an `in` the compiler
|
|
99
|
+
dropped does not count, and the engine also rejects a pipeline whose
|
|
100
|
+
attribute lists leave a read attribute uncovered. The material
|
|
101
|
+
keeps one program and builds one pipeline per layout its meshes bring,
|
|
102
|
+
so a geometry may carry more than a material reads. The whole layout
|
|
35
103
|
ships whether a material reads every attribute or not (inactive
|
|
36
|
-
attributes only keep the stride), so
|
|
37
|
-
|
|
38
|
-
standard geometry.
|
|
104
|
+
attributes only keep the stride), so extra channels cost their floats on
|
|
105
|
+
every draw of that geometry - keep data-light passes (a wireframe
|
|
106
|
+
reading only aPos) on standard geometry. `layoutStride`/`layoutSlot`/
|
|
107
|
+
`layoutKey`/`layoutAttributes` are the layout arithmetic; two layouts
|
|
108
|
+
with equal keys interleave identically (merge requires that).
|
|
39
109
|
Indices are uint16 or uint32 - the `Geometry.indices` array type picks
|
|
40
110
|
the draw's index format, so hand-built geometry past 64k vertices just
|
|
41
111
|
uses a Uint32Array (generators emit uint16). Geometry GPU buffers are
|
|
@@ -47,20 +117,26 @@ blendMode and pointer events like any element. Design rationale:
|
|
|
47
117
|
(unlit color, unlit map, each opaque or transparent), `depth: true` +
|
|
48
118
|
`cull: "back"`; an instance is
|
|
49
119
|
just per-entry uniforms (`uColor`) and bindings (`uMap`).
|
|
50
|
-
- The pure pieces (`math.ts`, `
|
|
51
|
-
`
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
120
|
+
- The pure pieces (`math.ts`, `order.ts`, `geometry.ts`,
|
|
121
|
+
`profile.ts`, `sweep.ts`, `gltf.ts`, `model-file.ts`) are Solid-free and
|
|
122
|
+
GPU-free BY DESIGN so they can be checked headless (and, for the two
|
|
123
|
+
model modules, run under bun in `tools/model.ts`); keep them that way.
|
|
124
|
+
The rigs under `checks/`
|
|
125
|
+
(`geometry-check`, `sweep-check`, `pick-check`, `order-check`,
|
|
126
|
+
`gltf-check`) run on
|
|
127
|
+
flux from the repo root: `bunx srt bundle -f --stdout
|
|
128
|
+
packages/3d/checks/<name>.ts | target/release/flux -`. Run the ones
|
|
129
|
+
touching what you changed.
|
|
55
130
|
|
|
56
131
|
## Components
|
|
57
132
|
|
|
58
133
|
| Component | Props |
|
|
59
134
|
| --- | --- |
|
|
60
|
-
| `Scene` | `width`, `height` (target pixels), `clearColor?`, `background?` (fragment GLSL), `label?`, `ref?(scene)`, `output?(texture)`, `events?` (mesh pointer events, default on) |
|
|
135
|
+
| `Scene` | `width`, `height` (target pixels), `clearColor?`, `background?` (fragment GLSL), `samples?` (1/2/4/8 MSAA), `label?`, `ref?(scene)`, `output?(texture)`, `events?` (mesh pointer events, default on) |
|
|
61
136
|
| `Group` | `position?`, `rotation?` (Euler radians, XYZ order), `quaternion?` (either, not both), `scale?` (number = uniform), `visible?`, pointer events (below), `ref?(node)` |
|
|
62
137
|
| `Mesh` | `geometry`, `material`, transforms as Group, `params?` (per-mesh uniforms, merge semantics - no unset), pointer events (below), `ref?(mesh)` |
|
|
63
|
-
| `
|
|
138
|
+
| `Sprite` | as Mesh minus `geometry`: a camera-facing unit quad, `scale` is its world size, rotation is ignored; pair with a `sprite()` material |
|
|
139
|
+
| `InstancedMesh` | as Mesh, plus `records` (interleaved per-instance floats; buffer capacity starts at the first value and grows on larger rewrites), `count?` (records drawn, default all), `bounds?` (local [minX..maxZ] over the population - without it the mesh never picks); the record buffer is component-owned and freed on unmount |
|
|
64
140
|
| `PerspectiveCamera` | `fov?` (vertical DEGREES, default 60), `near?`, `far?`, `position?`, `lookAt?`, `up?` |
|
|
65
141
|
|
|
66
142
|
Output composition: without `output`, `Scene` emits a minimal
|
|
@@ -79,8 +155,11 @@ Camera control: `createOrbitCamera(scene, { target?, azimuth?, elevation?,
|
|
|
79
155
|
distance?, min/maxDistance?, min/maxElevation?, orbitSpeed?, rotateSpeed?,
|
|
80
156
|
zoomSpeed?, zoomAnchor?, rotateAnchor?, panSpeed?, viewport?, clampTarget? })`
|
|
81
157
|
- drag-to-rotate, pinch- and wheel-to-zoom, two-finger pan, optional
|
|
82
|
-
auto-orbit.
|
|
83
|
-
|
|
158
|
+
auto-orbit. The first argument is anything with the scene's `setCamera`: a
|
|
159
|
+
Scene, or a View to drive one view's camera independently (one orbit per
|
|
160
|
+
view, each handed the handlers of its own viewport element). Input runs on
|
|
161
|
+
core's `createTransform` recognizer, so drag and pinch arbitrate in the
|
|
162
|
+
app-wide gesture arena (a viewport inside a scroller
|
|
84
163
|
does not double-handle) and rotation starts after the recognizer's slop;
|
|
85
164
|
`zoomSpeed` weights both wheel and pinch. Two-finger translation pans (the
|
|
86
165
|
scene tracks the fingers 1:1 at target depth, weighted by `panSpeed`) when
|
|
@@ -115,13 +194,16 @@ Picking: `scene.pick(x, y)` is project()'s inverse - the camera ray
|
|
|
115
194
|
through a scene pixel, returning `Hit[]` (`{ mesh, distance, point }`,
|
|
116
195
|
world units, nearest first; every hit along the ray, not just the front
|
|
117
196
|
one). `scene.raycast(origin, direction)` is the world-space primitive
|
|
118
|
-
under it.
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
so
|
|
197
|
+
under it.
|
|
198
|
+
The index and the narrowphase live in the spatial core: every attached
|
|
199
|
+
mesh's local box is a leaf in a dynamic AABB tree the flush refits from
|
|
200
|
+
the fresh world matrices (O(moved) per frame, a query O(log meshes)), and
|
|
201
|
+
an ordinary mesh is then tested per triangle against its geometry's
|
|
202
|
+
shape (one CPU copy per distinct geometry, created with its GPU buffers),
|
|
203
|
+
so hits carry `face`, `uv` and a world-space `normal` facing the ray, and
|
|
204
|
+
a ray through a knot's hole misses. An instanced mesh is box-only (its
|
|
205
|
+
explicit population bounds; records are opaque), so its hits have none of
|
|
206
|
+
the three. Both methods
|
|
125
207
|
flush pending writes first (the lookAt/project immediacy contract), and
|
|
126
208
|
both skip invisible meshes.
|
|
127
209
|
|
|
@@ -140,29 +222,41 @@ automatically (opt out: `events={false}`); an `output` leaf or
|
|
|
140
222
|
imperative composition spreads `{...scene.handlers}` onto the element
|
|
141
223
|
showing the texture. `scene.handlers` assumes that leaf is LAID OUT at
|
|
142
224
|
the target size - true for the built-in leaf and a d-texture at natural
|
|
143
|
-
size, under any ancestor transforms or
|
|
225
|
+
size, under any ancestor transforms or design-size fits (the hit test
|
|
144
226
|
undoes them; localX/localY arrive in the leaf's layout frame). A leaf
|
|
145
227
|
laid out at a different size (the supersampling pattern) uses
|
|
146
228
|
`scene.handlersFor(() => ({ width, height }))` with its layout size.
|
|
147
229
|
|
|
148
|
-
Geometry
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
`
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
230
|
+
Geometry generators take ONE options object, every field optional with
|
|
231
|
+
a default, named as Three names them: `box({ width, height, depth })`
|
|
232
|
+
(1x1x1); `plane({ width, height })`, `circle({ radius, segments })` and
|
|
233
|
+
`ring({ innerRadius, outerRadius, segments })` (XY, facing +z - rotate
|
|
234
|
+
`[-Math.PI/2, 0, 0]` for a floor); `sphere({ radius, widthSegments,
|
|
235
|
+
heightSegments })`; `cylinder({ radiusTop, radiusBottom, height,
|
|
236
|
+
radialSegments })` (y axis, capped; unequal radii taper it) and
|
|
237
|
+
`cone({ radius, height, radialSegments })`; `torus({ radius, tube,
|
|
238
|
+
radialSegments, tubularSegments })` (lying flat, hole on the y axis) and
|
|
239
|
+
`torusKnot({ radius, tube, tubularSegments, radialSegments, p, q })`
|
|
155
240
|
(standing y-up) - both oriented for the y-up world, unlike Three's z-up.
|
|
156
|
-
`
|
|
157
|
-
|
|
158
|
-
`
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
241
|
+
No positional form: `box()` is the default cube, `box({ label: "rock" })`
|
|
242
|
+
names it. Every options object (the profile kit's `extrude`/`lathe`/
|
|
243
|
+
`sweep`/`tube` too) also takes `label` and `layout` - `layout` makes the
|
|
244
|
+
generator emit that layout in one pass (standard channels written, the
|
|
245
|
+
extra slots zero), so `box({ layout: "colored" })` then
|
|
246
|
+
`fillColors(g, fill)` builds colored geometry without the
|
|
247
|
+
generate-then-repack copy; the result is byte-identical to
|
|
248
|
+
`withColors(box(), fill)`. `packGeometry(verts, indices, options?)` is
|
|
249
|
+
the tail every generator ends in, for your own generators.
|
|
250
|
+
`withAttribute(geometry, attr, fill, label?)` derives a copy of any
|
|
251
|
+
geometry (generator or hand-built) with one more channel after its
|
|
252
|
+
current layout; the source is untouched. `withColors(geometry, fill,
|
|
253
|
+
label?)` is the aColor vec4 case, keeping the "colored" preset name.
|
|
254
|
+
`fillAttribute(geometry, name, fill, first?, count?)` is the in-place
|
|
255
|
+
primitive under both: overwrites one channel the geometry's layout
|
|
256
|
+
already carries (withAttribute ADDS one), reading pos/normal/uv from the
|
|
257
|
+
buffer itself - so a builder that bakes transforms while writing hands
|
|
258
|
+
the baker world-space vertices. `fill` indexes relative to `first`.
|
|
259
|
+
`fillColors(geometry, fill, first?, count?)` is its aColor spelling.
|
|
166
260
|
|
|
167
261
|
Geometry as data: `transformGeometry(geometry, { position?, rotation?,
|
|
168
262
|
quaternion?, scale? }, label?)` bakes a placement into a copy (the
|
|
@@ -188,11 +282,11 @@ closed XY polygon, bare `[x, y]` points crease, `{ p, smooth }` points
|
|
|
188
282
|
share an averaged normal - `fillet(points, radius, segs?)` and
|
|
189
283
|
`roundRect(w?, h?, radius?, segs?)` emit those (arc corners smooth).
|
|
190
284
|
Winding is normalized, so either authoring direction works.
|
|
191
|
-
`extrude(profile, depth
|
|
192
|
-
with a quarter-round bevel at both rims; `lathe(profile,
|
|
193
|
-
start
|
|
285
|
+
`extrude(profile, { depth, bevel, bevelSegments })` sweeps along z,
|
|
286
|
+
centered, with a quarter-round bevel at both rims; `lathe(profile, {
|
|
287
|
+
segments, angle, start })` revolves a CLOSED (x = radius, y = height) profile about the y
|
|
194
288
|
axis - watertight by construction, flat caps on partial sweeps;
|
|
195
|
-
`sweep(profile, path)` runs the profile along an open 3D polyline with
|
|
289
|
+
`sweep(profile, path, options?)` runs the profile along an open 3D polyline with
|
|
196
290
|
MITRED joints (each cross-section sits on its bend's bisector plane, so
|
|
197
291
|
bends never gape or overlap) and flat caps at both ends. The path
|
|
198
292
|
mirrors the profile convention: bare `[x, y, z]` points crease (a strap
|
|
@@ -200,10 +294,10 @@ folding over an edge), `{ p, smooth }` points shade continuous (tag a
|
|
|
200
294
|
sampled curve's points); the profile's y starts as close to world up as
|
|
201
295
|
the first segment allows, then parallel-transports without spinning.
|
|
202
296
|
Closed loops are NOT supported yet - overlap the ends by a segment to
|
|
203
|
-
fake one. `tube(path, radius
|
|
297
|
+
fake one. `tube(path, { radius, radialSegments })` is the round-profile
|
|
204
298
|
shorthand (wire, rope, pipe), and `pathFrames(path)` exports the
|
|
205
299
|
per-segment frames (tangents, cross-section axes, arc lengths) for
|
|
206
|
-
custom work along a path. `shape(profile)` fills one flat (facing +z,
|
|
300
|
+
custom work along a path. `shape(profile, options?)` fills one flat (facing +z,
|
|
207
301
|
like circle); `triangulate(points)` is the ear-clipping core (fan
|
|
208
302
|
fallback, never drops a cap), exported for custom flat work. These pick
|
|
209
303
|
uint16/uint32 indices by vertex count automatically.
|
|
@@ -212,6 +306,17 @@ Materials:
|
|
|
212
306
|
|
|
213
307
|
- `unlit({ color?, map? })` - straight `[r, g, b, a?]` 0..1, premultiplied
|
|
214
308
|
internally.
|
|
309
|
+
- `sprite({ color?, map?, transparent?, billboard? })` - unlit on a quad
|
|
310
|
+
that turns to face the camera IN THE VERTEX STAGE (off the shared
|
|
311
|
+
uCamRight/uCamUp, or uCamPos for `billboard: "fixed-y"`, which yaws
|
|
312
|
+
only and stays upright on world y - Godot's BILLBOARD_FIXED_Y, the
|
|
313
|
+
tree/character sprite; the default `"full"` is Three's Sprite, flat to
|
|
314
|
+
the screen). No per-frame JS however many sprites. `transparent`
|
|
315
|
+
defaults to TRUE here (cutouts; Three's SpriteMaterial default), cull is
|
|
316
|
+
off. Draw with `createSprite(material)` / `<Sprite>`: a Mesh over a
|
|
317
|
+
shared unit plane, no geometry argument, `scale` = world size, rotation
|
|
318
|
+
ignored. Picks by a unit box around its center (its reach at any
|
|
319
|
+
facing), so hits carry no normal/face/uv. `examples/sprites.tsx`.
|
|
215
320
|
- `shaderMaterial({ vertex, fragment, params?, textures?, depth?,
|
|
216
321
|
depthWrite?, blend?, cull?, topology?, label? })` - your own GLSL, the
|
|
217
322
|
custom-look escape hatch. The STANDARD UNIFORM SET: the vertex stage
|
|
@@ -229,9 +334,11 @@ Materials:
|
|
|
229
334
|
inverse-transpose, written beside uModel for this material's meshes;
|
|
230
335
|
take `mat3(uNormal)` - correct under non-uniform scale, where
|
|
231
336
|
mat3(uModel) bends normals off the surface). Attributes come from the
|
|
232
|
-
geometry's layout by name;
|
|
233
|
-
|
|
234
|
-
|
|
337
|
+
geometry's layout by name; the ones the linked program actually reads
|
|
338
|
+
(engine reflection, instance attributes excluded) must all be in the
|
|
339
|
+
mesh's geometry layout or add() throws - so a used `in vec4 aColor`
|
|
340
|
+
needs `withColors()` geometry and a custom channel needs
|
|
341
|
+
`withAttribute()`. One program per class, one pipeline per layout met. Sources without `#version` get the standard
|
|
235
342
|
pipeline preamble. App-driven uniforms beyond the standard set: seed
|
|
236
343
|
via `params`, then write per mesh with
|
|
237
344
|
`setMeshParams(mesh, { name: value })` (validated names; values persist
|
|
@@ -258,14 +365,15 @@ Instancing - one draw entry covering a population:
|
|
|
258
365
|
label? })` returns an ordinary Mesh whose entry draws the geometry once
|
|
259
366
|
per record. `records` is the interleaved per-instance data (stride = the
|
|
260
367
|
material's instanceAttributes summed, a mismatch throws), uploaded to a
|
|
261
|
-
mesh-owned buffer whose
|
|
368
|
+
mesh-owned buffer whose capacity starts at the records given. `count` picks how
|
|
262
369
|
many records draw (default all). Everything mesh works unchanged:
|
|
263
370
|
setTransform moves the whole population through one uModel, setVisible
|
|
264
371
|
zeroes the drawn count and restores the record count on unhide,
|
|
265
372
|
renderOrder/params/geometry/material swaps apply. `setInstances(mesh,
|
|
266
373
|
records, count?)` rewrites records from the start (count defaults to the
|
|
267
|
-
records written; more than capacity
|
|
268
|
-
|
|
374
|
+
records written; more than capacity GROWS: capacity doubles into a
|
|
375
|
+
replacement buffer, the entry is re-pointed via `setDrawBuffers`, the old
|
|
376
|
+
buffer is freed), `setInstanceCount(mesh, n)` is the population dial (clamped to capacity;
|
|
269
377
|
frame-rate-safe), and `disposeInstances(mesh)` detaches and frees the
|
|
270
378
|
record buffer - the one explicit free, geometry-buffer rule. Records are
|
|
271
379
|
opaque data (position/yaw/tint/whatever your shader reads), NOT matrices:
|
|
@@ -295,17 +403,121 @@ Lighting GLSL (`@solidrt/3d/glsl`): exported string constants composed
|
|
|
295
403
|
into shaderMaterial sources with plain template literals - `LIT_VERTEX`
|
|
296
404
|
(the standard vertex stage: clip position plus vWorldPos/vNormal/vUv
|
|
297
405
|
varyings, normals via mat3(uNormal)), `LIT_VERTEX_COLORED` (the same
|
|
298
|
-
plus the colored layout's aColor forwarded raw as vColor - using it
|
|
299
|
-
the material
|
|
406
|
+
plus the colored layout's aColor forwarded raw as vColor - using it makes
|
|
407
|
+
the material need that channel) and the pure functions `HEMISPHERE`
|
|
300
408
|
(`hemisphere(n, sky, ground)`), `LAMBERT` (`lambert(n, l)`),
|
|
301
409
|
`BLINN_SPECULAR` (`blinnSpecular(n, v, l, shininess)`), `FRESNEL`
|
|
302
|
-
(`fresnel(n, v, power)`)
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
410
|
+
(`fresnel(n, v, power)`), and the shadow trio composed IN ORDER:
|
|
411
|
+
`SHADOW_SLOTS` (the scene's shadow set: `uShadowMap0..N-1`,
|
|
412
|
+
`uShadowMatrix[N]`, `uShadowCast[N]`, `uShadowBias[N]`,
|
|
413
|
+
`uShadowNormalBias[N]`, slot i = directional light i), `SHADOW`
|
|
414
|
+
(`shadow(map, coord, bias)` - one map's 3x3 PCF factor) and
|
|
415
|
+
`SHADOW_LOOKUP` (`lightShadow(i, worldPos, n)` - light i's factor, 1
|
|
416
|
+
when it does not cast; it hides the if-chain that picks the map, since
|
|
417
|
+
GLSL ES 3.00 forbids dynamic sampler indexing). A receiving fragment
|
|
418
|
+
multiplies light i's term by `lightShadow(i, ...)`, exactly what `lit`
|
|
419
|
+
composes; a non-receiving one composes none of the three and declares no
|
|
420
|
+
samplers. Lights, colors and exponents are arguments, so
|
|
421
|
+
nothing is pinned but the function names; `lit` is composed from these
|
|
422
|
+
same constants - customizing never means leaving the system.
|
|
423
|
+
|
|
424
|
+
Lights and `lit`: lights are graph NODES, like Three. `createDirectionalLight({
|
|
425
|
+
direction?, color?, intensity? })` / `<DirectionalLight>` is parallel light
|
|
426
|
+
travelling along `direction` in the node's LOCAL space (default `[0, -1,
|
|
427
|
+
0]`, a sun overhead; length ignored), so a parent Group's rotation turns it
|
|
428
|
+
and position/scale do not matter - deliberately a direction, not Three's
|
|
429
|
+
position-minus-target. `createHemisphereLight({ sky?, ground?, intensity?
|
|
430
|
+
})` / `<HemisphereLight>` is the ambient term, a gradient by the WORLD
|
|
431
|
+
normal's tilt (fixed to world up, the node's transform is ignored); one per
|
|
432
|
+
scene, the last attached wins. Placement goes through setTransform, the
|
|
433
|
+
light's own fields through `setLight(light, { ... })` (frame-rate-safe,
|
|
434
|
+
like setMeshParams). At most `MAX_LIGHTS` (4, exported from `/glsl`)
|
|
435
|
+
directional lights per scene - the fifth throws at add(); it is a
|
|
436
|
+
shader-source constant, fixed per app. `uLightDir` is core-driven: each
|
|
437
|
+
directional light's slot is a spatial-core shared-slot sink following
|
|
438
|
+
the node's world rotation, so a MOVING light costs no JS. The sync
|
|
439
|
+
rewrites the rest whenever a light attaches, detaches or changes a
|
|
440
|
+
field -
|
|
441
|
+
`uHemiSky`/`uHemiGround` (vec3, intensity folded in), `uLightCount` (int),
|
|
442
|
+
`uLightDir[MAX_LIGHTS]`/`uLightColor[MAX_LIGHTS]` (world-space vector
|
|
443
|
+
TOWARD the light, normalized; intensity folded into the color) - so a
|
|
444
|
+
custom fragment declaring those names reads the same list, and a light
|
|
445
|
+
change costs one write however many meshes. Everything starts black: a
|
|
446
|
+
lit scene with no light shows nothing, on purpose, like Three.
|
|
447
|
+
|
|
448
|
+
`lit(opts)` is the standard look beside `unlit`: hemisphere ambient plus
|
|
449
|
+
the directional list, Lambert diffuse, Blinn-Phong highlight when
|
|
450
|
+
`specular` (0..1 strength) is set with `shininess` (default 30), the
|
|
451
|
+
same `color`/`map`/`transparent` as unlit, `vertexColors: true` to
|
|
452
|
+
multiply by the colored layout's aColor (so the geometry must carry it),
|
|
453
|
+
and `triplanar: n` to sample `map` by world position at `n` repeats per
|
|
454
|
+
world unit, blended across the three axis planes by the normal. Triplanar
|
|
455
|
+
is an OPTION, not the default: generators emit 0..1 UVs per face, so a
|
|
456
|
+
map on a plane is a decal (UV) while a map on generated scenery wants one
|
|
457
|
+
density across parts of any size (triplanar); the map must be created
|
|
458
|
+
with `wrap: "repeat"`. Internally one `shaderMaterialClass` per option
|
|
459
|
+
combination (map x vertexColors x triplanar x transparent), cached for
|
|
460
|
+
the app's lifetime, one pipeline per vertex layout - a thousand lit
|
|
461
|
+
meshes share one program. The view vector comes from the shared uCamPos;
|
|
462
|
+
`uTriplanar` is declared only by the triplanar classes so the other
|
|
463
|
+
classes do not warn about an inactive uniform.
|
|
464
|
+
|
|
465
|
+
## Models
|
|
466
|
+
|
|
467
|
+
Authored models come in as glTF 2.0 (.gltf with its .bin and image files
|
|
468
|
+
next to it, or single-file .glb) and become a Group of meshes, Three's
|
|
469
|
+
`gltf.scene`. Three layers, use the lowest that fits:
|
|
470
|
+
|
|
471
|
+
- `parseGltf(bytes, resolve?)` - the pure parser (no engine, runs under
|
|
472
|
+
bun and on flux): `ModelData` = `parts` (one per mesh node, its NAME
|
|
473
|
+
kept, vertices in the standard layout with the node's WORLD transform
|
|
474
|
+
baked in), `materials` (base color factor, `map` = index into `images`,
|
|
475
|
+
`doubleSided`, `transparent` = alphaMode BLEND), `images` (the encoded
|
|
476
|
+
PNG/JPEG bytes, undecoded) and `bounds`. A .gltf's external files come
|
|
477
|
+
through `resolve(uri)` (uri as written, still percent-encoded;
|
|
478
|
+
`gltfExternalUris(bytes)` lists them so an async caller can read them
|
|
479
|
+
first); .glb and data: uris need none. Missing normals produce FLAT
|
|
480
|
+
shading (the spec's rule): the primitive is un-indexed, one vertex per
|
|
481
|
+
corner. A mirroring node flips the winding so `cull: "back"` still
|
|
482
|
+
keeps the outside. Non-triangle primitives are skipped; a required
|
|
483
|
+
extension the parser does not implement throws naming it, and Draco or
|
|
484
|
+
meshopt compression throws "re-export without mesh compression" -
|
|
485
|
+
Blender exports Draco by DEFAULT, so that is the first error a real
|
|
486
|
+
file hits.
|
|
487
|
+
- `createModel(data, { material?, label? })` - uploads the images (repeat
|
|
488
|
+
wrap, mipmapped), makes one material per glTF material (default `lit({
|
|
489
|
+
color, map, transparent })`; pass `material(m, map)` for anything else,
|
|
490
|
+
it is called once per material and shared), one mesh per part, all
|
|
491
|
+
children of the returned `Model` (a Group): `add(scene.root, model)`,
|
|
492
|
+
place it with `setTransform`, find parts by name in `model.parts`
|
|
493
|
+
(`{ name, mesh }`), `model.bounds` for framing a camera. `dispose()`
|
|
494
|
+
detaches it and frees the geometry buffers and textures - the model owns
|
|
495
|
+
them, nothing else frees them.
|
|
496
|
+
- `loadGltf(path)` / `loadModel(path)` - read from `assets/` with flux:fs
|
|
497
|
+
and build. `loadModel` reads the baked `.srtm` written by `srt tool
|
|
498
|
+
3d/model <in.gltf|glb> -o assets/<name>.srtm`: the same parse run once
|
|
499
|
+
under bun, stored in the GPU layout, so loading is views onto the file's
|
|
500
|
+
bytes plus the image decodes. Numbers from a 32k-vertex, 6-texture model
|
|
501
|
+
on a release client: `parseGltf` 124 ms on flux (22 ms under bun) against
|
|
502
|
+
40 ms for the whole baked load - the runtime parse is fine for small
|
|
503
|
+
models and a binary import (`import bytes from "./x.glb" with { type:
|
|
504
|
+
"binary" }` then `createModel(parseGltf(bytes))`, see
|
|
505
|
+
`examples/model.tsx`); bake anything big.
|
|
506
|
+
|
|
507
|
+
Not in the subset, reported or dropped: `doubleSided` is reported and NOT
|
|
508
|
+
applied (the standard materials cull back faces); vertex colors, tangents
|
|
509
|
+
and further UV sets are dropped; samplers are ignored (every texture
|
|
510
|
+
repeats); alphaMode MASK draws opaque; emissive/additive parts of a model
|
|
511
|
+
draw as their base color (a model's "glow" cards come out as dark wedges).
|
|
512
|
+
The follow-ups are filed in okf/backlog/3d-model-loader.md.
|
|
306
513
|
|
|
307
514
|
## Traps
|
|
308
515
|
|
|
516
|
+
- A model's vertices are in WORLD space at parse time (node transforms
|
|
517
|
+
baked), so `model.bounds` and each part's geometry already include the
|
|
518
|
+
file's placement; the Model group starts at identity and `setTransform`
|
|
519
|
+
on it moves the whole thing. Parts cannot be moved relative to their
|
|
520
|
+
glTF parent - that is the retained-hierarchy follow-up, not a bug.
|
|
309
521
|
- The y-down clip flip is baked into `perspective()`; scene code and
|
|
310
522
|
geometry are plain y-up right-handed, and CCW-outward winding culls
|
|
311
523
|
correctly with `cull: "back"`. Do NOT negate y anywhere else, and do not
|
|
@@ -316,17 +528,18 @@ system.
|
|
|
316
528
|
when it is instanced - never a bare 1 into an instanced entry. Hidden
|
|
317
529
|
meshes skip uModel writes; the fresh matrix is
|
|
318
530
|
written on unhide. A freshly attached entry starts off the same way and
|
|
319
|
-
|
|
320
|
-
world matrix yet, and drawn before the sync microtask it
|
|
321
|
-
world origin for a frame.
|
|
531
|
+
the core's flush turns it on when it writes uModel - never add one
|
|
532
|
+
live: it has no world matrix yet, and drawn before the sync microtask it
|
|
533
|
+
flashes at the world origin for a frame.
|
|
322
534
|
- Instancing pairs strictly at add(), like layout: an instanced material
|
|
323
535
|
needs a createInstancedMesh mesh (records included) and vice versa, and
|
|
324
536
|
the record stride must match the material's attributes - each mismatch
|
|
325
537
|
throws there. The instance buffer is MESH-owned (unlike shared geometry
|
|
326
538
|
buffers): `disposeInstances` is its one free, and the mesh cannot be
|
|
327
|
-
re-added afterwards. Capacity
|
|
328
|
-
|
|
329
|
-
|
|
539
|
+
re-added afterwards. Capacity grows by REPLACEMENT, never resize:
|
|
540
|
+
`setInstances` past capacity doubles (at least to the records written)
|
|
541
|
+
into a new buffer and swaps it in - amortized like a dynamic array, same
|
|
542
|
+
policy as @solidrt/2d; size the initial records to skip the copies.
|
|
330
543
|
- An instanced mesh without explicit `bounds` has no BVH leaf: it never
|
|
331
544
|
picks, pointer events never target it, and its transparent sort key
|
|
332
545
|
falls back to the node's world position. That is deliberate - records
|
|
@@ -427,7 +640,34 @@ system.
|
|
|
427
640
|
target state), independent of mesh count - never reintroduce per-mesh
|
|
428
641
|
camera writes (uEye-style per-mesh params are exactly the O(scene) cost
|
|
429
642
|
the shared channel removed). Scene scale honestly: hundreds to a
|
|
430
|
-
few thousand objects, bounded by the interpreter, not the GPU.
|
|
643
|
+
few thousand objects, bounded by the interpreter, not the GPU. A view
|
|
644
|
+
is one more such write per camera change and one more entry per mesh
|
|
645
|
+
at attach; a view's per-frame cost is the core's (one params write per
|
|
646
|
+
sink per moved node), never JS.
|
|
647
|
+
- A CASTING light's position matters (nothing else about a directional
|
|
648
|
+
light's position does): the shadow camera is placed AT the light node's
|
|
649
|
+
world position, Three's rule, so a `castShadow` sun at the origin
|
|
650
|
+
pointing down shadows nothing above it - give it a `position` above the
|
|
651
|
+
scene and a frustum (`shadow.camera`) that covers the casters. Acne
|
|
652
|
+
knobs are Three's: `shadow.bias` (map depth units) and
|
|
653
|
+
`shadow.normalBias` (world units along the receiver normal, the one to
|
|
654
|
+
reach for first, ~0.02); the depth pass culls FRONT faces (Three's
|
|
655
|
+
shadowSide default), so closed casters need little bias but a
|
|
656
|
+
single-sided plane casts nothing. Opting out of receiving is on the
|
|
657
|
+
MATERIAL here (`receiveShadow: false`), not the object (Three's
|
|
658
|
+
`mesh.receiveShadow`) - Godot's split, and URP's - and instanced
|
|
659
|
+
meshes never cast (the depth override cannot know their records) - the
|
|
660
|
+
additive follow-up is a per-class `shadowVertex`. Every casting light
|
|
661
|
+
is a full extra pass over the casters plus a sampler unit on every
|
|
662
|
+
receiving program (MAX_LIGHTS of those are always bound, placeholders
|
|
663
|
+
included), so cast from the lights that matter, not all of them.
|
|
664
|
+
- A mesh's entries are mirrored into every view at attach and dropped at
|
|
665
|
+
detach; `setGeometry`/`setMaterial` rebuild them everywhere. An
|
|
666
|
+
`overrideMaterial` is validated against every mesh's layout (at
|
|
667
|
+
createView for the meshes present, at add() for later ones) exactly like
|
|
668
|
+
a mesh's own material, so an override reading `aColor` throws for a
|
|
669
|
+
standard-layout mesh. Views are disposed by the scene; `view.dispose()`
|
|
670
|
+
only for dropping one early.
|
|
431
671
|
- SCENE-WIDE uniforms go through that same shared channel via
|
|
432
672
|
`scene.setParams({ uTime })`, and this is the single highest-leverage
|
|
433
673
|
pattern in the library. It merges an app-owned name in beside
|
|
@@ -473,18 +713,19 @@ system.
|
|
|
473
713
|
a cache. A class instance has no `dispose` of its own; disposing the
|
|
474
714
|
class invalidates every instance.
|
|
475
715
|
- A parameterised class whose variants (mapped/unmapped, ...) are SEPARATE
|
|
476
|
-
classes
|
|
477
|
-
|
|
478
|
-
|
|
716
|
+
classes may seed every variant with one param/texture object: a uniform
|
|
717
|
+
a variant declares but does not use compiles out, and the engine then
|
|
718
|
+
accepts the write with a warning and skips it. A name no variant
|
|
719
|
+
DECLARES still throws at add().
|
|
479
720
|
- The standard-set contract is checked TEXTUALLY at shaderMaterial()
|
|
480
721
|
creation (uModel and uViewProj must appear in the vertex source) and
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
shared names have no such backstop - a declared-but-unused
|
|
485
|
-
uCamPos is skipped silently (shared params tolerate zero
|
|
486
|
-
the symptom is an untransformed or unlit render, not an
|
|
487
|
-
you declare.
|
|
722
|
+
at add() for the per-entry names: a uModel or uNormal that is declared
|
|
723
|
+
but never USED compiles out, and the scene's entry seed is then skipped
|
|
724
|
+
with an engine warning (the engine rejects only names the program never
|
|
725
|
+
declared). The shared names have no such backstop - a declared-but-unused
|
|
726
|
+
uViewProj or uCamPos is skipped silently (shared params tolerate zero
|
|
727
|
+
coverage), so the symptom is an untransformed or unlit render, not an
|
|
728
|
+
error. Use what you declare.
|
|
488
729
|
- The layout scan is textual the same way: any `aColor` token in the
|
|
489
730
|
vertex source - a comment counts - selects the "colored" layout, and
|
|
490
731
|
the material then rejects standard geometry at add(). Do not mention
|
|
@@ -496,11 +737,11 @@ system.
|
|
|
496
737
|
interpreter-hostile; that tier is core work (BVH descent per the
|
|
497
738
|
differentiators ladder).
|
|
498
739
|
- `scene.handlers` vs `handlersFor`: localX/localY arrive in the leaf's
|
|
499
|
-
LAYOUT frame (every ancestor transform and
|
|
740
|
+
LAYOUT frame (every ancestor transform and design-size fit is already
|
|
500
741
|
undone by the element hit test). `handlers` therefore assumes leaf
|
|
501
742
|
layout == target pixels; scaling by `getBoundingBox` would be WRONG -
|
|
502
743
|
the box composes transforms, and it would double-correct the built-in
|
|
503
|
-
leaf under a
|
|
744
|
+
leaf under a design size. Only a leaf whose layout size deliberately
|
|
504
745
|
differs from the target (supersampling) needs `handlersFor`, fed the
|
|
505
746
|
layout size the app itself set.
|
|
506
747
|
- Hover (enter/leave) reacts to pointer MOTION only: a mesh animating
|
package/README.md
CHANGED
|
@@ -77,18 +77,22 @@ blinn, fresnel, a standard vertex stage) to compose your own lit looks
|
|
|
77
77
|
from plain template literals.
|
|
78
78
|
|
|
79
79
|
v1 scope: unlit color/textured materials plus `shaderMaterial` (your own
|
|
80
|
-
GLSL as a first-class material),
|
|
80
|
+
GLSL as a first-class material), sprites (`<Sprite>` with a `sprite()`
|
|
81
|
+
material: a quad facing the camera in the vertex stage, full or fixed-y
|
|
82
|
+
billboarding), geometry generators (box, plane, circle,
|
|
81
83
|
ring, sphere, cylinder, cone, torus, torus knot), a profile kit for custom
|
|
82
84
|
solids (`extrude` with bevels, `lathe`, polyline `sweep`/`tube` with
|
|
83
85
|
mitred joints, flat `shape`, with `fillet`/`roundRect`/`triangulate`
|
|
84
86
|
helpers), geometry as data (`transformGeometry` bakes a placement into
|
|
85
87
|
vertices and `mergeGeometries` concatenates parts, so a static scene is
|
|
86
|
-
one mesh per material),
|
|
87
|
-
|
|
88
|
-
|
|
88
|
+
one mesh per material), open vertex layouts (`withAttribute` appends any
|
|
89
|
+
named channel to a geometry's interleave, `withColors` is the `aColor`
|
|
90
|
+
vec4 case - tint, baked AO, any four scalars - and materials read channels
|
|
91
|
+
by name), one perspective camera
|
|
89
92
|
with an orbit control (`createOrbitCamera`: drag, pinch/wheel zoom, auto-orbit),
|
|
90
|
-
mesh picking with pointer events,
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
[
|
|
93
|
+
mesh picking with pointer events, scene backgrounds, transparency, and
|
|
94
|
+
lights (`lit` material with hemisphere ambient, up to four directional
|
|
95
|
+
light nodes, Blinn-Phong highlight and triplanar mapping).
|
|
96
|
+
Model loading and shadows are staged next - see `okf/notes/3d-roadmap.md`
|
|
97
|
+
for the ranked list. Full usage notes and traps: [AGENTS.md](AGENTS.md);
|
|
98
|
+
runnable examples: [examples/](examples/).
|