@solidrt/3d 0.0.56 → 0.0.58
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +1052 -639
- package/README.md +2 -1
- package/examples/README.md +19 -8
- package/examples/collision.tsx +17 -4
- package/examples/first-person.tsx +20 -10
- package/examples/fleet.tsx +11 -5
- package/examples/model.tsx +3 -3
- package/examples/pick.tsx +144 -34
- package/examples/probe.tsx +10 -4
- package/examples/scene-views.tsx +49 -58
- package/examples/sky-lit.tsx +10 -4
- package/examples/skybox.tsx +10 -4
- package/examples/standard.tsx +10 -4
- package/examples/wireframe.tsx +107 -0
- package/package.json +2 -2
- package/src/collision.ts +14 -193
- package/src/components/context.tsx +35 -0
- package/src/components/directional-light.tsx +48 -0
- package/src/components/first-person-camera.tsx +72 -0
- package/src/components/group.tsx +18 -0
- package/src/components/hemisphere-light.tsx +24 -0
- package/src/components/index.ts +46 -0
- package/src/components/instanced-mesh.tsx +105 -0
- package/src/components/mesh.tsx +92 -0
- package/src/components/node-props.ts +90 -0
- package/src/components/orbit-camera.tsx +75 -0
- package/src/components/perspective-camera.tsx +32 -0
- package/src/components/point-light.tsx +52 -0
- package/src/components/record-mesh.tsx +68 -0
- package/src/components/scene.tsx +281 -0
- package/src/components/spot-light.tsx +65 -0
- package/src/components/sprite.tsx +38 -0
- package/src/components/view3d.tsx +152 -0
- package/src/first-person.ts +140 -188
- package/src/geometry-gpu.ts +45 -13
- package/src/geometry.ts +186 -5
- package/src/gltf.ts +97 -16
- package/src/index.ts +15 -10
- package/src/input.ts +79 -0
- package/src/material.ts +26 -8
- package/src/mesh.ts +5 -6
- package/src/model.ts +65 -33
- package/src/node.ts +100 -26
- package/src/orbit.ts +266 -235
- package/src/scene-pointer.ts +229 -101
- package/src/scene.ts +256 -79
- package/src/skeleton.ts +211 -0
- package/src/components.tsx +0 -1101
package/AGENTS.md
CHANGED
|
@@ -6,248 +6,381 @@ 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
7
|
blendMode and pointer events like any element.
|
|
8
8
|
|
|
9
|
+
The layer below is worth knowing by name: `@solidrt/core`'s
|
|
10
|
+
`examples/gpu-*.tsx` are the raw pipeline vocabulary this package
|
|
11
|
+
composes - `gpu-particles.tsx` (points topology, `gl_PointSize` /
|
|
12
|
+
`gl_PointCoord` splats, additive blend), `gpu-instancing.tsx`
|
|
13
|
+
(`instanceCount` and `gl_InstanceID`), `gpu-pipeline.tsx` (a custom
|
|
14
|
+
vertex + fragment pair over an interleaved buffer), `gpu-draw-list.tsx`
|
|
15
|
+
(a mutable list of draws sharing one depth buffer), `gpu-raw-program.tsx`
|
|
16
|
+
(compile/link/pipeline by hand). What the GPU layer can do is answered
|
|
17
|
+
there, not here.
|
|
18
|
+
|
|
19
|
+
Contents:
|
|
20
|
+
|
|
21
|
+
- [The model](#the-model)
|
|
22
|
+
- [Two layers: core and components](#two-layers-core-and-components)
|
|
23
|
+
- [Node lifecycle](#node-lifecycle)
|
|
24
|
+
- [Rendering is the runtime's](#rendering-is-the-runtimes)
|
|
25
|
+
- [Views and layers](#views-and-layers)
|
|
26
|
+
- [Culling](#culling)
|
|
27
|
+
- [Shadows](#shadows)
|
|
28
|
+
- [Retargeted motion](#retargeted-motion)
|
|
29
|
+
- [Geometry layout, indices and topology](#geometry-layout-indices-and-topology)
|
|
30
|
+
- [Material classes](#material-classes)
|
|
31
|
+
- [Pure pieces and checks](#pure-pieces-and-checks)
|
|
32
|
+
- [Components](#components)
|
|
33
|
+
- [Component props](#component-props)
|
|
34
|
+
- [Output composition](#output-composition)
|
|
35
|
+
- [Fill and fixed sizes](#fill-and-fixed-sizes)
|
|
36
|
+
- [Input](#input)
|
|
37
|
+
- [Orbit camera](#orbit-camera)
|
|
38
|
+
- [First-person camera](#first-person-camera)
|
|
39
|
+
- [Overlay projection](#overlay-projection)
|
|
40
|
+
- [Picking](#picking)
|
|
41
|
+
- [Collision](#collision)
|
|
42
|
+
- [Pointer events](#pointer-events)
|
|
43
|
+
- [Geometry generators](#geometry-generators)
|
|
44
|
+
- [Geometry as data](#geometry-as-data)
|
|
45
|
+
- [Profile kit](#profile-kit)
|
|
46
|
+
- [Materials](#materials)
|
|
47
|
+
- [Instancing](#instancing)
|
|
48
|
+
- [Background](#background)
|
|
49
|
+
- [Cube-map convention](#cube-map-convention)
|
|
50
|
+
- [Environment](#environment)
|
|
51
|
+
- [Reflection probes](#reflection-probes)
|
|
52
|
+
- [Baked sky](#baked-sky)
|
|
53
|
+
- [Panoramas](#panoramas)
|
|
54
|
+
- [Fog](#fog)
|
|
55
|
+
- [Color](#color)
|
|
56
|
+
- [Lighting GLSL](#lighting-glsl)
|
|
57
|
+
- [Custom looks: three tiers](#custom-looks-three-tiers)
|
|
58
|
+
- [Lights](#lights)
|
|
59
|
+
- [lit](#lit)
|
|
60
|
+
- [standard](#standard)
|
|
61
|
+
- [Models](#models)
|
|
62
|
+
- [Three layers](#three-layers)
|
|
63
|
+
- [Async loading](#async-loading)
|
|
64
|
+
- [Placement and sockets](#placement-and-sockets)
|
|
65
|
+
- [Wardrobe pieces](#wardrobe-pieces)
|
|
66
|
+
- [Applied glTF material fields](#applied-gltf-material-fields)
|
|
67
|
+
- [Animation](#animation)
|
|
68
|
+
- [Root motion](#root-motion)
|
|
69
|
+
- [Skins](#skins)
|
|
70
|
+
- [Not in the subset](#not-in-the-subset)
|
|
71
|
+
- [Traps](#traps)
|
|
72
|
+
- [Models and skinning](#models-and-skinning)
|
|
73
|
+
- [Materials and color](#materials-and-color)
|
|
74
|
+
- [Environment and background](#environment-and-background)
|
|
75
|
+
- [Coordinates and rotation](#coordinates-and-rotation)
|
|
76
|
+
- [Visibility and instancing](#visibility-and-instancing)
|
|
77
|
+
- [Shadows](#shadows)
|
|
78
|
+
- [Views and scene params](#views-and-scene-params)
|
|
79
|
+
- [Shader materials](#shader-materials)
|
|
80
|
+
- [Picking, pointer and collision](#picking-pointer-and-collision)
|
|
81
|
+
- [Geometry and components](#geometry-and-components)
|
|
82
|
+
|
|
9
83
|
## The model
|
|
10
84
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
85
|
+
### Two layers: core and components
|
|
86
|
+
|
|
87
|
+
Two layers. The imperative core is Solid-free: `createScene`,
|
|
88
|
+
`createMesh(geometry, material)`, `add`/`remove`, `setTransform`,
|
|
89
|
+
`lookAt`, `getRotation`, `setVisible`, `setRenderOrder` - plain objects
|
|
90
|
+
over the spatial core (`flux:spatial`, `alloy/src/spatial/`): every node
|
|
91
|
+
in a scene has a core node, JS keeps the LOCAL position/quaternion/scale
|
|
92
|
+
as the readable truth and forwards each write, and the core's flush
|
|
93
|
+
(one call per microtask) recomputes only the moved subtrees and writes
|
|
94
|
+
each entry's uModel (plus uNormal for materials declaring it) and its
|
|
95
|
+
visibility switch itself - a move costs its subtree, never the scene.
|
|
96
|
+
ONE `setTargetParams` (the shared uViewProj + uCamPos) per camera
|
|
97
|
+
change, however many meshes. World matrices live in the core only:
|
|
98
|
+
`worldPosition`/`lookAt`/picking read them back (`worldMatrix`, pending
|
|
99
|
+
writes included). See okf/backlog/spatial-core.md for what still runs
|
|
100
|
+
in JS and why. The component face (`Scene`/`View3d`/`Group`/`Mesh`/
|
|
101
|
+
`PerspectiveCamera`) syncs props into that core over context and renders
|
|
102
|
+
nothing itself.
|
|
103
|
+
|
|
104
|
+
### Node lifecycle
|
|
105
|
+
|
|
106
|
+
Node lifecycle: `add(parent, child)` attaches (re-parenting detaches
|
|
107
|
+
first), `remove(child)` DETACHES an intact subtree - children stay
|
|
108
|
+
under the removed node, core nodes free on leave and recreate on
|
|
109
|
+
re-enter, so a removed subtree re-adds cleanly. Nothing is destroyed
|
|
110
|
+
except instance record buffers (`disposeInstances`) and the scene
|
|
111
|
+
itself. (@solidrt/2d's `removeGroup` DESTROYS its subtree instead - a
|
|
112
|
+
sprite cannot exist outside its layer, so there remove means destroy.)
|
|
113
|
+
An instance node (addInstance on an instanced mesh) follows the 2d
|
|
114
|
+
rule: slot-bound to its mesh, `removeInstance` destroys it, and the
|
|
115
|
+
generic add/remove throw on it.
|
|
116
|
+
|
|
117
|
+
### Rendering is the runtime's
|
|
118
|
+
|
|
119
|
+
Rendering is the runtime's. The target is `render: "auto"`: it
|
|
120
|
+
re-renders when entries change, so a STATIC scene costs zero passes and
|
|
121
|
+
the library registers no frame loop. Continuous animation is the app's
|
|
122
|
+
own `onFrame` writing a signal (declarative) or `setTransform` on a
|
|
123
|
+
`ref`-grabbed node (the frame-rate escape hatch - signals carry
|
|
124
|
+
structure, per-frame motion goes straight to the scene).
|
|
125
|
+
|
|
126
|
+
### Views and layers
|
|
127
|
+
|
|
128
|
+
VIEWS: `scene.createView({ width, height, overrideMaterial?, depth?,
|
|
129
|
+
clearColor?, ... })` renders the same scene into a second target from
|
|
130
|
+
its own camera (`view.setCamera`, the scene's CameraUpdate shape; in a
|
|
131
|
+
component tree `<View3d>` is the same as a Scene child, see Components).
|
|
132
|
+
Each mesh gets one entry in the view's target bound as one more draw sink of
|
|
133
|
+
its CORE node, so the one flush writes every target - the app writes
|
|
134
|
+
nothing per view. Geometry buffers and (without an override) materials
|
|
135
|
+
are shared; the light set and `scene.setParams` names fan out to every
|
|
136
|
+
view, `view.setParams` is the view's own channel - and names a view
|
|
137
|
+
sets itself (or its `fog` option, below) become VIEW-OWNED: the
|
|
138
|
+
scene's setParams/setFog fan-out skips them from then on, so a view
|
|
139
|
+
override survives scene-wide writes. A view's backdrop is its
|
|
140
|
+
clearColor (the scene background draws on PROBES, not views); a view
|
|
141
|
+
has no picking. LAYERS select what a target
|
|
142
|
+
draws, Three's model exactly: `layers` on a mesh is its membership
|
|
143
|
+
bitmask (default 1, `setLayers`/the `layers` prop, NOT inherited from
|
|
144
|
+
Groups), and each target carries a mask (default 1) - `layers` on
|
|
145
|
+
createScene/createView, live via `setLayers` on the scene handle and
|
|
146
|
+
each view. A mesh draws where mask & layers is non-zero, so a minimap's
|
|
147
|
+
marker meshes live on bit 2: invisible in the main render, drawn by
|
|
148
|
+
the map view whose mask admits them. Shadow views follow the SCENE's
|
|
149
|
+
mask (what the scene cannot see must not darken it), and
|
|
150
|
+
pick()/raycast()/overlap()/sweep() skip scene-masked-out meshes like
|
|
151
|
+
invisible ones - unless the query passes its own `{ layers }`, which is
|
|
152
|
+
how a low-poly collision mesh lives undrawn in the scene yet answers
|
|
153
|
+
ground and collision queries (the physics-collider pattern).
|
|
154
|
+
Per-view fog: `fog: FogOptions | null` on createView overrides the
|
|
155
|
+
scene's fog for that view (null = unfogged - the clear minimap over a
|
|
156
|
+
fogged scene); absent follows the scene. `overrideMaterial` (Three's
|
|
157
|
+
`scene.overrideMaterial`, scoped to the view) draws every mesh with one
|
|
158
|
+
material - a depth pass, a normal/id visualizer - skips instanced
|
|
159
|
+
meshes (unless the override itself declares their exact
|
|
160
|
+
`instanceAttributes` record layout) and draws in add order. `depth: "texture"` exposes `view.depthTexture`, the shadow-map
|
|
161
|
+
input; the same option on createScene exposes `scene.depthTexture`,
|
|
162
|
+
the input for a depth-reading post effect in `output` (not combinable
|
|
163
|
+
with `samples` - no multisampled sampleable depth). `ortho: { left, right, top, bottom }` on any camera swaps
|
|
164
|
+
perspective for `orthographic()` (`fov` ignored; `ortho: null` returns);
|
|
165
|
+
the scene's own camera takes it too, and pick() follows.
|
|
166
|
+
`examples/scene-views.tsx` is the shape.
|
|
167
|
+
|
|
168
|
+
### Culling
|
|
169
|
+
|
|
170
|
+
CULLING is the core's, per target, on by default: every camera write
|
|
171
|
+
(the scene's, a view's, a shadow tile's) also sets that target's
|
|
172
|
+
frustum in the spatial core, and the flush switches an entry whose
|
|
173
|
+
world box falls wholly outside it to instance count 0 - the same
|
|
174
|
+
switch as `visible`, so a culled mesh costs nothing per frame and a
|
|
175
|
+
still camera re-tests nothing (a camera move re-tests every sink on
|
|
176
|
+
that target, in Rust; a node move re-tests its own). The box is the
|
|
177
|
+
picking box (the local bounds through the world matrix, the
|
|
178
|
+
Godot/Unity AABB test; Three uses spheres); an instanced mesh without
|
|
179
|
+
explicit `bounds` is culled by the union of its live instances' boxes
|
|
180
|
+
(the joint group below over the instance nodes, so the box follows
|
|
181
|
+
them: Godot's MultiMesh AABB, with `bounds` as its custom_aabb; Three
|
|
182
|
+
computes an InstancedMesh sphere once and leaves it stale, Unity wants
|
|
183
|
+
worldBounds), and a record mesh without `bounds` is never culled. Per
|
|
184
|
+
mesh: `frustumCulled: false` (Three's name; `setCulling`) for geometry
|
|
185
|
+
a vertex stage moves beyond its box - a fullscreen quad, a custom
|
|
186
|
+
displacement - and `cullMargin` (world units, Godot's
|
|
187
|
+
`extra_cull_margin`) for bounded displacement such as wind. Sprites
|
|
188
|
+
cull by their quad's reach at any facing. A SKINNED part is culled by
|
|
189
|
+
the union of its joints' boxes (the bake computes each joint's
|
|
190
|
+
influence box in joint space, `ModelSkin.jointBounds`, .srtm VERSION
|
|
191
|
+
5; the joint nodes carry them as culling-only bounds, outside the
|
|
192
|
+
picking index), so the box follows the pose with no per-frame JS -
|
|
193
|
+
Unity's bone bounds, Godot's per-bone AABBs; no `updateWhenOffscreen`
|
|
194
|
+
knob is needed. Probe faces set no frustum (six cameras, one target).
|
|
195
|
+
Shadow tiles cull casters against their light frustum, which is what
|
|
196
|
+
makes `shadow.distance` and cascades cheaper.
|
|
197
|
+
|
|
198
|
+
### Shadows
|
|
199
|
+
|
|
200
|
+
SHADOWS are a view: `<DirectionalLight castShadow shadow={{ mapSize?,
|
|
201
|
+
bias?, normalBias?, radius?, camera? }}>` (`createDirectionalLight({ castShadow,
|
|
202
|
+
shadow })`, `setLight`) makes the scene own an internal
|
|
203
|
+
`createView({ depth: "texture", overrideMaterial: depth pass })` drawing
|
|
204
|
+
the `castShadow` meshes (`<Mesh castShadow>`, `setCastShadow`) from an
|
|
205
|
+
orthographic camera at the light's WORLD position along its world
|
|
206
|
+
direction, `shadow.camera` (+-5, 0.5..500 by default) as the frustum.
|
|
207
|
+
Any light may cast, bounded by the shadow-slot budget
|
|
208
|
+
(MAX_SHADOW_MAPS = 8, its own constant: a directional light claims
|
|
209
|
+
`shadow.cascades` consecutive slots, a point light six, a spot one,
|
|
210
|
+
and a caster past the budget throws at attach). `<SpotLight castShadow
|
|
211
|
+
shadow={{ mapSize?, bias?, normalBias?, near? }}>` is the same
|
|
212
|
+
machinery with a PERSPECTIVE camera: at the light's world position
|
|
213
|
+
along its world direction, fov = its cone (2 * angle), near from
|
|
214
|
+
`shadow.near` (default 0.5), far from the light's `distance` (or the
|
|
215
|
+
directional default 500 when 0) - one map, one slot, the same atlas
|
|
216
|
+
and lookup. A perspective map's depth is nonlinear, so `normalBias`
|
|
217
|
+
(world units) is the acne knob to reach for; `bias` acts in that
|
|
218
|
+
nonlinear depth. `<PointLight castShadow>` casts in every direction
|
|
219
|
+
with the same option set: six 90-degree face maps (world-axis
|
|
220
|
+
aligned, slot order +X, -X, +Y, -Y, +Z, -Z) as six consecutive tiles
|
|
221
|
+
of the same atlas, far from `distance` like a spot - so give a
|
|
222
|
+
casting bulb a distance. No cube map: a receiver picks the face by
|
|
223
|
+
the dominant axis of the light-to-point vector (SHADOW_LOOKUP), one
|
|
224
|
+
projection, one hardware-compare tap - the Three/Godot/Unity-URP
|
|
225
|
+
atlas route. `shadow.radius` (every casting light, default 1 = that
|
|
226
|
+
one tap, a 2x2 bilinear compare) softens the edge: above 1 a 3x3 grid
|
|
227
|
+
of hardware taps `radius` texels apart, Three's `shadow.radius` /
|
|
228
|
+
Godot's `shadow_blur`, nine taps per receiver fragment. A first-person
|
|
229
|
+
eye over a floor shows the texel stairs at any map size; 2 is the
|
|
230
|
+
usual figure, past ~3 the taps separate into bands. Each face map renders a few degrees wider than its face
|
|
231
|
+
(URP's fovBias) so a seam fragment's occluder is inside the map it
|
|
232
|
+
samples - without the guard band every seam shows a lit slit - and
|
|
233
|
+
PCF taps clamp at face-tile edges, so a face seam hardens slightly
|
|
234
|
+
instead of bleeding into the neighbour.
|
|
235
|
+
`shadow: { cascades: N }` (1..MAX_CASCADES = 4) replaces the box with
|
|
236
|
+
N maps fitted to slices of the SCENE camera's frustum (near ..
|
|
237
|
+
`shadow.distance`, default the camera far; the practical split; each
|
|
238
|
+
slice's bounding sphere as an ortho box along the light, its centre
|
|
239
|
+
snapped to the map's texel grid so edges do not swim; re-fitted
|
|
240
|
+
whenever the scene camera or the light moves) - a receiver samples the
|
|
241
|
+
tightest map that covers the point, fading into the next over the
|
|
242
|
+
map's outer 10% (`SHADOW_BLEND`) so the hand-over is a band, not a
|
|
243
|
+
seam; contact shadows stay sharp near the camera while the horizon
|
|
244
|
+
still has coarse ones, and pulling `distance` in sharpens all of them.
|
|
245
|
+
The box is the honest tier for a bounded scene; cascades are for a
|
|
246
|
+
scene that outgrows it, at N times the shadow fill. Every map is a
|
|
247
|
+
TILE of the scene's one shadow atlas (a `depth: "texture"` draw target,
|
|
248
|
+
a grid of cells the largest `mapSize` wide, scaled down uniformly
|
|
249
|
+
against `limits.maxTextureSize`), so N maps are ONE pass: the atlas
|
|
250
|
+
depth binds as the target-level `uShadowAtlas` of the scene and every
|
|
251
|
+
non-shadow view (a white texel when nothing casts); maps are MAP slots
|
|
252
|
+
dealt in light order, a light's cascades consecutive and tightest
|
|
253
|
+
first - `uShadowRect[j]` slot j's tile in atlas UV, `uShadowMatrix[j]`
|
|
254
|
+
its view's own view-projection (the whole array is one write per
|
|
255
|
+
shadow-camera move) - and per light i `uShadowFirst[i]`/`uShadowCount[i]`
|
|
256
|
+
name its slots (count 0 = it does not cast) with
|
|
257
|
+
`uShadowBias[i]`/`uShadowNormalBias[i]` its knobs; `SHADOW_SLOTS` in
|
|
258
|
+
glsl declares the set. Every `lit` material RECEIVES by default
|
|
259
|
+
(Godot's and Three's default); `lit({ receiveShadow: false })` opts a
|
|
260
|
+
material out and drops the map from its program - a material option,
|
|
261
|
+
as with vertexColors/triplanar, because the material picks the program
|
|
262
|
+
(Godot's `disable_receive_shadows`). The factor is `SHADOW`'s one
|
|
263
|
+
hardware-compare tap (sampler2DShadow, LEQUAL, the driver's 2x2 PCF)
|
|
264
|
+
on each casting light's own term. `examples/shadows.tsx` (three
|
|
265
|
+
casting lights) is the shape; `examples/cascades.tsx` the cascaded sun.
|
|
266
|
+
|
|
267
|
+
### Retargeted motion
|
|
268
|
+
|
|
269
|
+
RETARGETED motion is native: `setTransition(node, { position:
|
|
270
|
+
{ duration: 400 }, ... })` makes setTransform writes TARGETS the core
|
|
271
|
+
animates toward every frame (position/scale per lane, rotation along
|
|
272
|
+
the quaternion geodesic - a spring keeps its velocity through
|
|
273
|
+
retargets), so a mesh gliding to a slot or a camera rig easing costs
|
|
274
|
+
one JS write per target change, zero per frame. The declaration lives
|
|
275
|
+
on the SceneNode and re-applies on every scene enter; the pose a node
|
|
276
|
+
enters with always snaps. Each natural settle calls the node's
|
|
277
|
+
`onTransitionEnd` (plain field like the pointer handlers) with
|
|
278
|
+
`{ component }`; the raw "spatialTransitionEnd" engine event
|
|
279
|
+
(srt:events, carrying the CORE node id `_node`) stays for flux:spatial
|
|
280
|
+
consumers.
|
|
281
|
+
|
|
282
|
+
### Geometry layout, indices and topology
|
|
283
|
+
|
|
284
|
+
One interleaved vertex buffer per geometry, described by an open layout
|
|
285
|
+
(`Geometry.layout`, absent = "standard"): an ordered attribute list that
|
|
286
|
+
always starts with the standard prefix `aPos` vec3 + `aNormal` vec3 +
|
|
287
|
+
`aUV` vec2 (what every generator emits) and may carry any named channels
|
|
288
|
+
after it. `withAttribute(geometry, { name, format }, fill)` appends one
|
|
289
|
+
(Three's `setAttribute` for an interleave); "colored" names the common
|
|
290
|
+
case, the prefix plus `aColor` vec4 - the per-vertex data channel (a
|
|
291
|
+
tint, baked AO, any four scalars; standard name, your contents) - and
|
|
292
|
+
`withColors(geometry, fill)` is its spelling. Fill is a flat
|
|
293
|
+
size-per-vertex array or a per-vertex callback receiving `(index, pos,
|
|
294
|
+
normal, uv)`. Materials read attributes BY NAME: a material's vertex
|
|
295
|
+
stage may declare any subset of its geometry's channels, and a channel
|
|
296
|
+
the program reads that the geometry lacks (name + format) throws at
|
|
297
|
+
add(). What a program reads is the ENGINE's word (`material.attributes()`
|
|
298
|
+
= `programAttributes` reflection of the linked program, instance
|
|
299
|
+
attributes excluded), not a parse of the GLSL: an `in` the compiler
|
|
300
|
+
dropped does not count, and the engine also rejects a pipeline whose
|
|
301
|
+
attribute lists leave a read attribute uncovered. The material
|
|
302
|
+
keeps one program and builds one pipeline per layout its meshes bring,
|
|
303
|
+
so a geometry may carry more than a material reads. The whole layout
|
|
304
|
+
ships whether a material reads every attribute or not (inactive
|
|
305
|
+
attributes only keep the stride), so extra channels cost their floats on
|
|
306
|
+
every draw of that geometry - keep data-light passes (a wireframe
|
|
307
|
+
reading only aPos) on standard geometry. The prefix has no opt-out, so
|
|
308
|
+
geometry that is not a surface pays for it anyway: a lidar point
|
|
309
|
+
carrying a position and one packed channel still costs the 8-float
|
|
310
|
+
prefix, doubling a large cloud's vertex buffer
|
|
311
|
+
(okf/backlog/non-surface-vertex-layouts.md). `layoutStride`/`layoutSlot`/
|
|
312
|
+
`layoutKey`/`layoutAttributes` are the layout arithmetic; two layouts
|
|
313
|
+
with equal keys interleave identically (merge requires that).
|
|
314
|
+
Indices are uint16 or uint32 - the `Geometry.indices` array type picks
|
|
315
|
+
the draw's index format, so hand-built geometry past 64k vertices just
|
|
316
|
+
uses a Uint32Array (generators emit uint16). What the indices LIST is
|
|
317
|
+
`Geometry.topology` (`"triangles"` when absent; `"lines"`,
|
|
318
|
+
`"line-strip"`, `"points"`, `"triangle-strip"`): the index buffer and
|
|
319
|
+
its primitive travel together (Godot's surface primitive, Unity's
|
|
320
|
+
SetIndices topology), a material builds one pipeline per (layout,
|
|
321
|
+
topology) pair its meshes bring, and validateGeometry enforces the
|
|
322
|
+
count rule at add() (a whole number of triangles or lines, one
|
|
323
|
+
primitive's worth for a strip). Materials have no topology of their
|
|
324
|
+
own. Only a triangle list gets a picking shape; lines and points pick
|
|
325
|
+
and collide by their bounds box, and cast no shadow. `geometryTopology`
|
|
326
|
+
reads the field with the default applied. POINTS: a point cloud is
|
|
327
|
+
ordinary indexed geometry - one vertex per point, the index buffer
|
|
328
|
+
listing them, `topology: "points"`, no constraint on the index count.
|
|
329
|
+
Under `"points"` the vertex stage writes `gl_PointSize` (honored to the
|
|
330
|
+
pixel; `size / gl_Position.w` attenuates it with distance) and the
|
|
331
|
+
fragment reads `gl_PointCoord` (0..1 across the sprite) to shape the
|
|
332
|
+
splat - `discard` outside the inscribed circle for round ones. The
|
|
333
|
+
worked example is `@solidrt/core`'s `examples/gpu-particles.tsx`. Do
|
|
334
|
+
NOT reach for instancing to draw a cloud (Instancing, below, has the
|
|
335
|
+
cost model). Geometry GPU buffers are lazy, shared, and
|
|
336
|
+
reference-counted by draw entries: removing the last
|
|
337
|
+
entry frees them at the end of the microtask (a same-tick rebuild keeps
|
|
338
|
+
the upload), so swapping `<Mesh geometry>` reactively never accumulates
|
|
339
|
+
old generations; the vertex upload is keyed on the `Float32Array`
|
|
340
|
+
itself, so geometries sharing a vertex array (a wireframe over its
|
|
341
|
+
source) share one buffer. `disposeGeometry` is the immediate explicit
|
|
342
|
+
free.
|
|
343
|
+
|
|
344
|
+
### Material classes
|
|
345
|
+
|
|
346
|
+
Materials dedupe hard: one program + one pipeline per material CLASS
|
|
347
|
+
(a `shaderMaterialClass` per option combination for unlit, lit and
|
|
348
|
+
sprite alike: map x transparent x cull x alphaTest, lit's extras on
|
|
349
|
+
top), `depth: true` + `cull: "back"` unless the material says otherwise
|
|
350
|
+
(`cull: "none"` for double-sided geometry; lit flips the normal on back
|
|
351
|
+
faces); an instance is just per-entry uniforms (`uColor`) and bindings
|
|
352
|
+
(`uMap`).
|
|
353
|
+
|
|
354
|
+
### Pure pieces and checks
|
|
355
|
+
|
|
356
|
+
The pure pieces (`math.ts`, `order.ts`, `geometry.ts`,
|
|
357
|
+
`profile.ts`, `sweep.ts`, `gltf.ts`, `model-file.ts`) are Solid-free and
|
|
358
|
+
GPU-free BY DESIGN so they can be checked headless (and, for the two
|
|
359
|
+
model modules, run under bun in `tools/model.ts`); keep them that way.
|
|
360
|
+
The rigs under `checks/`
|
|
361
|
+
(`geometry-check`, `sweep-check`, `pick-check`, `dispatch-check`,
|
|
362
|
+
`order-check`, `gltf-check`) run on
|
|
363
|
+
flux from the repo root: `bunx srt bundle -f --stdout
|
|
364
|
+
packages/3d/checks/<name>.ts | target/release/flux -`. Run the ones
|
|
365
|
+
touching what you changed. `raycast-check.tsx` and
|
|
366
|
+
`collision-check.tsx` are the exceptions: they assert the documented
|
|
367
|
+
picking contract (triangle accuracy, the box tier, pick/raycast
|
|
368
|
+
parity, layer masks, the `{ meshes }` filter) and collision contract
|
|
369
|
+
(exact sweep times, the surface rule, the slide filter, layers and
|
|
370
|
+
meshes on overlap/sweep, moveAndSlide's landing) against a real scene,
|
|
371
|
+
so they run on the playback client instead:
|
|
372
|
+
`bunx srt render packages/3d/checks/<name>.tsx --project --duration 3
|
|
373
|
+
--size 128x128`. Run them whenever a doc edit touches picking or
|
|
374
|
+
collision claims - two copies of this contract have drifted before.
|
|
245
375
|
|
|
246
376
|
## Components
|
|
247
377
|
|
|
378
|
+
### Component props
|
|
379
|
+
|
|
248
380
|
| Component | Props |
|
|
249
381
|
| --- | --- |
|
|
250
|
-
| `Scene` | `width?`, `height?` (target pixels - both, or neither = FILL, below), `clearColor?`, `camera?` (partial CameraUpdate, `ortho` included - the declarative scene.setCamera; same state as `PerspectiveCamera`, use one form), `background?` (fragment GLSL, or a skybox `{ cube, intensity?, rotation? }`), `environment?` (`{ cube, intensity?, rotation? }`, the cube reflective materials mirror), `fog?` (`{ color, near, far }`, linear by camera distance), `toneMapping?` (`"none"` default or `"aces"`), `exposure?` (default 1), `layers?` (target mask, default 1), `depth?` (`"texture"` exposes scene.depthTexture; not with samples), `samples?` (1/2/4/8 MSAA), `label?`, `ref?(scene)`, `output?(texture)`, `events?` (
|
|
382
|
+
| `Scene` | `width?`, `height?` (target pixels - both, or neither = FILL, below), `clearColor?`, `camera?` (partial CameraUpdate, `ortho` included - the declarative scene.setCamera; same state as `PerspectiveCamera`, use one form; the camera CONTROLS are not a third form - `OrbitCamera`/`FirstPersonCamera` drive position and target only, so `fov`/`near`/`far` come from here even while a control moves the camera, and the default `far` of 100 is what clips a scene in metres), `background?` (fragment GLSL, or a skybox `{ cube, intensity?, rotation? }`), `environment?` (`{ cube, intensity?, rotation? }`, the cube reflective materials mirror), `fog?` (`{ color, near, far }`, linear by camera distance), `toneMapping?` (`"none"` default or `"aces"`), `exposure?` (default 1), `layers?` (target mask, default 1), `depth?` (`"texture"` exposes scene.depthTexture; not with samples), `samples?` (1/2/4/8 MSAA), `label?`, `ref?(scene)`, `output?(texture)`, `events?` (pointer events, default on), `pointer?` (the leaf's pointer feed, fed from the scene's root), `onPointerDown/Move/Up?`, `onWheel?`, `onTap?` (the scene's own handlers, the last stop of the walk - `event.mesh` null over empty space) |
|
|
383
|
+
| `View3d` | a Scene child rendering the scene again from a camera of its own (scene.createView as a component): `width`, `height` (target pixels, live; fixed-size only for now), `x?`, `y?` (the tile's top-left in `into`, live), `into?` (tile an app-owned draw target - one pass for every view into it; fixed at creation), `camera?` (partial CameraUpdate on the view's camera, live; same state as a `PerspectiveCamera` child), `layers?` (the view's mask, live), `clearColor?`, `label?`, `overrideMaterial?`, `fog?` (FogOptions, or null for none), `depth?`, `samples?`, `filter?`, `wrap?` (createView's, fixed), `ref?(view)`, `output?(texture)` (else a built-in `<texture>` leaf at the target size, a tile shown through srcX/srcY), `events?`, `pointer?` (the view leaf's feed, fed from the view's root), `onPointerDown/Move/Up?`, `onWheel?`, `onTap?` (the view's own handlers); camera-control children drive the VIEW (inside, `useScene()` reports the view as `viewport` and the view's feed as `pointer`); node children mount to the scene as outside, and under the view's leaf get their ordinary pointer handlers, picked through the view's camera (`view.pick`), the view as the root of that walk |
|
|
251
384
|
| `Group` | `position?`, `rotation?` (Euler radians, XYZ order), `quaternion?` (either, not both), `scale?` (number = uniform), `visible?`, pointer events (below), `ref?(node)` |
|
|
252
385
|
| `Mesh` | `geometry`, `material`, transforms as Group, `params?` (per-mesh uniforms, merge semantics - no unset), `renderOrder?`, `castShadow?`, `layers?` (membership bitmask, default 1), pointer events (below), `ref?(mesh)` |
|
|
253
386
|
| `Sprite` | as Mesh minus `geometry`: a camera-facing unit quad, `scale` is its world size, rotation is ignored; pair with a `sprite()` material |
|
|
@@ -258,6 +391,8 @@ blendMode and pointer events like any element.
|
|
|
258
391
|
| `SpotLight` | transforms as Group, `direction?` (local aim, default [0, -1, 0]), `color?`, `intensity?`, `distance?` (falloff cutoff, 0 = none), `angle?` (cone half-angle DEGREES, default 60), `penumbra?` (0..1 rim fade, default 0), `decay?` (falloff exponent, default 2), `castShadow?`, `shadow?` (mapSize, bias, normalBias, near), `ref?(light)` |
|
|
259
392
|
| `PointLight` | transforms as Group (position is what matters), `color?`, `intensity?`, `distance?`, `decay?`, `castShadow?` (six face maps, six shadow slots), `shadow?` (mapSize, bias, normalBias, near), `ref?(light)` |
|
|
260
393
|
|
|
394
|
+
### Output composition
|
|
395
|
+
|
|
261
396
|
Output composition: without `output`, `Scene` emits a minimal
|
|
262
397
|
`<texture width height>` leaf and nothing else is forwarded - anything
|
|
263
398
|
more goes through `output(texture)`, which renders in place of that leaf:
|
|
@@ -270,15 +405,18 @@ elsewhere. Called once, untracked, inside the scene context. Scene
|
|
|
270
405
|
layout, so render and display size separate - render at 2x and display
|
|
271
406
|
smaller for supersampling.
|
|
272
407
|
|
|
408
|
+
### Fill and fixed sizes
|
|
409
|
+
|
|
273
410
|
Fill (the default): omit `width`/`height` and the built-in leaf is laid
|
|
274
411
|
out at 100% of its parent's box (give it a sized parent, as on the web)
|
|
275
412
|
while the target follows the leaf's on-screen size in DEVICE pixels -
|
|
276
413
|
display scale, `designSize` fits and ancestor transforms included
|
|
277
414
|
(getBoundingBoxViewport x displayScale, applied from onLayout, so no
|
|
278
415
|
frame draws at a stale size). A bare `<Scene>` in a pane renders at
|
|
279
|
-
native density on any display, mesh events
|
|
280
|
-
|
|
281
|
-
|
|
416
|
+
native density on any display, mesh events are wired automatically
|
|
417
|
+
(event scaling reads the leaf's untransformed box back with
|
|
418
|
+
`getLayoutBox`), the `pointer` feed you hand in is spread on the leaf,
|
|
419
|
+
and the camera aspect follows the box. Fill
|
|
282
420
|
or fixed is decided at mount; `output` requires explicit sizes (the
|
|
283
421
|
target cannot follow a leaf it does not own), and giving exactly one of
|
|
284
422
|
width/height throws.
|
|
@@ -293,98 +431,150 @@ examples fill; scene-views and scene-post-effect keep fixed sizes to
|
|
|
293
431
|
show multi-view composition and supersampling). A custom `output` leaf
|
|
294
432
|
whose layout differs from the target
|
|
295
433
|
takes `handlersFor` (below), not `handlers`; `useScene()` works inside
|
|
296
|
-
`output` because it runs in the scene context.
|
|
297
|
-
|
|
298
|
-
`{...useScene().
|
|
299
|
-
and
|
|
434
|
+
`output` because it runs in the scene context. One spread carries
|
|
435
|
+
everything: the `pointer` feed listens at the scene's root, so
|
|
436
|
+
`{...useScene().scene.handlers}` feeds the nodes, the scene's own
|
|
437
|
+
handlers and the feed alike (a detached leaf gives its feed a `layout`,
|
|
438
|
+
it has no layout box to normalize by).
|
|
439
|
+
|
|
440
|
+
### Input
|
|
441
|
+
|
|
442
|
+
Input, the rule (ARCHITECTURE.md): a camera control consumes a
|
|
443
|
+
device-free abstraction and never handles events or reads a device
|
|
444
|
+
itself. The controls expose `axes` (core's createAxes contract - rates
|
|
445
|
+
sampled per frame, immediate deltas in device-free units) and pose verbs;
|
|
446
|
+
an input map (`createInputMap`, core AGENTS.md) drives the axes by action
|
|
447
|
+
name, and the APP binds devices to the map: the scene leaf's pointer feed
|
|
448
|
+
(`createPointerFeed()`, handed to `<Scene pointer>` / `<View3d pointer>`,
|
|
449
|
+
where it listens at the root of the pointer walk, so a mesh that claims
|
|
450
|
+
its press keeps the control out of that drag; an imperative scene or
|
|
451
|
+
view feeds it with `feedPointer(scene, feed)`), a pad (`gamepad(0)`, or `gamepad.next()`
|
|
452
|
+
for the next pad to press a button), the keyboard (`keyboard`, its key
|
|
453
|
+
events through `input.handlers` on the window).
|
|
454
|
+
Nothing binds by default: `<OrbitCamera />` without `input` moves only
|
|
455
|
+
through its handle. The standard wiring is a preset, plain bindings the
|
|
456
|
+
app applies and edits:
|
|
457
|
+
|
|
458
|
+
```tsx
|
|
459
|
+
let pointer = createPointerFeed()
|
|
460
|
+
let input = createInputMap(orbitActions)
|
|
461
|
+
input.bind(orbitBindings({ pointer, gamepad: gamepad() }))
|
|
462
|
+
<Scene pointer={pointer}>
|
|
463
|
+
<OrbitCamera input={input} distance={7} />
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
The vocabulary is shared with @solidrt/2d, one kind and unit per word:
|
|
467
|
+
`rotate` vec2 (orbit turns), `zoom` axis (octaves, positive in), `pan`
|
|
468
|
+
vec2 (element heights), `look` vec2 (turns), `move` vec2 ([right,
|
|
469
|
+
forward], forward = -y in the screen convention), `rise` axis. Keys and
|
|
470
|
+
sticks move the CAMERA where a drag moves the content, so the presets
|
|
471
|
+
bind them to `rotate`/`pan` through `invert()`; `look` is the exception,
|
|
472
|
+
a drag and a stick both turn the eye.
|
|
473
|
+
|
|
474
|
+
### Orbit camera
|
|
300
475
|
|
|
301
476
|
Camera control: `createOrbitCamera(scene, { target?, azimuth?, elevation?,
|
|
302
477
|
distance?, min/maxDistance?, min/maxElevation?, orbitSpeed?, rotateSpeed?,
|
|
303
|
-
zoomSpeed?,
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
Scene, or a View to drive one view's
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
`
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
when
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
pose
|
|
478
|
+
zoomSpeed?, panSpeed?, clampTarget?, zoomAnchor?, rotateAnchor? })` -
|
|
479
|
+
azimuth/elevation/distance around a target with optional auto-orbit. The
|
|
480
|
+
first argument is anything with the scene's `setCamera` and `camera()`
|
|
481
|
+
(the fov maps pan travel to world): a Scene, or a View to drive one view's
|
|
482
|
+
camera independently. Its `axes` are `rotate` (a delta of one element
|
|
483
|
+
height sweeps one full turn, Three's OrbitControls convention, so a drag
|
|
484
|
+
feels the same on a phone and a 4k window; a rate turns at 0.5 turn/s at
|
|
485
|
+
full deflection), `zoom` (octaves: a delta of 1 halves the distance, a
|
|
486
|
+
rate of 1 halves it per second) and `pan` (the target slides so the scene
|
|
487
|
+
tracks the fingers 1:1 at the target's depth, three.js DOLLY_PAN, weighted
|
|
488
|
+
by `panSpeed`; `clampTarget(target)` bounds where a pan may put the
|
|
489
|
+
pivot). The verbs: `rotateBy(azimuth, elevation)` radians, `zoomBy(factor,
|
|
490
|
+
anchor?)` (factor > 1 in, as the 2d camera's zoomAt), `panBy(right, up)`
|
|
491
|
+
world units, `setPivot(point)`, `set(pose)`; every one pushes the pose at
|
|
492
|
+
once. Zoom aims at the target unless `zoomAnchor(focal, {eye, target})`
|
|
493
|
+
maps the gesture's focal point - a FRACTION of the element, [0..1, 0..1] -
|
|
494
|
+
to a world point (ground hit, target-depth plane, ...): then that point
|
|
495
|
+
stays pinned under the pointer and the target slides toward it; only the
|
|
496
|
+
app can build that mapping, since it needs the projection
|
|
497
|
+
(scene.unproject over the scene's size). A pinch holds one anchor for its
|
|
498
|
+
whole gesture (the fingers' interleaved events make the span oscillate,
|
|
499
|
+
and re-anchoring per delta turns that into a crawl); the wheel, arriving
|
|
500
|
+
unbracketed, anchors per notch. Pair it with `rotateAnchor({eye, target})`:
|
|
501
|
+
called when a rotate gesture begins, its point is projected onto the
|
|
502
|
+
view axis and re-seats the pivot without moving the picture, so a drag
|
|
503
|
+
after an anchored zoom orbits what the camera looks at, not wherever the
|
|
504
|
+
zoom left the target. Call `orbit.update(dt)` from your onFrame to
|
|
505
|
+
integrate the rates and the auto-orbit (no frame loop of its own), and
|
|
506
|
+
use its return - true when the pose changed since the previous update,
|
|
507
|
+
nudges included - to gate per-frame dependents like reprojecting HUD
|
|
508
|
+
overlays. `orbiting()` (the auto-orbit switch) and `active()` (the
|
|
509
|
+
frame-loop gate: orbiting with a non-zero rate, or any rate driving - the
|
|
510
|
+
predicate every camera control shares) are reactive (HUD-safe); the pose
|
|
511
|
+
is plain state via `pose()`/`set()` (also the debug-command shape). It
|
|
512
|
+
drives position and target only; fov/near/far stay on scene.setCamera
|
|
513
|
+
(or the Scene `camera` prop). The auto-orbit pauses while a gesture is
|
|
514
|
+
open.
|
|
515
|
+
|
|
516
|
+
In a component tree, skip the wiring: `<OrbitCamera input={input}
|
|
517
|
+
azimuth={1.2} distance={7} />` as a Scene child reaches the scene through
|
|
518
|
+
context (as a `<View3d>` child, that view: the context's `viewport` is
|
|
519
|
+
the nearest owner's), drives from the map in `input` (its `rotate`,
|
|
520
|
+
`zoom`, `pan` actions, or the names in `actions`; live - a new map
|
|
521
|
+
reconnects) and nothing else, and runs a frame loop only while
|
|
522
|
+
`active()`, so a camera moved by drags alone keeps the app demand-driven
|
|
523
|
+
idle. The pose props are initial values: runtime pose changes (and the
|
|
524
|
+
debug-command hookup) go through `ref`'s handle, whose set() and verbs
|
|
525
|
+
push the pose. Every other prop is live - forwarded to the control as a
|
|
526
|
+
getter and read where it applies, never snapshotted - so clamps, rates
|
|
527
|
+
and anchors follow their props without a remount, and a clamp change
|
|
528
|
+
re-clamps the pose at once.
|
|
529
|
+
|
|
530
|
+
### First-person camera
|
|
349
531
|
|
|
350
532
|
First-person control: `createFirstPersonCamera(scene, { position?, yaw?,
|
|
351
|
-
pitch?, min/maxPitch?, moveSpeed?, lookSpeed?, fly?,
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
`
|
|
383
|
-
|
|
533
|
+
pitch?, min/maxPitch?, moveSpeed?, lookSpeed?, fly?, clampPosition? })` -
|
|
534
|
+
a position plus yaw/pitch (yaw 0 faces -z, positive turns left; pitch
|
|
535
|
+
positive looks up), Unity's FirstPersonController shape (look AND move in
|
|
536
|
+
one control) where Three splits PointerLockControls from a hand-written
|
|
537
|
+
key loop. Its `axes`: `look` (a delta of one element height sweeps half a
|
|
538
|
+
turn - a drag across the screen turns the walker around - and the
|
|
539
|
+
pointer feed's `mouseDelta` delivers mouse motion under pointer lock in
|
|
540
|
+
the same unit; a rate turns at 0.4 turn/s), `move` ([right, forward],
|
|
541
|
+
forward = -y: a stick pushed up or W reads [0, -1]; a rate walks at
|
|
542
|
+
`moveSpeed`, a delta is a step in world units, diagonals clamped to unit
|
|
543
|
+
length so they walk no faster) and `rise` (world up at `moveSpeed`, fly
|
|
544
|
+
mode only). Walking (the default) flattens the heading onto the ground
|
|
545
|
+
plane at fixed height; `fly` moves along the view. The verbs:
|
|
546
|
+
`lookBy(yaw, pitch)` radians, `moveBy(right, forward, up?)` in the
|
|
547
|
+
walker's frame, `set(pose)`. Every option but the initial pose is read
|
|
548
|
+
where it applies (`fly` per step, `clampPosition` per move, the rates and
|
|
549
|
+
pitch clamps per input), so a field changed on the options object takes
|
|
550
|
+
effect on the next move: walk and fly are one control. The control NEVER
|
|
551
|
+
calls `lockPointer` - click-to-lock and Escape-to-release are the app's
|
|
552
|
+
window-level decisions (see `examples/first-person.tsx`) - and has no
|
|
553
|
+
collision of its own: `clampPosition(next, current)` is the whole hook -
|
|
554
|
+
bounds, a floor height, or `moveAndSlide` over `next - current` against
|
|
555
|
+
the collision layer (see `examples/collision.tsx`, whose `jump` is one
|
|
556
|
+
more action on the same map: Space or the pad's south button, read by
|
|
557
|
+
name). Call `update(dt)` from onFrame; `active()` is reactive - a rate
|
|
558
|
+
driving (a held key, a deflected stick) - and gates the loop. The map
|
|
559
|
+
tracks held keys from the down/up pair through `input.handlers`; spread
|
|
560
|
+
its `onBlur` too, since the up never arrives once focus has left.
|
|
561
|
+
|
|
562
|
+
`<FirstPersonCamera input={input}>` as a Scene child wires the control
|
|
563
|
+
to the scene and the map, nothing else: the app spreads
|
|
564
|
+
`input.handlers` on the window (keys bubble there from wherever focus
|
|
565
|
+
is, so a click on the scene needs no focus dance) and hands the scene
|
|
566
|
+
its `pointer` feed. The frame loop runs only while `active()`. Every prop
|
|
384
567
|
but the initial pose is live: `fly={flying()}` toggles walk/fly on the
|
|
385
|
-
running control (pose
|
|
386
|
-
|
|
387
|
-
|
|
568
|
+
running control (pose carries over, no remount, and `clampPosition` may
|
|
569
|
+
swap with it), `moveSpeed`/`lookSpeed` follow their props, and a pitch
|
|
570
|
+
clamp change re-clamps at once. Like the orbit control it drives
|
|
571
|
+
position and yaw/pitch only: fov/near/far stay on scene.setCamera (or
|
|
572
|
+
the Scene `camera` prop), so a walker in a scene bigger than the default
|
|
573
|
+
`far` of 100 sets one there. checks/orbit-check.ts and
|
|
574
|
+
checks/first-person-check.ts pin both controls headless (they import
|
|
575
|
+
`@solidrt/core/input` only).
|
|
576
|
+
|
|
577
|
+
### Overlay projection
|
|
388
578
|
|
|
389
579
|
Overlay projection: `scene.project(point)` maps a world point to scene
|
|
390
580
|
pixels (top-left origin, y down - the output texture's own space; `w` is
|
|
@@ -400,6 +590,8 @@ unproject each move). `scene.viewProj(out?)` copies the view-projection
|
|
|
400
590
|
matrix for batch work. Never rebuild the camera matrices by hand for a
|
|
401
591
|
HUD.
|
|
402
592
|
|
|
593
|
+
### Picking
|
|
594
|
+
|
|
403
595
|
Picking: `scene.pick(x, y)` is project()'s inverse - the camera ray
|
|
404
596
|
through a scene pixel, returning `Hit[]` (`{ mesh, distance, point }`,
|
|
405
597
|
world units, nearest first; every hit along the ray, not just the front
|
|
@@ -423,13 +615,17 @@ a ray through a knot's hole misses. A large geometry's triangles are
|
|
|
423
615
|
BVH-indexed too - built by the first ray that reaches the shape, log-cost
|
|
424
616
|
after - so raycasting a merged static scene stays cheap (see the batching
|
|
425
617
|
advice). An instanced mesh is box-only (its
|
|
426
|
-
explicit population bounds; records are opaque)
|
|
618
|
+
explicit population bounds; records are opaque), and so is a mesh whose
|
|
619
|
+
geometry is not a triangle list (lines, points, strips have no triangle
|
|
620
|
+
narrowphase): it is tested by the
|
|
427
621
|
box's twelve triangles, so its hits carry the struck face's `normal` and
|
|
428
622
|
no `face`/`uv` - and a ray from inside it meets the far side, the
|
|
429
623
|
surface contract overlap/sweep share. Both methods
|
|
430
624
|
flush pending writes first (the lookAt/project immediacy contract), and
|
|
431
625
|
both skip invisible meshes.
|
|
432
626
|
|
|
627
|
+
### Collision
|
|
628
|
+
|
|
433
629
|
Collision: `scene.overlap(volume, opts?)` and `scene.sweep(volume,
|
|
434
630
|
motion, opts?)` are the same index's other two questions - what a volume
|
|
435
631
|
touches, and where a moving one first touches: Unity's
|
|
@@ -453,12 +649,21 @@ closed mesh with no triangle in reach touches nothing - the trimesh rule
|
|
|
453
649
|
in every engine), and a sweep from a volume already in contact reports
|
|
454
650
|
time 0 only while the motion closes in; leaving or sliding along the
|
|
455
651
|
contact is no hit, which is what lets a slide along a wall proceed. A
|
|
456
|
-
zero motion touches nothing.
|
|
457
|
-
`
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
652
|
+
zero motion touches nothing. The filters run in the core: every query
|
|
653
|
+
hands `flux:spatial` a `{ root, layers, nodes }` filter (the scene root,
|
|
654
|
+
the mask, the `meshes` include-list as node ids - an instanced mesh
|
|
655
|
+
contributing every instance node), and a mesh's `layers` is written to
|
|
656
|
+
its core node (`setLayers`), so a query never sees another scene's or a
|
|
657
|
+
2d layer's nodes and JS only maps hits back to meshes.
|
|
658
|
+
`scene.moveAndSlide(volume, motion, opts?)` (and the free
|
|
659
|
+
`moveAndSlide(scene, ...)` spelling of it) is the controller over them:
|
|
660
|
+
Godot's CharacterBody3D.move_and_slide and Unity's
|
|
661
|
+
CharacterController.Move as one PURE call - no node, no velocity; the
|
|
662
|
+
first-person camera composes it in `clampPosition`, a node-driven body
|
|
663
|
+
applies `result.motion` with setTransform. The loop runs in the spatial
|
|
664
|
+
core (`alloy/src/spatial/mover.rs`, one FFI call per body per frame; the
|
|
665
|
+
2d layer's mover is the same loop), the JS side only packs the volume
|
|
666
|
+
and maps hits back to meshes. It pushes the body out
|
|
462
667
|
of anything it starts inside, sweeps, stops a skin short, slides the
|
|
463
668
|
rest along the contact plane up to `maxSlides` times, then snaps down
|
|
464
669
|
onto a floor within `floorSnap` unless the motion rises - and the
|
|
@@ -478,25 +683,58 @@ one overlap per move. Deliberately absent and additive when asked: a
|
|
|
478
683
|
step offset (Unity's stepOffset; Godot has none either) and a cylinder
|
|
479
684
|
volume (Godot only).
|
|
480
685
|
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
`
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
the
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
686
|
+
### Pointer events
|
|
687
|
+
|
|
688
|
+
Pointer events (scene-pointer.ts): the element event model one tree
|
|
689
|
+
deeper, with the SCENE as the root of the walk (under a `<View3d>` leaf:
|
|
690
|
+
the view). `onPointerDown/Move/Up/Enter/Leave/Wheel/Tap` are plain
|
|
691
|
+
fields on any node (and Mesh/Group/Instance props); the nearest hit is
|
|
692
|
+
the target - the struck instance of an instanced mesh, else the mesh -
|
|
693
|
+
and down/move/up/wheel dispatch there, bubble through the ancestors and
|
|
694
|
+
END AT THE SCENE's listeners (`scene.listen({...})`, the `<Scene>`
|
|
695
|
+
`onPointer*`/`onWheel`/`onTap` props); over empty space the walk is the
|
|
696
|
+
scene alone, with `event.mesh` null (a tap there is the deselect idiom).
|
|
697
|
+
`event.mesh` stays the hit (`NodePointerEvent` in the chain,
|
|
698
|
+
`ScenePointerEvent` at the root with `mesh` nullable), `currentTarget`
|
|
699
|
+
the node whose handler runs (the scene at the end), `stopPropagation()`
|
|
700
|
+
stops the walk, and a stopped DOWN claims the whole press: that
|
|
701
|
+
pointer's move, up and tap never reach the scene either (the chain still
|
|
702
|
+
bubbles). That one rule is how a mesh drags itself under an
|
|
703
|
+
`<OrbitCamera>` without the view turning: stop the down, own the
|
|
704
|
+
captured moves - the control's pointer feed listens at the root
|
|
705
|
+
(`<Scene pointer>` does it, `feedPointer(scene, feed)` by hand) and sees
|
|
706
|
+
only what the nodes let through. Capture is per pointerId to the press
|
|
707
|
+
target, the scene included (a drag from empty space keeps delivering to
|
|
708
|
+
the root as it crosses meshes; a drag from a mesh keeps naming it, with
|
|
709
|
+
`point`/`distance` null while the ray misses it). Enter/leave fire on
|
|
710
|
+
the struck node alone - a group never receives them - while the root
|
|
711
|
+
sees every move, so "hovering empty space" is a root move with `mesh`
|
|
712
|
+
null. Taps are synthesized by the dispatch (DOM click, Unity's click
|
|
713
|
+
handler): `onTap` fires after the up when the press released on the
|
|
714
|
+
target it pressed within the slop (8 window px, core's recognizer slop,
|
|
715
|
+
so a press is never both a tap and a drag), was the only pointer down
|
|
716
|
+
for its whole press (a pinch never taps), with `tapCount` counting
|
|
717
|
+
repeats within 300 ms and 20 px on the same target (a double tap is
|
|
718
|
+
`tapCount === 2`); a release on another instance of the same mesh is no
|
|
719
|
+
tap. Wheel walks like a move with `deltaX/deltaY` (a node stopping it
|
|
720
|
+
keeps the zoom out). Every event carries `native`, the leaf's element
|
|
721
|
+
event, for core's recognizers, and the element fields (pointerId,
|
|
722
|
+
pointerType, button, modifiers) plus `x`/`y` in scene pixels
|
|
723
|
+
(screenRay's input - a drag plane is one intersection away). Root
|
|
724
|
+
listeners all run, in registration order (the root is the last stop,
|
|
725
|
+
nothing is left to claim). Wiring: the built-in `<Scene>` leaf carries
|
|
726
|
+
`scene.handlers` automatically (opt out: `events={false}`); an `output`
|
|
727
|
+
leaf or imperative composition spreads `{...scene.handlers}` onto the
|
|
728
|
+
element showing the texture. `scene.handlers` assumes that leaf is LAID
|
|
729
|
+
OUT at the target size - true for the built-in leaf and a d-texture at
|
|
730
|
+
natural size, under any ancestor transforms or design-size fits (the hit
|
|
731
|
+
test undoes them; localX/localY arrive in the leaf's layout frame). A
|
|
732
|
+
leaf laid out at a different size (the supersampling pattern) uses
|
|
499
733
|
`scene.handlersFor(() => ({ width, height }))` with its layout size.
|
|
734
|
+
checks/dispatch-check.ts pins the walk, claiming, capture, hover, wheel
|
|
735
|
+
and tap rules headless; examples/pick.tsx is the live guard.
|
|
736
|
+
|
|
737
|
+
### Geometry generators
|
|
500
738
|
|
|
501
739
|
Geometry generators take ONE options object, every field optional with
|
|
502
740
|
a default, named as Three names them: `box({ width, height, depth })`
|
|
@@ -529,6 +767,8 @@ buffer itself - so a builder that bakes transforms while writing hands
|
|
|
529
767
|
the baker world-space vertices. `fill` indexes relative to `first`.
|
|
530
768
|
`fillColors(geometry, fill, first?, count?)` is its aColor spelling.
|
|
531
769
|
|
|
770
|
+
### Geometry as data
|
|
771
|
+
|
|
532
772
|
Geometry as data: `transformGeometry(geometry, { position?, rotation?,
|
|
533
773
|
quaternion?, scale? }, label?)` bakes a placement into a copy (the
|
|
534
774
|
setTransform shape: Euler XYZ radians or a quaternion, number = uniform
|
|
@@ -544,13 +784,27 @@ does not tax picking: a merged geometry's raycast narrowphase runs
|
|
|
544
784
|
through its triangle BVH (built on the first ray), so merge for draw
|
|
545
785
|
count without giving up ground queries. Both are
|
|
546
786
|
pure array math (Three's `applyMatrix4` + `mergeGeometries`), no GPU
|
|
547
|
-
call, and the source geometries are untouched. `
|
|
787
|
+
call, and the source geometries are untouched. `wireframeGeometry(geometry,
|
|
788
|
+
label?)` and `edgesGeometry(geometry, thresholdAngle?, label?)` (Three's
|
|
789
|
+
WireframeGeometry/EdgesGeometry) build `"lines"` geometry over a triangle
|
|
790
|
+
geometry's OWN vertex array and layout, shared by reference: every edge
|
|
791
|
+
once for the wireframe, for the edges only those where two faces meet at
|
|
792
|
+
the threshold (degrees, default 1) or more plus the open borders. Edges
|
|
793
|
+
are matched by position, so a uv seam or a per-face normal split draws
|
|
794
|
+
one line; a generated round shape is faceted and keeps its facet lines
|
|
795
|
+
until the threshold passes 360 / radialSegments. The wireframe of a
|
|
796
|
+
"skinned" part draws in its pose under `unlit({ skinned: true })` with
|
|
797
|
+
the mesh's palette, and the swap onto a live mesh is
|
|
798
|
+
`setGeometry` + `setMaterial` (`examples/wireframe.tsx`). Lines are one
|
|
799
|
+
pixel wide on GL ES; thick lines are quad geometry. `geometryBounds(geometry)`
|
|
548
800
|
returns the cached local AABB `[minX, minY, minZ, maxX, maxY, maxZ]`, and
|
|
549
801
|
`rayBoxDistance(ox, oy, oz, dx, dy, dz, minX, .., maxZ)` is the picking
|
|
550
802
|
slab test (entry t >= 0 in units of the direction's length, 0 from
|
|
551
803
|
inside, -1 for a miss) - for ray-testing boxes you keep yourself
|
|
552
804
|
(triggers, collision volumes) without meshes you do not want to draw.
|
|
553
805
|
|
|
806
|
+
### Profile kit
|
|
807
|
+
|
|
554
808
|
Profile kit (2D outlines to solids, real texture UVs): a `Profile` is a
|
|
555
809
|
closed XY polygon, bare `[x, y]` points crease, `{ p, smooth }` points
|
|
556
810
|
share an averaged normal - `fillet(points, radius, segs?)` and
|
|
@@ -576,87 +830,120 @@ like circle); `triangulate(points)` is the ear-clipping core (fan
|
|
|
576
830
|
fallback, never drops a cap), exported for custom flat work. These pick
|
|
577
831
|
uint16/uint32 indices by vertex count automatically.
|
|
578
832
|
|
|
579
|
-
Materials
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
833
|
+
### Materials
|
|
834
|
+
|
|
835
|
+
#### unlit
|
|
836
|
+
|
|
837
|
+
`unlit({ color?, map?, transparent?, cull?, alphaTest?, fog? })` -
|
|
838
|
+
straight `[r, g, b, a?]` 0..1 sRGB (decoded to linear light, see Color
|
|
839
|
+
below), premultiplied internally; `cull` and
|
|
840
|
+
`alphaTest` as on lit (a mapped cutout casts its cutout); `fog: false`
|
|
841
|
+
opts out of the scene's fog (all four library materials take it).
|
|
842
|
+
|
|
843
|
+
#### sprite
|
|
844
|
+
|
|
845
|
+
`sprite({ color?, map?, transparent?, billboard? })` - unlit on a quad
|
|
846
|
+
that turns to face the camera IN THE VERTEX STAGE (off the shared
|
|
847
|
+
uCamRight/uCamUp, or uCamPos for `billboard: "fixed-y"`, which yaws
|
|
848
|
+
only and stays upright on world y - Godot's BILLBOARD_FIXED_Y, the
|
|
849
|
+
tree/character sprite; the default `"full"` is Three's Sprite, flat to
|
|
850
|
+
the screen). No per-frame JS however many sprites. `transparent`
|
|
851
|
+
defaults to TRUE here (cutouts; Three's SpriteMaterial default), cull is
|
|
852
|
+
off. Draw with `createSprite(material)` / `<Sprite>`: a Mesh over a
|
|
853
|
+
shared unit plane, no geometry argument, `scale` = world size, rotation
|
|
854
|
+
ignored. Picks by a unit box around its center (its reach at any
|
|
855
|
+
facing), so hits carry no normal/face/uv. `examples/sprites.tsx`.
|
|
856
|
+
|
|
857
|
+
#### shaderMaterial
|
|
858
|
+
|
|
859
|
+
`shaderMaterial({ vertex, fragment, params?, textures?, depth?,
|
|
860
|
+
depthWrite?, blend?, cull?, label? })` - your own GLSL, the
|
|
861
|
+
custom-look escape hatch. The STANDARD UNIFORM SET: the vertex stage
|
|
862
|
+
MUST declare and use `uniform mat4 uModel` (the mesh's world matrix,
|
|
863
|
+
per entry) and `uniform mat4 uViewProj` (the camera, shared
|
|
864
|
+
target-level params) - transform with
|
|
865
|
+
`uViewProj * uModel * vec4(aPos, 1.0)`; a source missing either throws
|
|
866
|
+
at shaderMaterial() creation. The rest is opt-in by declare-and-use:
|
|
867
|
+
`uniform vec3 uCamPos` (the camera's world position, shared and written
|
|
868
|
+
with uViewProj - the specular/fresnel view vector is
|
|
869
|
+
`normalize(uCamPos - worldPos)`), `uniform vec3 uCamRight` / `uCamUp`
|
|
870
|
+
(the camera's world-space view axes, shared likewise - a billboard is
|
|
871
|
+
`center + uCamRight * x + uCamUp * y`; do NOT rebuild them from
|
|
872
|
+
uViewProj rows, that carries the clip flip), `uniform mat4
|
|
873
|
+
uInvViewProj` (the camera's inverse view-projection, shared likewise -
|
|
874
|
+
a clip position back to world, the world-space ray through a pixel
|
|
875
|
+
without knowing the projection), the output stage's `uniform float
|
|
876
|
+
uExposure` / `uToneMapping` (compose `OUTPUT` from `/glsl` and end with
|
|
877
|
+
`fragColor = outputColor(rgb, alpha)` to take the scene's exposure and
|
|
878
|
+
tone mapping and encode like the library materials do; a fragment
|
|
879
|
+
writing fragColor directly writes final encoded pixels) and `uniform
|
|
880
|
+
mat4 uNormal` (the world
|
|
881
|
+
inverse-transpose, written beside uModel for this material's meshes;
|
|
882
|
+
take `mat3(uNormal)` - correct under non-uniform scale, where
|
|
883
|
+
mat3(uModel) bends normals off the surface). Attributes come from the
|
|
884
|
+
geometry's layout by name; the ones the linked program actually reads
|
|
885
|
+
(engine reflection, instance attributes excluded) must all be in the
|
|
886
|
+
mesh's geometry layout or add() throws - so a used `in vec4 aColor`
|
|
887
|
+
needs `withColors()` geometry and a custom channel needs
|
|
888
|
+
`withAttribute()`. One program per class, one pipeline per layout met. Sources without `#version` get the standard
|
|
889
|
+
pipeline preamble. App-driven uniforms beyond the standard set: seed
|
|
890
|
+
via `params`, then write per mesh with
|
|
891
|
+
`setMeshParams(mesh, { name: value })` (validated names; values persist
|
|
892
|
+
across entry rebuilds; frame-rate-safe like setTransform) or declaratively
|
|
893
|
+
with the `Mesh` `params` prop (same merge semantics - a key that
|
|
894
|
+
disappears from the object keeps its old value; for per-frame values
|
|
895
|
+
prefer `ref` + setMeshParams from onFrame, the setTransform split).
|
|
896
|
+
Scene-wide values (a clock, a sun direction, fog) go through
|
|
897
|
+
`scene.setParams({ uTime })` instead - one write for every mesh.
|
|
898
|
+
|
|
899
|
+
#### shaderMaterialClass
|
|
900
|
+
|
|
901
|
+
`shaderMaterialClass({ vertex, fragment, ...pipeline state })` - the
|
|
902
|
+
class/instance split for your own GLSL: compiles once, and
|
|
903
|
+
`cls.instance({ params?, textures? })` returns a Material sharing that
|
|
904
|
+
pipeline with its own values. `dispose()` lives on the class alone.
|
|
905
|
+
`shaderMaterial(opts)` is exactly a class with one instance (its
|
|
906
|
+
`dispose` forwards to the class).
|
|
907
|
+
|
|
908
|
+
#### Instanced materials
|
|
909
|
+
|
|
910
|
+
`instanceAttributes: [{ name, format, slot? }]` (`format` is the vertex
|
|
911
|
+
vocabulary, `"f32" | "vec2" | "vec3" | "vec4"`, NOT WebGPU's
|
|
912
|
+
`float32x3` spelling) on either shader-material form makes an INSTANCED
|
|
913
|
+
material: the vertex stage reads them as `in` variables beside the
|
|
914
|
+
layout's own, and each drawn instance gets one
|
|
915
|
+
record per slot from the mesh's instance buffers - slot 0 (default)
|
|
916
|
+
is the record buffer, slot 1 an instanced mesh's STYLE buffer (below).
|
|
917
|
+
Its meshes come from `createInstancedMesh` or `createRecordMesh`; a
|
|
918
|
+
`createMesh` mesh is rejected at add(). `instanceStyle: [..]` is the
|
|
919
|
+
slot-1 record a fresh instance starts with. The stock materials do
|
|
920
|
+
the same with one flag: `lit/standard/unlit({ instanced: true })`
|
|
921
|
+
places by the instance matrix (the shadow pass too, so the fleet
|
|
922
|
+
casts), and `{ instanceColors: true }` adds a per-instance
|
|
923
|
+
premultiplied `[r, g, b, a]` style record starting white - Unity's
|
|
924
|
+
per-material instancing switch with Three's setColorAt.
|
|
925
|
+
|
|
926
|
+
### Instancing
|
|
655
927
|
|
|
656
928
|
Instancing - one draw entry covering a population, in two forms. The
|
|
657
929
|
axis between them is WHERE MOTION IS COMPUTED, the same split as
|
|
658
930
|
@solidrt/2d's sprite and record layers.
|
|
659
931
|
|
|
932
|
+
FIRST, whether to instance at all. An instance costs the GPU a fixed
|
|
933
|
+
per-instance setup, so records buy cheap MOTION, not cheap VERTICES:
|
|
934
|
+
below roughly a hundred vertices per instance that setup dominates, and
|
|
935
|
+
a plain geometry with a bigger vertex buffer draws faster. A point cloud
|
|
936
|
+
is the extreme case - one vertex per point in an ordinary indexed
|
|
937
|
+
`topology: "points"` geometry, no instancing, measured at 4x the frame
|
|
938
|
+
rate of the same points as records. Packing more points per record does
|
|
939
|
+
not recover it: every vertex of an instance fetches ALL of that
|
|
940
|
+
instance's attributes, so attribute bandwidth grows with the packing.
|
|
941
|
+
Reach for instancing when each instance is a real object (a ship, a
|
|
942
|
+
tree, a crowd member), not to submit a lot of vertices. The tell in
|
|
943
|
+
`/gpu` is a large `instanceCount` beside a tiny `indexCount`.
|
|
944
|
+
|
|
945
|
+
#### createInstancedMesh
|
|
946
|
+
|
|
660
947
|
`createInstancedMesh(geometry, material, { capacity?, bounds?, label? })`
|
|
661
948
|
draws the geometry once per instance NODE: `addInstance(mesh,
|
|
662
949
|
transform?, parent?)` returns a scene node (kind "instance") placed
|
|
@@ -705,15 +992,17 @@ MultiMesh is the record form. The components: `<InstancedMesh capacity>`
|
|
|
705
992
|
with `<Instance position style transition onPointerDown>` children
|
|
706
993
|
(`<Group>` children are squads, an `<Instance>` is a parent too);
|
|
707
994
|
`examples/fleet.tsx` is a thousand of them springing between formations
|
|
708
|
-
with tap-to-tint
|
|
995
|
+
with tap-to-tint.
|
|
709
996
|
Mount cost is the one difference between the faces: an `<Instance>` row
|
|
710
997
|
costs about eight times an `addInstance` call (the component machinery -
|
|
711
998
|
a Solid row, two effects, the style effect; a node component's context
|
|
712
|
-
provider is built only when it has children, the biggest single
|
|
713
|
-
|
|
999
|
+
provider is built only when it has children, the biggest single
|
|
1000
|
+
saving), so a few thousand
|
|
714
1001
|
mount in tens of milliseconds either way; populations spawned per frame,
|
|
715
1002
|
or in five figures, belong to the function face.
|
|
716
1003
|
|
|
1004
|
+
#### createRecordMesh
|
|
1005
|
+
|
|
717
1006
|
`createRecordMesh(geometry, material, records, count?, { bounds?,
|
|
718
1007
|
label? })` is the raw form: `records` is the interleaved per-instance
|
|
719
1008
|
data (stride = the material's slot-0 instanceAttributes summed, a
|
|
@@ -732,13 +1021,16 @@ then it picks and transparent-sorts conservatively as one box. The
|
|
|
732
1021
|
escape hatch for motion only JS can compute at scale (a particle sim, a
|
|
733
1022
|
crowd stepped in a worker).
|
|
734
1023
|
|
|
1024
|
+
#### Common to both
|
|
1025
|
+
|
|
735
1026
|
Everything mesh works on both: setTransform moves the whole population
|
|
736
1027
|
through one uModel, setVisible zeroes the drawn count and restores it on
|
|
737
1028
|
unhide, renderOrder/params/geometry/material swaps apply, and
|
|
738
1029
|
`disposeInstances(mesh)` detaches and frees the record buffers - the one
|
|
739
1030
|
explicit free, geometry-buffer rule. `examples/fleet.tsx` (instances,
|
|
740
|
-
components)
|
|
741
|
-
|
|
1031
|
+
components) and `examples/instanced.tsx` (records) are the live proofs.
|
|
1032
|
+
|
|
1033
|
+
### Background
|
|
742
1034
|
|
|
743
1035
|
Background: `scene.setBackground(source | null)`, the `background` option
|
|
744
1036
|
on createScene, and the reactive `Scene` prop. Drawn as the FIRST entry
|
|
@@ -774,6 +1066,8 @@ no separate resize plumbing. Two forms:
|
|
|
774
1066
|
the same way, so a skybox is one flat color there. A 2D texture id
|
|
775
1067
|
throws at the samplerCube binding. `examples/skybox.tsx`.
|
|
776
1068
|
|
|
1069
|
+
### Cube-map convention
|
|
1070
|
+
|
|
777
1071
|
The cube-map convention: a cube map holds what a GL lookup returns -
|
|
778
1072
|
each face as seen from OUTSIDE the cube, GL's own (RenderMan) frame -
|
|
779
1073
|
and every library lookup is a plain `texture(cube, dir)` in world space.
|
|
@@ -789,6 +1083,8 @@ number, so the object form keeps it unambiguous). Translucent grounds
|
|
|
789
1083
|
over a background still need blend factors (a separate shader texture
|
|
790
1084
|
underneath until then).
|
|
791
1085
|
|
|
1086
|
+
### Environment
|
|
1087
|
+
|
|
792
1088
|
Environment: `scene.setEnvironment({ cube, intensity?, rotation? } |
|
|
793
1089
|
null)`, the `environment` option on createScene and the reactive `Scene`
|
|
794
1090
|
prop - the cube map every `standard` material is lit by (always: the
|
|
@@ -838,6 +1134,8 @@ environment set contributes nothing (uEnvOn 0), not a black reflection.
|
|
|
838
1134
|
`examples/skybox.tsx` (a JS-baked sky), `examples/environment.tsx` (a
|
|
839
1135
|
baked HDRI lighting the scene alone).
|
|
840
1136
|
|
|
1137
|
+
### Reflection probes
|
|
1138
|
+
|
|
841
1139
|
Reflection probes: `scene.createReflectionProbe({ position, size?,
|
|
842
1140
|
near?, far?, layers?, clearColor?, label? })` renders the scene into a
|
|
843
1141
|
cube map from a point - Three's CubeCamera, Unity's and Godot's
|
|
@@ -883,6 +1181,8 @@ x-mirrored projection (`Camera.mirror`), because a GL cube face is seen
|
|
|
883
1181
|
from outside; the engine inverts the front-face rule on cube target
|
|
884
1182
|
passes so cull modes keep their meaning. `examples/probe.tsx`.
|
|
885
1183
|
|
|
1184
|
+
### Baked sky
|
|
1185
|
+
|
|
886
1186
|
Baked sky: `scene.bakeBackground(size?)` is a reflection probe at the
|
|
887
1187
|
origin that sees no mesh (layer mask 0): the scene's background - the
|
|
888
1188
|
GLSL sky or the skybox - alone on its six faces, LINEAR (a sky ending in
|
|
@@ -902,6 +1202,8 @@ that HDR is for. A sky that reads
|
|
|
902
1202
|
`uCamPos` bakes from the origin; scene params (an app clock) are seen as
|
|
903
1203
|
of the call. `examples/sky-lit.tsx`.
|
|
904
1204
|
|
|
1205
|
+
### Panoramas
|
|
1206
|
+
|
|
905
1207
|
Panoramas: `equirectToCube(map, size, opts?)` converts an uploaded
|
|
906
1208
|
equirectangular 2D texture (createImage, createTexture) into a cube
|
|
907
1209
|
TextureId on the GPU, synchronously (six face passes straight into a
|
|
@@ -917,6 +1219,8 @@ the bake tool above, whose CPU pipeline (`src/environment-bake.ts`:
|
|
|
917
1219
|
decodeHdr, panoramaToCube, prefilterCube, the .srte encode/decode) is
|
|
918
1220
|
pure TypeScript and bun-tested - there is no runtime .hdr decoder.
|
|
919
1221
|
|
|
1222
|
+
### Fog
|
|
1223
|
+
|
|
920
1224
|
Fog: `scene.setFog(fog | null)`, the `fog` option on createScene and
|
|
921
1225
|
the reactive `Scene` prop, in Three's two shapes: linear `{ color, near,
|
|
922
1226
|
far }` (`Fog`; fades from near to far, fully fogged past far) or exp2
|
|
@@ -948,6 +1252,8 @@ horizon, and put `far` at or inside the camera's far plane to hide the
|
|
|
948
1252
|
clip. `examples/fog.tsx` cycles the forms over a valley;
|
|
949
1253
|
`examples/cascades.tsx` fogs its field to the sky.
|
|
950
1254
|
|
|
1255
|
+
### Color
|
|
1256
|
+
|
|
951
1257
|
Color: the scene shades in LINEAR light and outputs sRGB, like Three
|
|
952
1258
|
(ColorManagement), Godot and Unity's linear space - no gamma mode. Every
|
|
953
1259
|
`[r, g, b]` color option is sRGB, what a color picker shows: material
|
|
@@ -973,6 +1279,8 @@ raw sampling). What changed for a scene tuned before this: terminators
|
|
|
973
1279
|
soften, mid-tones brighten, highlights widen - drop ambient rather than
|
|
974
1280
|
lights. `emissiveIntensity` scales the emissive in linear light.
|
|
975
1281
|
|
|
1282
|
+
### Lighting GLSL
|
|
1283
|
+
|
|
976
1284
|
Lighting GLSL (`@solidrt/3d/glsl`): exported string constants composed
|
|
977
1285
|
into shaderMaterial sources with plain template literals - `LIT_VERTEX`
|
|
978
1286
|
(the standard vertex stage: clip position plus vWorldPos/vNormal/vUv
|
|
@@ -1008,6 +1316,8 @@ samplers. Lights, colors and exponents are arguments, so
|
|
|
1008
1316
|
nothing is pinned but the function names; `lit` is composed from these
|
|
1009
1317
|
same constants - customizing never means leaving the system.
|
|
1010
1318
|
|
|
1319
|
+
### Custom looks: three tiers
|
|
1320
|
+
|
|
1011
1321
|
A custom look is a citizen of the scene - lit by its lights, shadowed,
|
|
1012
1322
|
fogged, exposed and tone mapped like the stock materials - at one of
|
|
1013
1323
|
three tiers, top first:
|
|
@@ -1021,7 +1331,12 @@ three tiers, top first:
|
|
|
1021
1331
|
mesh keeps the stock shading whole (`examples/instanced.tsx`: the
|
|
1022
1332
|
per-instance tint rides vColor). Instance it with the per-entry
|
|
1023
1333
|
uniforms the source declares (uColor, uSpecular/uShininess or
|
|
1024
|
-
uMetalness/uRoughness, the maps opted into)
|
|
1334
|
+
uMetalness/uRoughness, the maps opted into) - SEED THEM: a uniform
|
|
1335
|
+
no one wrote is zero, and the program shades `base * uColor`, so an
|
|
1336
|
+
unseeded uColor renders pure black with no error. `instance({
|
|
1337
|
+
params: { uColor: [1, 1, 1, 1], uSpecular: 0.12, uShininess: 24 } })`
|
|
1338
|
+
is the white starting point (the stock materials seed exactly this
|
|
1339
|
+
from their `color` option).
|
|
1025
1340
|
2. A SURFACE FUNCTION inside the stock fragment. `litFragment({ surface,
|
|
1026
1341
|
prelude })`: `prelude` is file scope (uniforms and helpers; a uniform
|
|
1027
1342
|
it declares is an ordinary `instance()` param), `surface` declares
|
|
@@ -1035,7 +1350,12 @@ three tiers, top first:
|
|
|
1035
1350
|
generated program is; colors are linear light, premultiplied
|
|
1036
1351
|
throughout, `Surface.base` included. The material describes the
|
|
1037
1352
|
surface, the package shades it (Godot's fragment(), Filament's
|
|
1038
|
-
material()).
|
|
1353
|
+
material()). This is the tier for anything whose geometry is not a
|
|
1354
|
+
surface: a point splat discarding outside `gl_PointCoord`'s inscribed
|
|
1355
|
+
circle and flipping a fitted normal towards the viewer keeps the
|
|
1356
|
+
scene's whole light model in about fifteen lines, where hand-rolling
|
|
1357
|
+
the light loop (tier 3) would have to match the tone mapping and
|
|
1358
|
+
output encoding by hand.
|
|
1039
1359
|
3. A FRAGMENT OF YOUR OWN over the scene set. Compose `SCENE` (or
|
|
1040
1360
|
`sceneSource({ lights, receiveShadow, env, fog })`, each flag leaving a
|
|
1041
1361
|
declaration out): it declares uCamPos, uHemiSky/uHemiGround, the
|
|
@@ -1049,11 +1369,9 @@ three tiers, top first:
|
|
|
1049
1369
|
loops `sceneLight(i, position, normal)` to `uLightCount` instead of a
|
|
1050
1370
|
shade function: light i's direction and its color already attenuated,
|
|
1051
1371
|
cone-faded and shadowed (zero when it cannot reach). The stock
|
|
1052
|
-
materials are built from this same set, so the tiers cannot drift
|
|
1053
|
-
`probes/scene-set-probe.tsx` is the byte-identity rig that checks it.
|
|
1372
|
+
materials are built from this same set, so the tiers cannot drift.
|
|
1054
1373
|
The demo `the-third-dimension.tsx` has tier 2 (the ground) and tier 3
|
|
1055
|
-
(the knot's rim term)
|
|
1056
|
-
sceneLight loop beside a lit() floor.
|
|
1374
|
+
(the knot's rim term).
|
|
1057
1375
|
`standardFragment(options)` is the same for `standard`: lit's options
|
|
1058
1376
|
minus `specularMap`/`env` (the environment is always composed) plus
|
|
1059
1377
|
`metalnessMap`/`roughnessMap`, on the same `litVertex(options)`, with
|
|
@@ -1071,6 +1389,8 @@ a shadow program that never reads `n` (no triplanar, no surface function
|
|
|
1071
1389
|
using it) reflects `uNormal` inactive - set `normalMatrix: false` on that
|
|
1072
1390
|
instance or every caster move warns about the skipped write.
|
|
1073
1391
|
|
|
1392
|
+
### Lights
|
|
1393
|
+
|
|
1074
1394
|
Lights and `lit`: lights are graph NODES, like Three. `createDirectionalLight({
|
|
1075
1395
|
direction?, color?, intensity? })` / `<DirectionalLight>` is parallel light
|
|
1076
1396
|
travelling along `direction` in the node's LOCAL space (default `[0, -1,
|
|
@@ -1116,6 +1436,8 @@ scenes. Everything starts black: a lit scene with no light shows
|
|
|
1116
1436
|
nothing, on purpose, like Three. `examples/lamps.tsx` is the spot/point
|
|
1117
1437
|
shape (soft vs hard cone, casting spots, an orbiting bulb).
|
|
1118
1438
|
|
|
1439
|
+
### lit
|
|
1440
|
+
|
|
1119
1441
|
`lit(opts)` is the standard look beside `unlit`: hemisphere ambient plus
|
|
1120
1442
|
the directional list, Lambert diffuse, Blinn-Phong highlight when
|
|
1121
1443
|
`specular` (0..1 strength) is set with `shininess` (default 30), a
|
|
@@ -1141,6 +1463,8 @@ or more beside it, or trilinear smears the far half into the mip its long
|
|
|
1141
1463
|
axis picked (`createModel` uploads its images with both; the device clamps
|
|
1142
1464
|
the level, `limits.maxAnisotropy` reports it).
|
|
1143
1465
|
|
|
1466
|
+
#### Surface maps
|
|
1467
|
+
|
|
1144
1468
|
The surface maps, each an option beside `map` and sampled at its uv:
|
|
1145
1469
|
|
|
1146
1470
|
- `normalMap` (+ `normalScale`, ONE float as in Unity/Godot - Three's
|
|
@@ -1175,6 +1499,8 @@ The surface maps, each an option beside `map` and sampled at its uv:
|
|
|
1175
1499
|
|
|
1176
1500
|
`examples/materials.tsx` shows all five.
|
|
1177
1501
|
|
|
1502
|
+
### standard
|
|
1503
|
+
|
|
1178
1504
|
`standard(opts)` is the metalness/roughness material, the look authored
|
|
1179
1505
|
assets expect (Three's MeshStandardMaterial, Godot's StandardMaterial3D,
|
|
1180
1506
|
Unity's Standard): every lit option but the Blinn-Phong knobs
|
|
@@ -1213,70 +1539,82 @@ classes do not warn about an inactive uniform.
|
|
|
1213
1539
|
|
|
1214
1540
|
## Models
|
|
1215
1541
|
|
|
1542
|
+
### Three layers
|
|
1543
|
+
|
|
1216
1544
|
Authored models come in as glTF 2.0 (.gltf with its .bin and image files
|
|
1217
1545
|
next to it, or single-file .glb) and become a Group carrying the file's
|
|
1218
1546
|
node hierarchy, Three's `gltf.scene`. Three layers, use the lowest that
|
|
1219
1547
|
fits:
|
|
1220
1548
|
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1549
|
+
#### parseGltf
|
|
1550
|
+
|
|
1551
|
+
`parseGltf(bytes, resolve?)` - the pure parser (no engine, runs under
|
|
1552
|
+
bun and on flux): `ModelData` = `nodes` (the retained hierarchy in
|
|
1553
|
+
pre-order - name, parent index, local TRS; matrix-form nodes are
|
|
1554
|
+
TRS-decomposed, shear dropped; nodes that carry no part, joint or
|
|
1555
|
+
animation target anywhere - cameras, lights, unused empties - are
|
|
1556
|
+
pruned), `parts` (one per mesh primitive, its node's NAME kept, `node`
|
|
1557
|
+
index, vertices in the standard layout LOCAL to the node - except
|
|
1558
|
+
skinned parts: "skinned" layout, model-space bind pose, `skin` index),
|
|
1559
|
+
`skins` (joint node indices + inverse binds), `clips` (the animations
|
|
1560
|
+
as baked channel buffers: node/path/interpolation, times, values),
|
|
1561
|
+
`materials` (base color factor, `map` =
|
|
1562
|
+
index into `images`, `doubleSided`, `transparent` = alphaMode BLEND,
|
|
1563
|
+
`alphaMode` as written and `alphaCutoff`, spec default 0.5, the
|
|
1564
|
+
normal and emissive slots, `metalness`/`roughness` factors and the
|
|
1565
|
+
packed `metalnessRoughnessMap` - standard's inputs), `images`
|
|
1566
|
+
(the encoded PNG/JPEG bytes, undecoded) and `bounds` (world-space rest
|
|
1567
|
+
pose, conservative for parts under rotated nodes). External
|
|
1568
|
+
files come through `resolve(uri)` (uri as written, still
|
|
1569
|
+
percent-encoded; `gltfExternalUris(bytes)` lists them so an async
|
|
1570
|
+
caller can read them first) - for a .gltf AND for a .glb, which is
|
|
1571
|
+
usually self-contained but may legally reference external images
|
|
1572
|
+
(real exporters do); data: uris need no resolver. Missing
|
|
1573
|
+
normals produce FLAT shading (the spec's rule): the primitive is
|
|
1574
|
+
un-indexed, one vertex per corner. A mirroring node chain (negative
|
|
1575
|
+
rest-pose world determinant) flips the part's index winding so
|
|
1576
|
+
`cull: "back"` still keeps the outside. Non-triangle primitives are
|
|
1577
|
+
skipped; a required extension the parser does not implement throws
|
|
1578
|
+
naming it, and Draco or meshopt compression throws "re-export without
|
|
1579
|
+
mesh compression" - Blender exports Draco by DEFAULT, so that is the
|
|
1580
|
+
first error a real file hits.
|
|
1581
|
+
|
|
1582
|
+
#### createModel
|
|
1583
|
+
|
|
1584
|
+
`createModel(data, { material?, label? })` - uploads the images (repeat
|
|
1585
|
+
wrap, mipmapped, 4x anisotropic), makes one material per glTF material (default `standard`
|
|
1586
|
+
with the file's color, maps, normal scale, metalness/roughness and
|
|
1587
|
+
packed map, emissive and transparency - the glTF material model, so a
|
|
1588
|
+
scene showing a model wants an `environment` (a glTF metal in a scene
|
|
1589
|
+
with none renders near black); `material: (m, maps, skinned) =>
|
|
1590
|
+
lit({ color: m.color, map: maps.map ?? undefined, skinned })` for the
|
|
1591
|
+
Blinn-Phong look, or any other material; it is called once per material and shared), the node table as nested
|
|
1592
|
+
Groups with the file's local TRS, and one mesh per part under its node,
|
|
1593
|
+
all inside the returned `Model` (a Group): `add(scene.root, model)`,
|
|
1594
|
+
place it with `setTransform`, find parts by name in `model.parts`
|
|
1595
|
+
(`{ name, mesh }`), spin a wheel relative to its axle through
|
|
1596
|
+
`model.nodes` (`{ name, node }` in table order, parents first; names
|
|
1597
|
+
repeat when the file's do - `.find()` yours), `model.bounds` for
|
|
1598
|
+
framing a camera. Skinned parts get the `skinned: true` material
|
|
1599
|
+
variant and hang off the model ROOT (the spec ignores their node's
|
|
1600
|
+
transform; the palette places them - see the mixer below). `dispose()`
|
|
1601
|
+
detaches it and frees the geometry buffers and textures - the model owns
|
|
1602
|
+
them, nothing else frees them.
|
|
1603
|
+
|
|
1604
|
+
#### loadGltf and loadModel
|
|
1605
|
+
|
|
1606
|
+
`loadGltf(path)` / `loadModel(path)` - read from `assets/` with flux:fs
|
|
1607
|
+
and build. `loadModel` reads the baked `.srtm` written by `srt tool
|
|
1608
|
+
3d/model <in.gltf|glb> -o assets/<name>.srtm`: the same parse run once
|
|
1609
|
+
under bun, stored in the GPU layout, so loading is views onto the file's
|
|
1610
|
+
bytes plus the image decodes. Numbers from a 32k-vertex, 6-texture model
|
|
1611
|
+
on a release client: `parseGltf` 124 ms on flux (22 ms under bun) against
|
|
1612
|
+
40 ms for the whole baked load - the runtime parse is fine for small
|
|
1613
|
+
models and a binary import (`import bytes from "./x.glb" with { type:
|
|
1614
|
+
"binary" }` then `createModel(parseGltf(bytes))`, see
|
|
1615
|
+
`examples/model.tsx`); bake anything big.
|
|
1616
|
+
|
|
1617
|
+
### Async loading
|
|
1280
1618
|
|
|
1281
1619
|
Loading is async everywhere but the binary import: loadGltf/loadModel
|
|
1282
1620
|
return promises, and the async value must be read the way Solid 2 async
|
|
@@ -1296,6 +1634,8 @@ a source glTF must be parsed at runtime, do the parse in an isolate
|
|
|
1296
1634
|
(parseGltf's result is plain data and copies across) and keep
|
|
1297
1635
|
createModel on main.
|
|
1298
1636
|
|
|
1637
|
+
### Placement and sockets
|
|
1638
|
+
|
|
1299
1639
|
Placement: pieces of one authored set (a body and its fitted cosmetics)
|
|
1300
1640
|
export in one world space, so composing them is `add(group, model)` per
|
|
1301
1641
|
piece and nothing else - no placement math. SOCKETED items (a weapon in
|
|
@@ -1303,8 +1643,8 @@ a hand) are different: they bind to a joint, so they only land once a
|
|
|
1303
1643
|
skeleton exists (a rig-less export cannot place them at all). The joint
|
|
1304
1644
|
is an ordinary Group in `model.nodes` - find it by name and `add()` the
|
|
1305
1645
|
item under it; it then follows the pose, mixer-driven or hand-posed,
|
|
1306
|
-
like any child transform. Two authoring cases
|
|
1307
|
-
|
|
1646
|
+
like any child transform. Two authoring cases: an item authored about
|
|
1647
|
+
its own socket
|
|
1308
1648
|
origin needs the plain `add()` and nothing more; one authored in the
|
|
1309
1649
|
RIG'S model space needs a socket Group between joint and item carrying
|
|
1310
1650
|
the joint's rest-pose inverse (at rest the item then sits exactly where
|
|
@@ -1313,6 +1653,34 @@ transform on top of the authored placement. Skinned PARTS are the one
|
|
|
1313
1653
|
thing that never needs this: they hang off the model root and the
|
|
1314
1654
|
palette places them.
|
|
1315
1655
|
|
|
1656
|
+
### Wardrobe pieces
|
|
1657
|
+
|
|
1658
|
+
Wardrobe pieces (a hood, a cape, cuffs) are the third case: exported
|
|
1659
|
+
WITH a skin over the body's joints and WITHOUT clips, so beside an
|
|
1660
|
+
animated body they hold their bind pose. `bindSkeleton(body, piece)`
|
|
1661
|
+
drives them from the body's skeleton - Three's SkinnedMesh.bind, Unity's
|
|
1662
|
+
`bones =`, Godot's shared Skeleton3D. The piece's palette rows re-bind
|
|
1663
|
+
onto the body's joint nodes (matched by name, case-insensitive) with
|
|
1664
|
+
the piece's own inverse binds, so the flush writes the body's pose into
|
|
1665
|
+
the piece's skin and no per-frame code exists; joints with no body
|
|
1666
|
+
counterpart (a hat's internal bones) and rigid parts hanging off a
|
|
1667
|
+
matched node are grafted under the matched body joint and ride it. The
|
|
1668
|
+
piece then hangs under the body at the body's placement (its skinned
|
|
1669
|
+
vertices are in the body's model space), and its own joint nodes are no
|
|
1670
|
+
longer posed by anything: read poses from and socket items on the
|
|
1671
|
+
BODY's joints. The piece's node tree is never composed - exporters
|
|
1672
|
+
truncate it above the spine or skip an ancestor in the middle, and
|
|
1673
|
+
copying locals through such a tree hangs joints off the wrong place
|
|
1674
|
+
while looking right for every piece whose tree happens to match; the
|
|
1675
|
+
body's world matrices are what a bone matrix needs. A shared joint
|
|
1676
|
+
whose bind pose differs (a piece exported against another rest pose or
|
|
1677
|
+
scale) throws at bind instead of rendering wrong. A piece comes off by
|
|
1678
|
+
disposing it; disposing a body disposes what it wears. Culling follows
|
|
1679
|
+
the wear: a body joint's box is the union of every skin reaching it,
|
|
1680
|
+
its own and the pieces'.
|
|
1681
|
+
|
|
1682
|
+
### Applied glTF material fields
|
|
1683
|
+
|
|
1316
1684
|
Applied: `doubleSided` (the default material draws it with `cull:
|
|
1317
1685
|
"none"`), alphaMode MASK (`alphaTest: alphaCutoff`), `normalTexture`
|
|
1318
1686
|
(+ scale; the derivative frame needs no tangents), `emissiveFactor` x
|
|
@@ -1328,6 +1696,9 @@ by lit()/standard() option name (`maps.map`/`maps.normalMap`/
|
|
|
1328
1696
|
A `.srtm` baked before the material records carried the PBR fields
|
|
1329
1697
|
(file version 3) is rejected by loadModel - re-bake with `srt tool
|
|
1330
1698
|
3d/model`.
|
|
1699
|
+
|
|
1700
|
+
### Animation
|
|
1701
|
+
|
|
1331
1702
|
Animation: `createMixer(model)` plays `model.clips` by name -
|
|
1332
1703
|
`mixer.play(name, { loop?, speed?, fadeMs? })` (fadeMs crossfades: the
|
|
1333
1704
|
named clip fades in, everything else fades out - Unity's CrossFade,
|
|
@@ -1355,6 +1726,9 @@ so a TRANSPARENT mesh parented under a player-animated joint does not
|
|
|
1355
1726
|
re-trigger the back-to-front re-sort while it animates (opaque meshes,
|
|
1356
1727
|
palettes and picking are unaffected) - nudge the scene with any
|
|
1357
1728
|
setTransform if it shows, until the core-side transparent sort lands.
|
|
1729
|
+
|
|
1730
|
+
### Root motion
|
|
1731
|
+
|
|
1358
1732
|
Root motion: `play(name, { inPlace? })` strips a clip's root travel
|
|
1359
1733
|
(Unity's applyRootMotion off, Godot's root_motion_track): the root's
|
|
1360
1734
|
x/z hold at the clip's first key and its height rebases onto the root's
|
|
@@ -1391,7 +1765,11 @@ horizontal travel (Unity's bake-into-pose Y - for a controller that
|
|
|
1391
1765
|
owns gravity). `inPlace` is ignored while rootMotion is set. Yaw is
|
|
1392
1766
|
the swing-twist about up (exact under any lean); a cubic rotation
|
|
1393
1767
|
track is linearized at 60 keys/s before its yaw is held. Verified on
|
|
1394
|
-
Mixamo's standing turns (external/mixamo-turn).
|
|
1768
|
+
Mixamo's standing turns (external/mixamo-turn).
|
|
1769
|
+
|
|
1770
|
+
### Skins
|
|
1771
|
+
|
|
1772
|
+
Skins need nothing further: each skin's uBones palette
|
|
1395
1773
|
(model-local jointWorld x inverseBind, sized to the RIG - an rgba32f
|
|
1396
1774
|
float texture, 4 texels wide, one row per joint, sampled in the vertex
|
|
1397
1775
|
stage via texelFetch, so there is no joint cap) is composed by the
|
|
@@ -1401,6 +1779,8 @@ computed-once texture. `sampleChannel` (pure, from the root) stays the
|
|
|
1401
1779
|
JS sampling core for checks and custom drivers;
|
|
1402
1780
|
okf/done/animation-core.md records the design.
|
|
1403
1781
|
|
|
1782
|
+
### Not in the subset
|
|
1783
|
+
|
|
1404
1784
|
Not in the subset, dropped: vertex colors, tangents and further UV sets;
|
|
1405
1785
|
morph targets (the "weights" channel path); samplers are ignored (every
|
|
1406
1786
|
texture repeats); additive blending draws as base color. The follow-ups
|
|
@@ -1410,6 +1790,8 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
|
|
|
1410
1790
|
|
|
1411
1791
|
## Traps
|
|
1412
1792
|
|
|
1793
|
+
### Models and skinning
|
|
1794
|
+
|
|
1413
1795
|
- A model's vertices are LOCAL to their node; the file's placement lives
|
|
1414
1796
|
in the node-table TRS, composed by the scene like any Group chain. So
|
|
1415
1797
|
a part's `geometry` alone is at the origin, `model.bounds` (rest-pose
|
|
@@ -1417,6 +1799,9 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
|
|
|
1417
1799
|
subtree. The winding flip for a mirroring node chain is baked into the
|
|
1418
1800
|
index order from the REST pose - re-scaling a node across zero at
|
|
1419
1801
|
runtime shows mesh interiors, so do not do that.
|
|
1802
|
+
- A worn piece's joints are dead after `bindSkeleton`: `piece.nodes`
|
|
1803
|
+
still lists them, but nothing poses them and their palette rows sit
|
|
1804
|
+
on the body's nodes now. Pose, read and socket on the body.
|
|
1420
1805
|
- Skinning is a VERTEX-STAGE effect: everything that runs off the
|
|
1421
1806
|
retained tree sees the bind pose. A skinned mesh picks by its
|
|
1422
1807
|
bind-pose triangles at the model root and its transparent sort key is
|
|
@@ -1424,6 +1809,9 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
|
|
|
1424
1809
|
MODEL moves both. Shadows are the exception: the shadow variants
|
|
1425
1810
|
(depth and cutout) skin by the same uBones palette, so a caster casts
|
|
1426
1811
|
its pose.
|
|
1812
|
+
|
|
1813
|
+
### Materials and color
|
|
1814
|
+
|
|
1427
1815
|
- A `standard` metal in a scene with no environment renders near black:
|
|
1428
1816
|
its diffuse is zero and the black placeholder cube is all there is to
|
|
1429
1817
|
reflect (Three and Godot render the same; Unity falls back to an
|
|
@@ -1431,74 +1819,9 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
|
|
|
1431
1819
|
so an untextured asset is all metal, and createModel's default is
|
|
1432
1820
|
`standard`: give a model scene an `environment` (loadEnvironment's
|
|
1433
1821
|
baked .srte, or the skybox's cube), or pass `lit` as the material.
|
|
1434
|
-
- A reflection probe is a mirrored render (x-flipped projection, winding
|
|
1435
|
-
inverted by the engine): anything built from screen-space derivatives
|
|
1436
|
-
flips with it, so a normal-mapped surface shows its bumps INVERTED in
|
|
1437
|
-
a probe's reflection. Known and shared with every engine's mirrored
|
|
1438
|
-
views; a `uMirror` sign on the derivative frame is the fix when it
|
|
1439
|
-
matters.
|
|
1440
|
-
- A generated cube chain (`mipmap: true` from six faces) is a box
|
|
1441
|
-
filter, not the GGX convolution the roughness-to-level rule assumes:
|
|
1442
|
-
rough reflections read too sharp and the diffuse `envIrradiance` is a
|
|
1443
|
-
4x4 average. Bake with `srt tool 3d/environment` for anything
|
|
1444
|
-
photographed; the JS sky gradients in the examples get away with it.
|
|
1445
1822
|
- Light intensities are the same numbers for `lit` and `standard`: 1
|
|
1446
1823
|
lights a white matte surface to 1 face-on. A Three scene's intensities
|
|
1447
1824
|
are a factor pi larger for the same look; divide when porting.
|
|
1448
|
-
- The y-down clip flip is baked into `perspective()`; scene code and
|
|
1449
|
-
geometry are plain y-up right-handed, and CCW-outward winding culls
|
|
1450
|
-
correctly with `cull: "back"`. Do NOT negate y anywhere else, and do not
|
|
1451
|
-
"fix" the negated row of `perspective()` - both would mirror the winding
|
|
1452
|
-
and show mesh interiors.
|
|
1453
|
-
- `visible: false` keeps the entry, drawn with `instanceCount: 0` (a
|
|
1454
|
-
cheap off switch); unhiding writes 1, or the mesh's own record count
|
|
1455
|
-
when it is instanced - never a bare 1 into an instanced entry. Hidden
|
|
1456
|
-
meshes skip uModel writes; the fresh matrix is
|
|
1457
|
-
written on unhide. A freshly attached entry starts off the same way and
|
|
1458
|
-
the core's flush turns it on when it writes uModel - never add one
|
|
1459
|
-
live: it has no world matrix yet, and drawn before the sync microtask it
|
|
1460
|
-
flashes at the world origin for a frame.
|
|
1461
|
-
- Instancing pairs strictly at add(), like layout: an instanced material
|
|
1462
|
-
needs a createInstancedMesh or createRecordMesh mesh and vice versa,
|
|
1463
|
-
and the record strides must match the material's attributes per slot
|
|
1464
|
-
(the 16 floats of INSTANCE_MATRIX_ATTRIBUTES in slot 0 on an instanced
|
|
1465
|
-
mesh, its style stride in slot 1; a record mesh is slot 0 only) - each
|
|
1466
|
-
mismatch throws there, at creation for the mesh's own material and at
|
|
1467
|
-
add() for a swapped one. The instance buffers are MESH-owned (unlike
|
|
1468
|
-
shared geometry buffers): `disposeInstances` is their one free, and
|
|
1469
|
-
the mesh cannot be re-added afterwards. Capacity grows by REPLACEMENT,
|
|
1470
|
-
never resize: an addInstance or setRecords past capacity doubles into
|
|
1471
|
-
new buffers and swaps them in (an instanced mesh's live records move
|
|
1472
|
-
in one core `retargetRecords`, its style mirror republishes) -
|
|
1473
|
-
amortized like a dynamic array, same policy as @solidrt/2d; size
|
|
1474
|
-
`capacity` or the initial records to skip the copies. An instanced
|
|
1475
|
-
mesh's style stride comes from its material AT CREATION: a later
|
|
1476
|
-
setMaterial to a class with another slot-1 layout throws at the
|
|
1477
|
-
rebuild.
|
|
1478
|
-
- Style writes are mirrored, not immediate: `setInstanceStyle` lands in
|
|
1479
|
-
`MeshInstances.style.data` and the scene's sync publishes the dirty
|
|
1480
|
-
range - so a write on a mesh outside any scene shows once it is added
|
|
1481
|
-
(the attach republishes the whole mirror), and reading the GPU buffer
|
|
1482
|
-
back mid-frame can lag the mirror by one sync.
|
|
1483
|
-
- Instanced casters: `castShadow` on a populated mesh needs a depth pass
|
|
1484
|
-
with the instance placement in it - the stock materials' `instanced`
|
|
1485
|
-
carries one, a custom class needs `shadowVertex` (see shadows below);
|
|
1486
|
-
a class without one is skipped by shadow views, silently.
|
|
1487
|
-
- A record mesh without explicit `bounds` has no BVH leaf: it never
|
|
1488
|
-
picks, pointer events never target it, and its transparent sort key
|
|
1489
|
-
falls back to the node's world position. That is deliberate - records
|
|
1490
|
-
are opaque to the library, so any inferred box would be a guess. Supply
|
|
1491
|
-
`bounds` for anything pickable or transparent. An instanced mesh picks
|
|
1492
|
-
per instance regardless and culls by its instances' union; its
|
|
1493
|
-
`bounds` replace that union and give the transparent sort a center.
|
|
1494
|
-
- An instance node's own fields hold its LOCAL pose (relative to its
|
|
1495
|
-
parent, as for any node); the record the core writes is its pose
|
|
1496
|
-
relative to the MESH, composed through any group between them. A
|
|
1497
|
-
removed instance's children stay parented to the dead node: remove()
|
|
1498
|
-
one to re-use it. `disposeInstances` on an instanced mesh flushes the
|
|
1499
|
-
core before freeing the buffer, so the destroyed instances' hiding
|
|
1500
|
-
writes land in a live buffer; keep that order if you ever free a
|
|
1501
|
-
record buffer by hand.
|
|
1502
1825
|
- Transparency is an EXPLICIT material flag, Three's rule: `unlit({ color:
|
|
1503
1826
|
[r, g, b, 0.5] })` still draws opaque, and opaque means it: the standard
|
|
1504
1827
|
classes write alpha 1 when not `transparent` (the scene target is
|
|
@@ -1525,6 +1848,54 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
|
|
|
1525
1848
|
translucents can sort wrong (center distance, not per-pixel) - that is the
|
|
1526
1849
|
engine contract, no OIT. A `shaderMaterial({ transparent: true })`
|
|
1527
1850
|
fragment must write PREMULTIPLIED output (`vec4(rgb * a, a)`).
|
|
1851
|
+
- A color map created as plain rgba8 renders WASHED OUT: the fragment
|
|
1852
|
+
reads its encoded bytes as linear light and encodes them again. Create
|
|
1853
|
+
color images (base color, emissive, a sky's faces or panorama) with
|
|
1854
|
+
`format: "rgba8-srgb"`; keep data maps rgba8. A rendered rgba8 texture
|
|
1855
|
+
(a scene view, a shader target, a UI capture) holds encoded pixels and
|
|
1856
|
+
cannot be tagged, so as a `map` it needs `srgbToLinear` from SRGB in a
|
|
1857
|
+
custom fragment (no material option yet); a draw target you create
|
|
1858
|
+
yourself can be `format: "rgba8-srgb"` and then decodes on sample (it
|
|
1859
|
+
is sampler-only: no display, readback or copy).
|
|
1860
|
+
- Reading the scene texture back (a probe's readTexture, a snapshot)
|
|
1861
|
+
gives ENCODED pixels: an expected linear value v shows as
|
|
1862
|
+
`linearToSrgb(v) * 255` - intensity 0.5 reads 188, not 128.
|
|
1863
|
+
|
|
1864
|
+
### Environment and background
|
|
1865
|
+
|
|
1866
|
+
- A reflection probe is a mirrored render (x-flipped projection, winding
|
|
1867
|
+
inverted by the engine): anything built from screen-space derivatives
|
|
1868
|
+
flips with it, so a normal-mapped surface shows its bumps INVERTED in
|
|
1869
|
+
a probe's reflection. Known and shared with every engine's mirrored
|
|
1870
|
+
views; a `uMirror` sign on the derivative frame is the fix when it
|
|
1871
|
+
matters.
|
|
1872
|
+
- A generated cube chain (`mipmap: true` from six faces) is a box
|
|
1873
|
+
filter, not the GGX convolution the roughness-to-level rule assumes:
|
|
1874
|
+
rough reflections read too sharp and the diffuse `envIrradiance` is a
|
|
1875
|
+
4x4 average. Bake with `srt tool 3d/environment` for anything
|
|
1876
|
+
photographed; the JS sky gradients in the examples get away with it.
|
|
1877
|
+
- The background covers the whole target with depth off, drawn first: it
|
|
1878
|
+
REPLACES the clearColor visually (the clear still runs; you just never
|
|
1879
|
+
see it), and a `transparent: true` mesh blends over it in-pass since the
|
|
1880
|
+
background is always entry zero.
|
|
1881
|
+
- The background pipeline/program are SCENE-OWNED (unlike shared
|
|
1882
|
+
material pipelines): setBackground(null), replacement, and dispose()
|
|
1883
|
+
destroy them. Do not hand the background's pipeline to anything else.
|
|
1884
|
+
A skybox is the same slot with the library's fragment; only a
|
|
1885
|
+
skybox-to-skybox replace keeps the entry (params and cube rewritten).
|
|
1886
|
+
- The environment binds through the light rewrite's map set (uEnv
|
|
1887
|
+
beside uShadowAtlas on every receiving target, new views included) and
|
|
1888
|
+
directly on setEnvironment; the placeholder cube is app-lifetime like
|
|
1889
|
+
the shadow placeholder. A `lit` without `reflectivity` declares no
|
|
1890
|
+
environment sampler - the flag is part of the class key.
|
|
1891
|
+
|
|
1892
|
+
### Coordinates and rotation
|
|
1893
|
+
|
|
1894
|
+
- The y-down clip flip is baked into `perspective()`; scene code and
|
|
1895
|
+
geometry are plain y-up right-handed, and CCW-outward winding culls
|
|
1896
|
+
correctly with `cull: "back"`. Do NOT negate y anywhere else, and do not
|
|
1897
|
+
"fix" the negated row of `perspective()` - both would mirror the winding
|
|
1898
|
+
and show mesh interiors.
|
|
1528
1899
|
- Rotation is stored as a QUATERNION (`node.quaternion`, `[x, y, z, w]`,
|
|
1529
1900
|
always unit). There is exactly one rotation field: no `node.rotation`
|
|
1530
1901
|
shadowing it, because a second field is a second thing to go stale (an
|
|
@@ -1596,14 +1967,70 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
|
|
|
1596
1967
|
- Transforms have ONE write path: `setTransform`/`lookAt`/`setVisible` (or
|
|
1597
1968
|
the props that call them). Mutating `node.position` directly does not
|
|
1598
1969
|
sync. Components have no `lookAt` prop - aim through a `ref`.
|
|
1599
|
-
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1970
|
+
- Vec3/Quat arguments are COPIED IN everywhere (`setTransform`, `lookAt`,
|
|
1971
|
+
`setCamera`, params), so ONE scratch array reused every frame is safe -
|
|
1972
|
+
allocating three arrays per node per frame is pure waste. The node's own
|
|
1973
|
+
`position`/`quaternion`/`scale` are the live arrays: read them, do not
|
|
1974
|
+
hand them out and do not mutate them (that write does not sync).
|
|
1975
|
+
- `setTransform` early-outs on an unchanged value (rotation compared AFTER
|
|
1976
|
+
euler conversion), so driving every node unconditionally from `onFrame`
|
|
1977
|
+
costs only the compare for nodes that did not move. Compares are exact,
|
|
1978
|
+
like `setVisible`.
|
|
1979
|
+
|
|
1980
|
+
### Visibility and instancing
|
|
1981
|
+
|
|
1982
|
+
- `visible: false` keeps the entry, drawn with `instanceCount: 0` (a
|
|
1983
|
+
cheap off switch); unhiding writes 1, or the mesh's own record count
|
|
1984
|
+
when it is instanced - never a bare 1 into an instanced entry. Hidden
|
|
1985
|
+
meshes skip uModel writes; the fresh matrix is
|
|
1986
|
+
written on unhide. A freshly attached entry starts off the same way and
|
|
1987
|
+
the core's flush turns it on when it writes uModel - never add one
|
|
1988
|
+
live: it has no world matrix yet, and drawn before the sync microtask it
|
|
1989
|
+
flashes at the world origin for a frame.
|
|
1990
|
+
- Instancing pairs strictly at add(), like layout: an instanced material
|
|
1991
|
+
needs a createInstancedMesh or createRecordMesh mesh and vice versa,
|
|
1992
|
+
and the record strides must match the material's attributes per slot
|
|
1993
|
+
(the 16 floats of INSTANCE_MATRIX_ATTRIBUTES in slot 0 on an instanced
|
|
1994
|
+
mesh, its style stride in slot 1; a record mesh is slot 0 only) - each
|
|
1995
|
+
mismatch throws there, at creation for the mesh's own material and at
|
|
1996
|
+
add() for a swapped one. The instance buffers are MESH-owned (unlike
|
|
1997
|
+
shared geometry buffers): `disposeInstances` is their one free, and
|
|
1998
|
+
the mesh cannot be re-added afterwards. Capacity grows by REPLACEMENT,
|
|
1999
|
+
never resize: an addInstance or setRecords past capacity doubles into
|
|
2000
|
+
new buffers and swaps them in (an instanced mesh's live records move
|
|
2001
|
+
in one core `retargetRecords`, its style mirror republishes) -
|
|
2002
|
+
amortized like a dynamic array, same policy as @solidrt/2d; size
|
|
2003
|
+
`capacity` or the initial records to skip the copies. An instanced
|
|
2004
|
+
mesh's style stride comes from its material AT CREATION: a later
|
|
2005
|
+
setMaterial to a class with another slot-1 layout throws at the
|
|
2006
|
+
rebuild.
|
|
2007
|
+
- Style writes are mirrored, not immediate: `setInstanceStyle` lands in
|
|
2008
|
+
`MeshInstances.style.data` and the scene's sync publishes the dirty
|
|
2009
|
+
range - so a write on a mesh outside any scene shows once it is added
|
|
2010
|
+
(the attach republishes the whole mirror), and reading the GPU buffer
|
|
2011
|
+
back mid-frame can lag the mirror by one sync.
|
|
2012
|
+
- Instanced casters: `castShadow` on a populated mesh needs a depth pass
|
|
2013
|
+
with the instance placement in it - the stock materials' `instanced`
|
|
2014
|
+
carries one, a custom class needs `shadowVertex` (see shadows below);
|
|
2015
|
+
a class without one is skipped by shadow views, silently.
|
|
2016
|
+
- A record mesh without explicit `bounds` has no BVH leaf: it never
|
|
2017
|
+
picks, pointer events never target it, and its transparent sort key
|
|
2018
|
+
falls back to the node's world position. That is deliberate - records
|
|
2019
|
+
are opaque to the library, so any inferred box would be a guess. Supply
|
|
2020
|
+
`bounds` for anything pickable or transparent. An instanced mesh picks
|
|
2021
|
+
per instance regardless and culls by its instances' union; its
|
|
2022
|
+
`bounds` replace that union and give the transparent sort a center.
|
|
2023
|
+
- An instance node's own fields hold its LOCAL pose (relative to its
|
|
2024
|
+
parent, as for any node); the record the core writes is its pose
|
|
2025
|
+
relative to the MESH, composed through any group between them. A
|
|
2026
|
+
removed instance's children stay parented to the dead node: remove()
|
|
2027
|
+
one to re-use it. `disposeInstances` on an instanced mesh flushes the
|
|
2028
|
+
core before freeing the buffer, so the destroyed instances' hiding
|
|
2029
|
+
writes land in a live buffer; keep that order if you ever free a
|
|
2030
|
+
record buffer by hand.
|
|
2031
|
+
|
|
2032
|
+
### Shadows
|
|
2033
|
+
|
|
1607
2034
|
- A CASTING light's position matters (nothing else about a directional
|
|
1608
2035
|
light's position does): the shadow camera is placed AT the light node's
|
|
1609
2036
|
world position, Three's rule, so a `castShadow` sun at the origin
|
|
@@ -1633,6 +2060,17 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
|
|
|
1633
2060
|
is a full extra pass over the casters plus a sampler unit on every
|
|
1634
2061
|
receiving program (MAX_LIGHTS of those are always bound, placeholders
|
|
1635
2062
|
included), so cast from the lights that matter, not all of them.
|
|
2063
|
+
|
|
2064
|
+
### Views and scene params
|
|
2065
|
+
|
|
2066
|
+
- A camera change is ONE `setTargetParams` write (uViewProj + uCamPos are
|
|
2067
|
+
target state), independent of mesh count - never reintroduce per-mesh
|
|
2068
|
+
camera writes (uEye-style per-mesh params are exactly the O(scene) cost
|
|
2069
|
+
the shared channel removed). Scene scale honestly: hundreds to a
|
|
2070
|
+
few thousand objects, bounded by the interpreter, not the GPU. A view
|
|
2071
|
+
is one more such write per camera change and one more entry per mesh
|
|
2072
|
+
at attach; a view's per-frame cost is the core's (one params write per
|
|
2073
|
+
sink per moved node), never JS.
|
|
1636
2074
|
- A mesh's entries are mirrored into every view at attach and dropped at
|
|
1637
2075
|
detach; `setGeometry`/`setMaterial` rebuild them everywhere. An
|
|
1638
2076
|
`overrideMaterial` is validated against every mesh's layout (at
|
|
@@ -1653,31 +2091,9 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
|
|
|
1653
2091
|
differs per mesh. (`scene.texture` IS the draw target id, so
|
|
1654
2092
|
`setTargetParams(scene.texture, ...)` is the same write - setParams is
|
|
1655
2093
|
the sanctioned spelling.)
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
`position`/`quaternion`/`scale` are the live arrays: read them, do not
|
|
1660
|
-
hand them out and do not mutate them (that write does not sync).
|
|
1661
|
-
- `setTransform` early-outs on an unchanged value (rotation compared AFTER
|
|
1662
|
-
euler conversion), so driving every node unconditionally from `onFrame`
|
|
1663
|
-
costs only the compare for nodes that did not move. Compares are exact,
|
|
1664
|
-
like `setVisible`.
|
|
1665
|
-
- Per-generator conventions - orientation, UV mapping, which axis a solid
|
|
1666
|
-
stands on, what a cap looks like - live on each generator's doc comment,
|
|
1667
|
-
not here. They are consistent (`plane`/`circle`/`ring` face +z, `torus`
|
|
1668
|
-
lies flat with the hole on y, discs and cylinder caps get a PLANAR disc
|
|
1669
|
-
map inscribed in the unit square) but the doc comment is the source.
|
|
1670
|
-
- Entry rebuild order: `setGeometry`/`setMaterial` re-add the entry at the
|
|
1671
|
-
list END and dirty the order, so the next sync() re-sorts and the mesh
|
|
1672
|
-
keeps its place. `_transparent` on the mesh is the flag AS ATTACHED
|
|
1673
|
-
(setMaterial swaps `mesh.material` before the rebuild, so _detach must
|
|
1674
|
-
not read the new material's flag).
|
|
1675
|
-
- `lathe` takes a CLOSED profile (a cross-section with thickness, or run
|
|
1676
|
-
to the axis at x = 0) - it is a solid of revolution, NOT Three's open
|
|
1677
|
-
polyline shell. An "open" outline must be closed by the author;
|
|
1678
|
-
otherwise the shape is simply wrong, there is no open-profile mode.
|
|
1679
|
-
- `useScene()`/`Group`/`Mesh` throw outside `<Scene>` (default-less
|
|
1680
|
-
context).
|
|
2094
|
+
|
|
2095
|
+
### Shader materials
|
|
2096
|
+
|
|
1681
2097
|
- A `shaderMaterial` INSTANCE is the pipeline handle: identical sources
|
|
1682
2098
|
compile twice - no dedupe by source value (deliberate; hidden
|
|
1683
2099
|
content-keyed caches are the anti-pattern the GPU layer avoids). Create
|
|
@@ -1704,10 +2120,24 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
|
|
|
1704
2120
|
vertex source - a comment counts - selects the "colored" layout, and
|
|
1705
2121
|
the material then rejects standard geometry at add(). Do not mention
|
|
1706
2122
|
aColor you do not read.
|
|
2123
|
+
- A custom fragment that ends in `fragColor = vec4(...)` bypasses the
|
|
2124
|
+
scene: no fog, no exposure or tone mapping, no encode - and a
|
|
2125
|
+
hand-rolled loop over `uLightDir` renders a spot light as a
|
|
2126
|
+
directional one (a lit rectangle on the floor, no cone). What you
|
|
2127
|
+
declare is what runs, and the engine injects nothing, so pick a tier
|
|
2128
|
+
(the three after the GLSL exports under "The model"): a stock fragment
|
|
2129
|
+
on your vertex stage,
|
|
2130
|
+
a `surface` function, or `SCENE` with `shadeBlinn`/`shadePbr` or
|
|
2131
|
+
`sceneLight` and `sceneOutput` at the end. Composing `FOG` and the
|
|
2132
|
+
`SHADOW_*` trio by hand still works and is no longer the shape.
|
|
2133
|
+
|
|
2134
|
+
### Picking, pointer and collision
|
|
2135
|
+
|
|
1707
2136
|
- Picking is triangle-accurate for ordinary meshes (`point` is a surface
|
|
1708
2137
|
point, hits carry `face`/`uv`/`normal`) but box-only for instanced
|
|
1709
|
-
meshes: there `point` is where the ray
|
|
1710
|
-
|
|
2138
|
+
meshes and for lines/points geometry: there `point` is where the ray
|
|
2139
|
+
meets the population `bounds` box (the geometry's box for lines),
|
|
2140
|
+
`normal` that face's, and `face`/`uv` are absent. Never present an
|
|
1711
2141
|
instanced hit as a surface hit. Both tiers run in the spatial core (Rust); never
|
|
1712
2142
|
add a per-triangle path in JS - rays at mesh scale are
|
|
1713
2143
|
interpreter-hostile, and the core already does it.
|
|
@@ -1719,50 +2149,10 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
|
|
|
1719
2149
|
leaf under a design size. Only a leaf whose layout size deliberately
|
|
1720
2150
|
differs from the target (supersampling) needs `handlersFor`, fed the
|
|
1721
2151
|
layout size the app itself set.
|
|
1722
|
-
- A custom fragment that ends in `fragColor = vec4(...)` bypasses the
|
|
1723
|
-
scene: no fog, no exposure or tone mapping, no encode - and a
|
|
1724
|
-
hand-rolled loop over `uLightDir` renders a spot light as a
|
|
1725
|
-
directional one (a lit rectangle on the floor, no cone). What you
|
|
1726
|
-
declare is what runs, and the engine injects nothing, so pick a tier
|
|
1727
|
-
(the three after the GLSL exports under "The model"): a stock fragment
|
|
1728
|
-
on your vertex stage,
|
|
1729
|
-
a `surface` function, or `SCENE` with `shadeBlinn`/`shadePbr` or
|
|
1730
|
-
`sceneLight` and `sceneOutput` at the end. Composing `FOG` and the
|
|
1731
|
-
`SHADOW_*` trio by hand still works and is no longer the shape.
|
|
1732
2152
|
- Hover (enter/leave) reacts to pointer MOTION only: a mesh animating
|
|
1733
2153
|
under a still pointer fires nothing until the next move - the same
|
|
1734
2154
|
limit the element hit test has (hit-test-per-frame is an open platform
|
|
1735
2155
|
item). Do not poll pick() per frame to fake it.
|
|
1736
|
-
- Geometry local bounds cache on the Geometry (like its GPU buffers):
|
|
1737
|
-
geometry is immutable after creation. Mutating `vertices` after a mesh
|
|
1738
|
-
used them leaves stale bounds AND a stale GPU buffer - make a new
|
|
1739
|
-
Geometry instead.
|
|
1740
|
-
- The background covers the whole target with depth off, drawn first: it
|
|
1741
|
-
REPLACES the clearColor visually (the clear still runs; you just never
|
|
1742
|
-
see it), and a `transparent: true` mesh blends over it in-pass since the
|
|
1743
|
-
background is always entry zero.
|
|
1744
|
-
- A color map created as plain rgba8 renders WASHED OUT: the fragment
|
|
1745
|
-
reads its encoded bytes as linear light and encodes them again. Create
|
|
1746
|
-
color images (base color, emissive, a sky's faces or panorama) with
|
|
1747
|
-
`format: "rgba8-srgb"`; keep data maps rgba8. A rendered rgba8 texture
|
|
1748
|
-
(a scene view, a shader target, a UI capture) holds encoded pixels and
|
|
1749
|
-
cannot be tagged, so as a `map` it needs `srgbToLinear` from SRGB in a
|
|
1750
|
-
custom fragment (no material option yet); a draw target you create
|
|
1751
|
-
yourself can be `format: "rgba8-srgb"` and then decodes on sample (it
|
|
1752
|
-
is sampler-only: no display, readback or copy).
|
|
1753
|
-
- Reading the scene texture back (a probe's readTexture, a snapshot)
|
|
1754
|
-
gives ENCODED pixels: an expected linear value v shows as
|
|
1755
|
-
`linearToSrgb(v) * 255` - intensity 0.5 reads 188, not 128.
|
|
1756
|
-
- The background pipeline/program are SCENE-OWNED (unlike shared
|
|
1757
|
-
material pipelines): setBackground(null), replacement, and dispose()
|
|
1758
|
-
destroy them. Do not hand the background's pipeline to anything else.
|
|
1759
|
-
A skybox is the same slot with the library's fragment; only a
|
|
1760
|
-
skybox-to-skybox replace keeps the entry (params and cube rewritten).
|
|
1761
|
-
- The environment binds through the light rewrite's map set (uEnv
|
|
1762
|
-
beside uShadowAtlas on every receiving target, new views included) and
|
|
1763
|
-
directly on setEnvironment; the placeholder cube is app-lifetime like
|
|
1764
|
-
the shadow placeholder. A `lit` without `reflectivity` declares no
|
|
1765
|
-
environment sampler - the flag is part of the class key.
|
|
1766
2156
|
- overlap()/sweep() test SURFACES, the trimesh contract everywhere: a
|
|
1767
2157
|
volume wholly inside a closed mesh with no triangle in reach touches
|
|
1768
2158
|
nothing, and a body whose center has passed through a wall reports
|
|
@@ -1774,3 +2164,26 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
|
|
|
1774
2164
|
floor absorbs the vertical part (a body never creeps down a slope it
|
|
1775
2165
|
can stand on), and with `floorSnap: 0` a standing body reports no
|
|
1776
2166
|
floor unless its motion presses into one.
|
|
2167
|
+
|
|
2168
|
+
### Geometry and components
|
|
2169
|
+
|
|
2170
|
+
- Per-generator conventions - orientation, UV mapping, which axis a solid
|
|
2171
|
+
stands on, what a cap looks like - live on each generator's doc comment,
|
|
2172
|
+
not here. They are consistent (`plane`/`circle`/`ring` face +z, `torus`
|
|
2173
|
+
lies flat with the hole on y, discs and cylinder caps get a PLANAR disc
|
|
2174
|
+
map inscribed in the unit square) but the doc comment is the source.
|
|
2175
|
+
- Entry rebuild order: `setGeometry`/`setMaterial` re-add the entry at the
|
|
2176
|
+
list END and dirty the order, so the next sync() re-sorts and the mesh
|
|
2177
|
+
keeps its place. `_transparent` on the mesh is the flag AS ATTACHED
|
|
2178
|
+
(setMaterial swaps `mesh.material` before the rebuild, so _detach must
|
|
2179
|
+
not read the new material's flag).
|
|
2180
|
+
- `lathe` takes a CLOSED profile (a cross-section with thickness, or run
|
|
2181
|
+
to the axis at x = 0) - it is a solid of revolution, NOT Three's open
|
|
2182
|
+
polyline shell. An "open" outline must be closed by the author;
|
|
2183
|
+
otherwise the shape is simply wrong, there is no open-profile mode.
|
|
2184
|
+
- `useScene()`/`Group`/`Mesh` throw outside `<Scene>` (default-less
|
|
2185
|
+
context).
|
|
2186
|
+
- Geometry local bounds cache on the Geometry (like its GPU buffers):
|
|
2187
|
+
geometry is immutable after creation. Mutating `vertices` after a mesh
|
|
2188
|
+
used them leaves stale bounds AND a stale GPU buffer - make a new
|
|
2189
|
+
Geometry instead.
|