@solidrt/3d 0.0.57 → 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 +859 -528
- package/examples/README.md +6 -0
- package/examples/wireframe.tsx +107 -0
- package/package.json +2 -2
- package/src/geometry-gpu.ts +45 -13
- package/src/geometry.ts +186 -5
- package/src/gltf.ts +97 -16
- package/src/index.ts +2 -1
- package/src/input.ts +11 -2
- package/src/material.ts +26 -8
- package/src/mesh.ts +5 -6
- package/src/model.ts +65 -33
- package/src/node.ts +2 -1
- package/src/scene.ts +6 -3
- package/src/skeleton.ts +211 -0
package/AGENTS.md
CHANGED
|
@@ -6,249 +6,380 @@ 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
|
-
|
|
245
|
-
|
|
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.
|
|
246
375
|
|
|
247
376
|
## Components
|
|
248
377
|
|
|
378
|
+
### Component props
|
|
379
|
+
|
|
249
380
|
| Component | Props |
|
|
250
381
|
| --- | --- |
|
|
251
|
-
| `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?` (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) |
|
|
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) |
|
|
252
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 |
|
|
253
384
|
| `Group` | `position?`, `rotation?` (Euler radians, XYZ order), `quaternion?` (either, not both), `scale?` (number = uniform), `visible?`, pointer events (below), `ref?(node)` |
|
|
254
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)` |
|
|
@@ -260,6 +391,8 @@ blendMode and pointer events like any element.
|
|
|
260
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)` |
|
|
261
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)` |
|
|
262
393
|
|
|
394
|
+
### Output composition
|
|
395
|
+
|
|
263
396
|
Output composition: without `output`, `Scene` emits a minimal
|
|
264
397
|
`<texture width height>` leaf and nothing else is forwarded - anything
|
|
265
398
|
more goes through `output(texture)`, which renders in place of that leaf:
|
|
@@ -272,6 +405,8 @@ elsewhere. Called once, untracked, inside the scene context. Scene
|
|
|
272
405
|
layout, so render and display size separate - render at 2x and display
|
|
273
406
|
smaller for supersampling.
|
|
274
407
|
|
|
408
|
+
### Fill and fixed sizes
|
|
409
|
+
|
|
275
410
|
Fill (the default): omit `width`/`height` and the built-in leaf is laid
|
|
276
411
|
out at 100% of its parent's box (give it a sized parent, as on the web)
|
|
277
412
|
while the target follows the leaf's on-screen size in DEVICE pixels -
|
|
@@ -302,6 +437,8 @@ everything: the `pointer` feed listens at the scene's root, so
|
|
|
302
437
|
handlers and the feed alike (a detached leaf gives its feed a `layout`,
|
|
303
438
|
it has no layout box to normalize by).
|
|
304
439
|
|
|
440
|
+
### Input
|
|
441
|
+
|
|
305
442
|
Input, the rule (ARCHITECTURE.md): a camera control consumes a
|
|
306
443
|
device-free abstraction and never handles events or reads a device
|
|
307
444
|
itself. The controls expose `axes` (core's createAxes contract - rates
|
|
@@ -334,6 +471,8 @@ sticks move the CAMERA where a drag moves the content, so the presets
|
|
|
334
471
|
bind them to `rotate`/`pan` through `invert()`; `look` is the exception,
|
|
335
472
|
a drag and a stick both turn the eye.
|
|
336
473
|
|
|
474
|
+
### Orbit camera
|
|
475
|
+
|
|
337
476
|
Camera control: `createOrbitCamera(scene, { target?, azimuth?, elevation?,
|
|
338
477
|
distance?, min/maxDistance?, min/maxElevation?, orbitSpeed?, rotateSpeed?,
|
|
339
478
|
zoomSpeed?, panSpeed?, clampTarget?, zoomAnchor?, rotateAnchor? })` -
|
|
@@ -388,6 +527,8 @@ getter and read where it applies, never snapshotted - so clamps, rates
|
|
|
388
527
|
and anchors follow their props without a remount, and a clamp change
|
|
389
528
|
re-clamps the pose at once.
|
|
390
529
|
|
|
530
|
+
### First-person camera
|
|
531
|
+
|
|
391
532
|
First-person control: `createFirstPersonCamera(scene, { position?, yaw?,
|
|
392
533
|
pitch?, min/maxPitch?, moveSpeed?, lookSpeed?, fly?, clampPosition? })` -
|
|
393
534
|
a position plus yaw/pitch (yaw 0 faces -z, positive turns left; pitch
|
|
@@ -426,10 +567,15 @@ its `pointer` feed. The frame loop runs only while `active()`. Every prop
|
|
|
426
567
|
but the initial pose is live: `fly={flying()}` toggles walk/fly on the
|
|
427
568
|
running control (pose carries over, no remount, and `clampPosition` may
|
|
428
569
|
swap with it), `moveSpeed`/`lookSpeed` follow their props, and a pitch
|
|
429
|
-
clamp change re-clamps at once.
|
|
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
|
|
430
574
|
checks/first-person-check.ts pin both controls headless (they import
|
|
431
575
|
`@solidrt/core/input` only).
|
|
432
576
|
|
|
577
|
+
### Overlay projection
|
|
578
|
+
|
|
433
579
|
Overlay projection: `scene.project(point)` maps a world point to scene
|
|
434
580
|
pixels (top-left origin, y down - the output texture's own space; `w` is
|
|
435
581
|
the camera-forward distance in world units under either projection) and
|
|
@@ -444,6 +590,8 @@ unproject each move). `scene.viewProj(out?)` copies the view-projection
|
|
|
444
590
|
matrix for batch work. Never rebuild the camera matrices by hand for a
|
|
445
591
|
HUD.
|
|
446
592
|
|
|
593
|
+
### Picking
|
|
594
|
+
|
|
447
595
|
Picking: `scene.pick(x, y)` is project()'s inverse - the camera ray
|
|
448
596
|
through a scene pixel, returning `Hit[]` (`{ mesh, distance, point }`,
|
|
449
597
|
world units, nearest first; every hit along the ray, not just the front
|
|
@@ -467,13 +615,17 @@ a ray through a knot's hole misses. A large geometry's triangles are
|
|
|
467
615
|
BVH-indexed too - built by the first ray that reaches the shape, log-cost
|
|
468
616
|
after - so raycasting a merged static scene stays cheap (see the batching
|
|
469
617
|
advice). An instanced mesh is box-only (its
|
|
470
|
-
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
|
|
471
621
|
box's twelve triangles, so its hits carry the struck face's `normal` and
|
|
472
622
|
no `face`/`uv` - and a ray from inside it meets the far side, the
|
|
473
623
|
surface contract overlap/sweep share. Both methods
|
|
474
624
|
flush pending writes first (the lookAt/project immediacy contract), and
|
|
475
625
|
both skip invisible meshes.
|
|
476
626
|
|
|
627
|
+
### Collision
|
|
628
|
+
|
|
477
629
|
Collision: `scene.overlap(volume, opts?)` and `scene.sweep(volume,
|
|
478
630
|
motion, opts?)` are the same index's other two questions - what a volume
|
|
479
631
|
touches, and where a moving one first touches: Unity's
|
|
@@ -531,6 +683,8 @@ one overlap per move. Deliberately absent and additive when asked: a
|
|
|
531
683
|
step offset (Unity's stepOffset; Godot has none either) and a cylinder
|
|
532
684
|
volume (Godot only).
|
|
533
685
|
|
|
686
|
+
### Pointer events
|
|
687
|
+
|
|
534
688
|
Pointer events (scene-pointer.ts): the element event model one tree
|
|
535
689
|
deeper, with the SCENE as the root of the walk (under a `<View3d>` leaf:
|
|
536
690
|
the view). `onPointerDown/Move/Up/Enter/Leave/Wheel/Tap` are plain
|
|
@@ -580,6 +734,8 @@ leaf laid out at a different size (the supersampling pattern) uses
|
|
|
580
734
|
checks/dispatch-check.ts pins the walk, claiming, capture, hover, wheel
|
|
581
735
|
and tap rules headless; examples/pick.tsx is the live guard.
|
|
582
736
|
|
|
737
|
+
### Geometry generators
|
|
738
|
+
|
|
583
739
|
Geometry generators take ONE options object, every field optional with
|
|
584
740
|
a default, named as Three names them: `box({ width, height, depth })`
|
|
585
741
|
(1x1x1); `plane({ width, height })`, `circle({ radius, segments })` and
|
|
@@ -611,6 +767,8 @@ buffer itself - so a builder that bakes transforms while writing hands
|
|
|
611
767
|
the baker world-space vertices. `fill` indexes relative to `first`.
|
|
612
768
|
`fillColors(geometry, fill, first?, count?)` is its aColor spelling.
|
|
613
769
|
|
|
770
|
+
### Geometry as data
|
|
771
|
+
|
|
614
772
|
Geometry as data: `transformGeometry(geometry, { position?, rotation?,
|
|
615
773
|
quaternion?, scale? }, label?)` bakes a placement into a copy (the
|
|
616
774
|
setTransform shape: Euler XYZ radians or a quaternion, number = uniform
|
|
@@ -626,13 +784,27 @@ does not tax picking: a merged geometry's raycast narrowphase runs
|
|
|
626
784
|
through its triangle BVH (built on the first ray), so merge for draw
|
|
627
785
|
count without giving up ground queries. Both are
|
|
628
786
|
pure array math (Three's `applyMatrix4` + `mergeGeometries`), no GPU
|
|
629
|
-
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)`
|
|
630
800
|
returns the cached local AABB `[minX, minY, minZ, maxX, maxY, maxZ]`, and
|
|
631
801
|
`rayBoxDistance(ox, oy, oz, dx, dy, dz, minX, .., maxZ)` is the picking
|
|
632
802
|
slab test (entry t >= 0 in units of the direction's length, 0 from
|
|
633
803
|
inside, -1 for a miss) - for ray-testing boxes you keep yourself
|
|
634
804
|
(triggers, collision volumes) without meshes you do not want to draw.
|
|
635
805
|
|
|
806
|
+
### Profile kit
|
|
807
|
+
|
|
636
808
|
Profile kit (2D outlines to solids, real texture UVs): a `Profile` is a
|
|
637
809
|
closed XY polygon, bare `[x, y]` points crease, `{ p, smooth }` points
|
|
638
810
|
share an averaged normal - `fillet(points, radius, segs?)` and
|
|
@@ -658,87 +830,120 @@ like circle); `triangulate(points)` is the ear-clipping core (fan
|
|
|
658
830
|
fallback, never drops a cap), exported for custom flat work. These pick
|
|
659
831
|
uint16/uint32 indices by vertex count automatically.
|
|
660
832
|
|
|
661
|
-
Materials
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
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
|
|
737
927
|
|
|
738
928
|
Instancing - one draw entry covering a population, in two forms. The
|
|
739
929
|
axis between them is WHERE MOTION IS COMPUTED, the same split as
|
|
740
930
|
@solidrt/2d's sprite and record layers.
|
|
741
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
|
+
|
|
742
947
|
`createInstancedMesh(geometry, material, { capacity?, bounds?, label? })`
|
|
743
948
|
draws the geometry once per instance NODE: `addInstance(mesh,
|
|
744
949
|
transform?, parent?)` returns a scene node (kind "instance") placed
|
|
@@ -787,15 +992,17 @@ MultiMesh is the record form. The components: `<InstancedMesh capacity>`
|
|
|
787
992
|
with `<Instance position style transition onPointerDown>` children
|
|
788
993
|
(`<Group>` children are squads, an `<Instance>` is a parent too);
|
|
789
994
|
`examples/fleet.tsx` is a thousand of them springing between formations
|
|
790
|
-
with tap-to-tint
|
|
995
|
+
with tap-to-tint.
|
|
791
996
|
Mount cost is the one difference between the faces: an `<Instance>` row
|
|
792
997
|
costs about eight times an `addInstance` call (the component machinery -
|
|
793
998
|
a Solid row, two effects, the style effect; a node component's context
|
|
794
|
-
provider is built only when it has children, the biggest single
|
|
795
|
-
|
|
999
|
+
provider is built only when it has children, the biggest single
|
|
1000
|
+
saving), so a few thousand
|
|
796
1001
|
mount in tens of milliseconds either way; populations spawned per frame,
|
|
797
1002
|
or in five figures, belong to the function face.
|
|
798
1003
|
|
|
1004
|
+
#### createRecordMesh
|
|
1005
|
+
|
|
799
1006
|
`createRecordMesh(geometry, material, records, count?, { bounds?,
|
|
800
1007
|
label? })` is the raw form: `records` is the interleaved per-instance
|
|
801
1008
|
data (stride = the material's slot-0 instanceAttributes summed, a
|
|
@@ -814,13 +1021,16 @@ then it picks and transparent-sorts conservatively as one box. The
|
|
|
814
1021
|
escape hatch for motion only JS can compute at scale (a particle sim, a
|
|
815
1022
|
crowd stepped in a worker).
|
|
816
1023
|
|
|
1024
|
+
#### Common to both
|
|
1025
|
+
|
|
817
1026
|
Everything mesh works on both: setTransform moves the whole population
|
|
818
1027
|
through one uModel, setVisible zeroes the drawn count and restores it on
|
|
819
1028
|
unhide, renderOrder/params/geometry/material swaps apply, and
|
|
820
1029
|
`disposeInstances(mesh)` detaches and frees the record buffers - the one
|
|
821
1030
|
explicit free, geometry-buffer rule. `examples/fleet.tsx` (instances,
|
|
822
|
-
components)
|
|
823
|
-
|
|
1031
|
+
components) and `examples/instanced.tsx` (records) are the live proofs.
|
|
1032
|
+
|
|
1033
|
+
### Background
|
|
824
1034
|
|
|
825
1035
|
Background: `scene.setBackground(source | null)`, the `background` option
|
|
826
1036
|
on createScene, and the reactive `Scene` prop. Drawn as the FIRST entry
|
|
@@ -856,6 +1066,8 @@ no separate resize plumbing. Two forms:
|
|
|
856
1066
|
the same way, so a skybox is one flat color there. A 2D texture id
|
|
857
1067
|
throws at the samplerCube binding. `examples/skybox.tsx`.
|
|
858
1068
|
|
|
1069
|
+
### Cube-map convention
|
|
1070
|
+
|
|
859
1071
|
The cube-map convention: a cube map holds what a GL lookup returns -
|
|
860
1072
|
each face as seen from OUTSIDE the cube, GL's own (RenderMan) frame -
|
|
861
1073
|
and every library lookup is a plain `texture(cube, dir)` in world space.
|
|
@@ -871,6 +1083,8 @@ number, so the object form keeps it unambiguous). Translucent grounds
|
|
|
871
1083
|
over a background still need blend factors (a separate shader texture
|
|
872
1084
|
underneath until then).
|
|
873
1085
|
|
|
1086
|
+
### Environment
|
|
1087
|
+
|
|
874
1088
|
Environment: `scene.setEnvironment({ cube, intensity?, rotation? } |
|
|
875
1089
|
null)`, the `environment` option on createScene and the reactive `Scene`
|
|
876
1090
|
prop - the cube map every `standard` material is lit by (always: the
|
|
@@ -920,6 +1134,8 @@ environment set contributes nothing (uEnvOn 0), not a black reflection.
|
|
|
920
1134
|
`examples/skybox.tsx` (a JS-baked sky), `examples/environment.tsx` (a
|
|
921
1135
|
baked HDRI lighting the scene alone).
|
|
922
1136
|
|
|
1137
|
+
### Reflection probes
|
|
1138
|
+
|
|
923
1139
|
Reflection probes: `scene.createReflectionProbe({ position, size?,
|
|
924
1140
|
near?, far?, layers?, clearColor?, label? })` renders the scene into a
|
|
925
1141
|
cube map from a point - Three's CubeCamera, Unity's and Godot's
|
|
@@ -965,6 +1181,8 @@ x-mirrored projection (`Camera.mirror`), because a GL cube face is seen
|
|
|
965
1181
|
from outside; the engine inverts the front-face rule on cube target
|
|
966
1182
|
passes so cull modes keep their meaning. `examples/probe.tsx`.
|
|
967
1183
|
|
|
1184
|
+
### Baked sky
|
|
1185
|
+
|
|
968
1186
|
Baked sky: `scene.bakeBackground(size?)` is a reflection probe at the
|
|
969
1187
|
origin that sees no mesh (layer mask 0): the scene's background - the
|
|
970
1188
|
GLSL sky or the skybox - alone on its six faces, LINEAR (a sky ending in
|
|
@@ -984,6 +1202,8 @@ that HDR is for. A sky that reads
|
|
|
984
1202
|
`uCamPos` bakes from the origin; scene params (an app clock) are seen as
|
|
985
1203
|
of the call. `examples/sky-lit.tsx`.
|
|
986
1204
|
|
|
1205
|
+
### Panoramas
|
|
1206
|
+
|
|
987
1207
|
Panoramas: `equirectToCube(map, size, opts?)` converts an uploaded
|
|
988
1208
|
equirectangular 2D texture (createImage, createTexture) into a cube
|
|
989
1209
|
TextureId on the GPU, synchronously (six face passes straight into a
|
|
@@ -999,6 +1219,8 @@ the bake tool above, whose CPU pipeline (`src/environment-bake.ts`:
|
|
|
999
1219
|
decodeHdr, panoramaToCube, prefilterCube, the .srte encode/decode) is
|
|
1000
1220
|
pure TypeScript and bun-tested - there is no runtime .hdr decoder.
|
|
1001
1221
|
|
|
1222
|
+
### Fog
|
|
1223
|
+
|
|
1002
1224
|
Fog: `scene.setFog(fog | null)`, the `fog` option on createScene and
|
|
1003
1225
|
the reactive `Scene` prop, in Three's two shapes: linear `{ color, near,
|
|
1004
1226
|
far }` (`Fog`; fades from near to far, fully fogged past far) or exp2
|
|
@@ -1030,6 +1252,8 @@ horizon, and put `far` at or inside the camera's far plane to hide the
|
|
|
1030
1252
|
clip. `examples/fog.tsx` cycles the forms over a valley;
|
|
1031
1253
|
`examples/cascades.tsx` fogs its field to the sky.
|
|
1032
1254
|
|
|
1255
|
+
### Color
|
|
1256
|
+
|
|
1033
1257
|
Color: the scene shades in LINEAR light and outputs sRGB, like Three
|
|
1034
1258
|
(ColorManagement), Godot and Unity's linear space - no gamma mode. Every
|
|
1035
1259
|
`[r, g, b]` color option is sRGB, what a color picker shows: material
|
|
@@ -1055,6 +1279,8 @@ raw sampling). What changed for a scene tuned before this: terminators
|
|
|
1055
1279
|
soften, mid-tones brighten, highlights widen - drop ambient rather than
|
|
1056
1280
|
lights. `emissiveIntensity` scales the emissive in linear light.
|
|
1057
1281
|
|
|
1282
|
+
### Lighting GLSL
|
|
1283
|
+
|
|
1058
1284
|
Lighting GLSL (`@solidrt/3d/glsl`): exported string constants composed
|
|
1059
1285
|
into shaderMaterial sources with plain template literals - `LIT_VERTEX`
|
|
1060
1286
|
(the standard vertex stage: clip position plus vWorldPos/vNormal/vUv
|
|
@@ -1090,6 +1316,8 @@ samplers. Lights, colors and exponents are arguments, so
|
|
|
1090
1316
|
nothing is pinned but the function names; `lit` is composed from these
|
|
1091
1317
|
same constants - customizing never means leaving the system.
|
|
1092
1318
|
|
|
1319
|
+
### Custom looks: three tiers
|
|
1320
|
+
|
|
1093
1321
|
A custom look is a citizen of the scene - lit by its lights, shadowed,
|
|
1094
1322
|
fogged, exposed and tone mapped like the stock materials - at one of
|
|
1095
1323
|
three tiers, top first:
|
|
@@ -1103,7 +1331,12 @@ three tiers, top first:
|
|
|
1103
1331
|
mesh keeps the stock shading whole (`examples/instanced.tsx`: the
|
|
1104
1332
|
per-instance tint rides vColor). Instance it with the per-entry
|
|
1105
1333
|
uniforms the source declares (uColor, uSpecular/uShininess or
|
|
1106
|
-
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).
|
|
1107
1340
|
2. A SURFACE FUNCTION inside the stock fragment. `litFragment({ surface,
|
|
1108
1341
|
prelude })`: `prelude` is file scope (uniforms and helpers; a uniform
|
|
1109
1342
|
it declares is an ordinary `instance()` param), `surface` declares
|
|
@@ -1117,7 +1350,12 @@ three tiers, top first:
|
|
|
1117
1350
|
generated program is; colors are linear light, premultiplied
|
|
1118
1351
|
throughout, `Surface.base` included. The material describes the
|
|
1119
1352
|
surface, the package shades it (Godot's fragment(), Filament's
|
|
1120
|
-
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.
|
|
1121
1359
|
3. A FRAGMENT OF YOUR OWN over the scene set. Compose `SCENE` (or
|
|
1122
1360
|
`sceneSource({ lights, receiveShadow, env, fog })`, each flag leaving a
|
|
1123
1361
|
declaration out): it declares uCamPos, uHemiSky/uHemiGround, the
|
|
@@ -1131,11 +1369,9 @@ three tiers, top first:
|
|
|
1131
1369
|
loops `sceneLight(i, position, normal)` to `uLightCount` instead of a
|
|
1132
1370
|
shade function: light i's direction and its color already attenuated,
|
|
1133
1371
|
cone-faded and shadowed (zero when it cannot reach). The stock
|
|
1134
|
-
materials are built from this same set, so the tiers cannot drift
|
|
1135
|
-
`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.
|
|
1136
1373
|
The demo `the-third-dimension.tsx` has tier 2 (the ground) and tier 3
|
|
1137
|
-
(the knot's rim term)
|
|
1138
|
-
sceneLight loop beside a lit() floor.
|
|
1374
|
+
(the knot's rim term).
|
|
1139
1375
|
`standardFragment(options)` is the same for `standard`: lit's options
|
|
1140
1376
|
minus `specularMap`/`env` (the environment is always composed) plus
|
|
1141
1377
|
`metalnessMap`/`roughnessMap`, on the same `litVertex(options)`, with
|
|
@@ -1153,6 +1389,8 @@ a shadow program that never reads `n` (no triplanar, no surface function
|
|
|
1153
1389
|
using it) reflects `uNormal` inactive - set `normalMatrix: false` on that
|
|
1154
1390
|
instance or every caster move warns about the skipped write.
|
|
1155
1391
|
|
|
1392
|
+
### Lights
|
|
1393
|
+
|
|
1156
1394
|
Lights and `lit`: lights are graph NODES, like Three. `createDirectionalLight({
|
|
1157
1395
|
direction?, color?, intensity? })` / `<DirectionalLight>` is parallel light
|
|
1158
1396
|
travelling along `direction` in the node's LOCAL space (default `[0, -1,
|
|
@@ -1198,6 +1436,8 @@ scenes. Everything starts black: a lit scene with no light shows
|
|
|
1198
1436
|
nothing, on purpose, like Three. `examples/lamps.tsx` is the spot/point
|
|
1199
1437
|
shape (soft vs hard cone, casting spots, an orbiting bulb).
|
|
1200
1438
|
|
|
1439
|
+
### lit
|
|
1440
|
+
|
|
1201
1441
|
`lit(opts)` is the standard look beside `unlit`: hemisphere ambient plus
|
|
1202
1442
|
the directional list, Lambert diffuse, Blinn-Phong highlight when
|
|
1203
1443
|
`specular` (0..1 strength) is set with `shininess` (default 30), a
|
|
@@ -1223,6 +1463,8 @@ or more beside it, or trilinear smears the far half into the mip its long
|
|
|
1223
1463
|
axis picked (`createModel` uploads its images with both; the device clamps
|
|
1224
1464
|
the level, `limits.maxAnisotropy` reports it).
|
|
1225
1465
|
|
|
1466
|
+
#### Surface maps
|
|
1467
|
+
|
|
1226
1468
|
The surface maps, each an option beside `map` and sampled at its uv:
|
|
1227
1469
|
|
|
1228
1470
|
- `normalMap` (+ `normalScale`, ONE float as in Unity/Godot - Three's
|
|
@@ -1257,6 +1499,8 @@ The surface maps, each an option beside `map` and sampled at its uv:
|
|
|
1257
1499
|
|
|
1258
1500
|
`examples/materials.tsx` shows all five.
|
|
1259
1501
|
|
|
1502
|
+
### standard
|
|
1503
|
+
|
|
1260
1504
|
`standard(opts)` is the metalness/roughness material, the look authored
|
|
1261
1505
|
assets expect (Three's MeshStandardMaterial, Godot's StandardMaterial3D,
|
|
1262
1506
|
Unity's Standard): every lit option but the Blinn-Phong knobs
|
|
@@ -1295,70 +1539,82 @@ classes do not warn about an inactive uniform.
|
|
|
1295
1539
|
|
|
1296
1540
|
## Models
|
|
1297
1541
|
|
|
1542
|
+
### Three layers
|
|
1543
|
+
|
|
1298
1544
|
Authored models come in as glTF 2.0 (.gltf with its .bin and image files
|
|
1299
1545
|
next to it, or single-file .glb) and become a Group carrying the file's
|
|
1300
1546
|
node hierarchy, Three's `gltf.scene`. Three layers, use the lowest that
|
|
1301
1547
|
fits:
|
|
1302
1548
|
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
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
|
|
1362
1618
|
|
|
1363
1619
|
Loading is async everywhere but the binary import: loadGltf/loadModel
|
|
1364
1620
|
return promises, and the async value must be read the way Solid 2 async
|
|
@@ -1378,6 +1634,8 @@ a source glTF must be parsed at runtime, do the parse in an isolate
|
|
|
1378
1634
|
(parseGltf's result is plain data and copies across) and keep
|
|
1379
1635
|
createModel on main.
|
|
1380
1636
|
|
|
1637
|
+
### Placement and sockets
|
|
1638
|
+
|
|
1381
1639
|
Placement: pieces of one authored set (a body and its fitted cosmetics)
|
|
1382
1640
|
export in one world space, so composing them is `add(group, model)` per
|
|
1383
1641
|
piece and nothing else - no placement math. SOCKETED items (a weapon in
|
|
@@ -1385,8 +1643,8 @@ a hand) are different: they bind to a joint, so they only land once a
|
|
|
1385
1643
|
skeleton exists (a rig-less export cannot place them at all). The joint
|
|
1386
1644
|
is an ordinary Group in `model.nodes` - find it by name and `add()` the
|
|
1387
1645
|
item under it; it then follows the pose, mixer-driven or hand-posed,
|
|
1388
|
-
like any child transform. Two authoring cases
|
|
1389
|
-
|
|
1646
|
+
like any child transform. Two authoring cases: an item authored about
|
|
1647
|
+
its own socket
|
|
1390
1648
|
origin needs the plain `add()` and nothing more; one authored in the
|
|
1391
1649
|
RIG'S model space needs a socket Group between joint and item carrying
|
|
1392
1650
|
the joint's rest-pose inverse (at rest the item then sits exactly where
|
|
@@ -1395,6 +1653,34 @@ transform on top of the authored placement. Skinned PARTS are the one
|
|
|
1395
1653
|
thing that never needs this: they hang off the model root and the
|
|
1396
1654
|
palette places them.
|
|
1397
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
|
+
|
|
1398
1684
|
Applied: `doubleSided` (the default material draws it with `cull:
|
|
1399
1685
|
"none"`), alphaMode MASK (`alphaTest: alphaCutoff`), `normalTexture`
|
|
1400
1686
|
(+ scale; the derivative frame needs no tangents), `emissiveFactor` x
|
|
@@ -1410,6 +1696,9 @@ by lit()/standard() option name (`maps.map`/`maps.normalMap`/
|
|
|
1410
1696
|
A `.srtm` baked before the material records carried the PBR fields
|
|
1411
1697
|
(file version 3) is rejected by loadModel - re-bake with `srt tool
|
|
1412
1698
|
3d/model`.
|
|
1699
|
+
|
|
1700
|
+
### Animation
|
|
1701
|
+
|
|
1413
1702
|
Animation: `createMixer(model)` plays `model.clips` by name -
|
|
1414
1703
|
`mixer.play(name, { loop?, speed?, fadeMs? })` (fadeMs crossfades: the
|
|
1415
1704
|
named clip fades in, everything else fades out - Unity's CrossFade,
|
|
@@ -1437,6 +1726,9 @@ so a TRANSPARENT mesh parented under a player-animated joint does not
|
|
|
1437
1726
|
re-trigger the back-to-front re-sort while it animates (opaque meshes,
|
|
1438
1727
|
palettes and picking are unaffected) - nudge the scene with any
|
|
1439
1728
|
setTransform if it shows, until the core-side transparent sort lands.
|
|
1729
|
+
|
|
1730
|
+
### Root motion
|
|
1731
|
+
|
|
1440
1732
|
Root motion: `play(name, { inPlace? })` strips a clip's root travel
|
|
1441
1733
|
(Unity's applyRootMotion off, Godot's root_motion_track): the root's
|
|
1442
1734
|
x/z hold at the clip's first key and its height rebases onto the root's
|
|
@@ -1473,7 +1765,11 @@ horizontal travel (Unity's bake-into-pose Y - for a controller that
|
|
|
1473
1765
|
owns gravity). `inPlace` is ignored while rootMotion is set. Yaw is
|
|
1474
1766
|
the swing-twist about up (exact under any lean); a cubic rotation
|
|
1475
1767
|
track is linearized at 60 keys/s before its yaw is held. Verified on
|
|
1476
|
-
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
|
|
1477
1773
|
(model-local jointWorld x inverseBind, sized to the RIG - an rgba32f
|
|
1478
1774
|
float texture, 4 texels wide, one row per joint, sampled in the vertex
|
|
1479
1775
|
stage via texelFetch, so there is no joint cap) is composed by the
|
|
@@ -1483,6 +1779,8 @@ computed-once texture. `sampleChannel` (pure, from the root) stays the
|
|
|
1483
1779
|
JS sampling core for checks and custom drivers;
|
|
1484
1780
|
okf/done/animation-core.md records the design.
|
|
1485
1781
|
|
|
1782
|
+
### Not in the subset
|
|
1783
|
+
|
|
1486
1784
|
Not in the subset, dropped: vertex colors, tangents and further UV sets;
|
|
1487
1785
|
morph targets (the "weights" channel path); samplers are ignored (every
|
|
1488
1786
|
texture repeats); additive blending draws as base color. The follow-ups
|
|
@@ -1492,6 +1790,8 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
|
|
|
1492
1790
|
|
|
1493
1791
|
## Traps
|
|
1494
1792
|
|
|
1793
|
+
### Models and skinning
|
|
1794
|
+
|
|
1495
1795
|
- A model's vertices are LOCAL to their node; the file's placement lives
|
|
1496
1796
|
in the node-table TRS, composed by the scene like any Group chain. So
|
|
1497
1797
|
a part's `geometry` alone is at the origin, `model.bounds` (rest-pose
|
|
@@ -1499,6 +1799,9 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
|
|
|
1499
1799
|
subtree. The winding flip for a mirroring node chain is baked into the
|
|
1500
1800
|
index order from the REST pose - re-scaling a node across zero at
|
|
1501
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.
|
|
1502
1805
|
- Skinning is a VERTEX-STAGE effect: everything that runs off the
|
|
1503
1806
|
retained tree sees the bind pose. A skinned mesh picks by its
|
|
1504
1807
|
bind-pose triangles at the model root and its transparent sort key is
|
|
@@ -1506,6 +1809,9 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
|
|
|
1506
1809
|
MODEL moves both. Shadows are the exception: the shadow variants
|
|
1507
1810
|
(depth and cutout) skin by the same uBones palette, so a caster casts
|
|
1508
1811
|
its pose.
|
|
1812
|
+
|
|
1813
|
+
### Materials and color
|
|
1814
|
+
|
|
1509
1815
|
- A `standard` metal in a scene with no environment renders near black:
|
|
1510
1816
|
its diffuse is zero and the black placeholder cube is all there is to
|
|
1511
1817
|
reflect (Three and Godot render the same; Unity falls back to an
|
|
@@ -1513,74 +1819,9 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
|
|
|
1513
1819
|
so an untextured asset is all metal, and createModel's default is
|
|
1514
1820
|
`standard`: give a model scene an `environment` (loadEnvironment's
|
|
1515
1821
|
baked .srte, or the skybox's cube), or pass `lit` as the material.
|
|
1516
|
-
- A reflection probe is a mirrored render (x-flipped projection, winding
|
|
1517
|
-
inverted by the engine): anything built from screen-space derivatives
|
|
1518
|
-
flips with it, so a normal-mapped surface shows its bumps INVERTED in
|
|
1519
|
-
a probe's reflection. Known and shared with every engine's mirrored
|
|
1520
|
-
views; a `uMirror` sign on the derivative frame is the fix when it
|
|
1521
|
-
matters.
|
|
1522
|
-
- A generated cube chain (`mipmap: true` from six faces) is a box
|
|
1523
|
-
filter, not the GGX convolution the roughness-to-level rule assumes:
|
|
1524
|
-
rough reflections read too sharp and the diffuse `envIrradiance` is a
|
|
1525
|
-
4x4 average. Bake with `srt tool 3d/environment` for anything
|
|
1526
|
-
photographed; the JS sky gradients in the examples get away with it.
|
|
1527
1822
|
- Light intensities are the same numbers for `lit` and `standard`: 1
|
|
1528
1823
|
lights a white matte surface to 1 face-on. A Three scene's intensities
|
|
1529
1824
|
are a factor pi larger for the same look; divide when porting.
|
|
1530
|
-
- The y-down clip flip is baked into `perspective()`; scene code and
|
|
1531
|
-
geometry are plain y-up right-handed, and CCW-outward winding culls
|
|
1532
|
-
correctly with `cull: "back"`. Do NOT negate y anywhere else, and do not
|
|
1533
|
-
"fix" the negated row of `perspective()` - both would mirror the winding
|
|
1534
|
-
and show mesh interiors.
|
|
1535
|
-
- `visible: false` keeps the entry, drawn with `instanceCount: 0` (a
|
|
1536
|
-
cheap off switch); unhiding writes 1, or the mesh's own record count
|
|
1537
|
-
when it is instanced - never a bare 1 into an instanced entry. Hidden
|
|
1538
|
-
meshes skip uModel writes; the fresh matrix is
|
|
1539
|
-
written on unhide. A freshly attached entry starts off the same way and
|
|
1540
|
-
the core's flush turns it on when it writes uModel - never add one
|
|
1541
|
-
live: it has no world matrix yet, and drawn before the sync microtask it
|
|
1542
|
-
flashes at the world origin for a frame.
|
|
1543
|
-
- Instancing pairs strictly at add(), like layout: an instanced material
|
|
1544
|
-
needs a createInstancedMesh or createRecordMesh mesh and vice versa,
|
|
1545
|
-
and the record strides must match the material's attributes per slot
|
|
1546
|
-
(the 16 floats of INSTANCE_MATRIX_ATTRIBUTES in slot 0 on an instanced
|
|
1547
|
-
mesh, its style stride in slot 1; a record mesh is slot 0 only) - each
|
|
1548
|
-
mismatch throws there, at creation for the mesh's own material and at
|
|
1549
|
-
add() for a swapped one. The instance buffers are MESH-owned (unlike
|
|
1550
|
-
shared geometry buffers): `disposeInstances` is their one free, and
|
|
1551
|
-
the mesh cannot be re-added afterwards. Capacity grows by REPLACEMENT,
|
|
1552
|
-
never resize: an addInstance or setRecords past capacity doubles into
|
|
1553
|
-
new buffers and swaps them in (an instanced mesh's live records move
|
|
1554
|
-
in one core `retargetRecords`, its style mirror republishes) -
|
|
1555
|
-
amortized like a dynamic array, same policy as @solidrt/2d; size
|
|
1556
|
-
`capacity` or the initial records to skip the copies. An instanced
|
|
1557
|
-
mesh's style stride comes from its material AT CREATION: a later
|
|
1558
|
-
setMaterial to a class with another slot-1 layout throws at the
|
|
1559
|
-
rebuild.
|
|
1560
|
-
- Style writes are mirrored, not immediate: `setInstanceStyle` lands in
|
|
1561
|
-
`MeshInstances.style.data` and the scene's sync publishes the dirty
|
|
1562
|
-
range - so a write on a mesh outside any scene shows once it is added
|
|
1563
|
-
(the attach republishes the whole mirror), and reading the GPU buffer
|
|
1564
|
-
back mid-frame can lag the mirror by one sync.
|
|
1565
|
-
- Instanced casters: `castShadow` on a populated mesh needs a depth pass
|
|
1566
|
-
with the instance placement in it - the stock materials' `instanced`
|
|
1567
|
-
carries one, a custom class needs `shadowVertex` (see shadows below);
|
|
1568
|
-
a class without one is skipped by shadow views, silently.
|
|
1569
|
-
- A record mesh without explicit `bounds` has no BVH leaf: it never
|
|
1570
|
-
picks, pointer events never target it, and its transparent sort key
|
|
1571
|
-
falls back to the node's world position. That is deliberate - records
|
|
1572
|
-
are opaque to the library, so any inferred box would be a guess. Supply
|
|
1573
|
-
`bounds` for anything pickable or transparent. An instanced mesh picks
|
|
1574
|
-
per instance regardless and culls by its instances' union; its
|
|
1575
|
-
`bounds` replace that union and give the transparent sort a center.
|
|
1576
|
-
- An instance node's own fields hold its LOCAL pose (relative to its
|
|
1577
|
-
parent, as for any node); the record the core writes is its pose
|
|
1578
|
-
relative to the MESH, composed through any group between them. A
|
|
1579
|
-
removed instance's children stay parented to the dead node: remove()
|
|
1580
|
-
one to re-use it. `disposeInstances` on an instanced mesh flushes the
|
|
1581
|
-
core before freeing the buffer, so the destroyed instances' hiding
|
|
1582
|
-
writes land in a live buffer; keep that order if you ever free a
|
|
1583
|
-
record buffer by hand.
|
|
1584
1825
|
- Transparency is an EXPLICIT material flag, Three's rule: `unlit({ color:
|
|
1585
1826
|
[r, g, b, 0.5] })` still draws opaque, and opaque means it: the standard
|
|
1586
1827
|
classes write alpha 1 when not `transparent` (the scene target is
|
|
@@ -1607,6 +1848,54 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
|
|
|
1607
1848
|
translucents can sort wrong (center distance, not per-pixel) - that is the
|
|
1608
1849
|
engine contract, no OIT. A `shaderMaterial({ transparent: true })`
|
|
1609
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.
|
|
1610
1899
|
- Rotation is stored as a QUATERNION (`node.quaternion`, `[x, y, z, w]`,
|
|
1611
1900
|
always unit). There is exactly one rotation field: no `node.rotation`
|
|
1612
1901
|
shadowing it, because a second field is a second thing to go stale (an
|
|
@@ -1678,14 +1967,70 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
|
|
|
1678
1967
|
- Transforms have ONE write path: `setTransform`/`lookAt`/`setVisible` (or
|
|
1679
1968
|
the props that call them). Mutating `node.position` directly does not
|
|
1680
1969
|
sync. Components have no `lookAt` prop - aim through a `ref`.
|
|
1681
|
-
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
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
|
+
|
|
1689
2034
|
- A CASTING light's position matters (nothing else about a directional
|
|
1690
2035
|
light's position does): the shadow camera is placed AT the light node's
|
|
1691
2036
|
world position, Three's rule, so a `castShadow` sun at the origin
|
|
@@ -1715,6 +2060,17 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
|
|
|
1715
2060
|
is a full extra pass over the casters plus a sampler unit on every
|
|
1716
2061
|
receiving program (MAX_LIGHTS of those are always bound, placeholders
|
|
1717
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.
|
|
1718
2074
|
- A mesh's entries are mirrored into every view at attach and dropped at
|
|
1719
2075
|
detach; `setGeometry`/`setMaterial` rebuild them everywhere. An
|
|
1720
2076
|
`overrideMaterial` is validated against every mesh's layout (at
|
|
@@ -1735,31 +2091,9 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
|
|
|
1735
2091
|
differs per mesh. (`scene.texture` IS the draw target id, so
|
|
1736
2092
|
`setTargetParams(scene.texture, ...)` is the same write - setParams is
|
|
1737
2093
|
the sanctioned spelling.)
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
`position`/`quaternion`/`scale` are the live arrays: read them, do not
|
|
1742
|
-
hand them out and do not mutate them (that write does not sync).
|
|
1743
|
-
- `setTransform` early-outs on an unchanged value (rotation compared AFTER
|
|
1744
|
-
euler conversion), so driving every node unconditionally from `onFrame`
|
|
1745
|
-
costs only the compare for nodes that did not move. Compares are exact,
|
|
1746
|
-
like `setVisible`.
|
|
1747
|
-
- Per-generator conventions - orientation, UV mapping, which axis a solid
|
|
1748
|
-
stands on, what a cap looks like - live on each generator's doc comment,
|
|
1749
|
-
not here. They are consistent (`plane`/`circle`/`ring` face +z, `torus`
|
|
1750
|
-
lies flat with the hole on y, discs and cylinder caps get a PLANAR disc
|
|
1751
|
-
map inscribed in the unit square) but the doc comment is the source.
|
|
1752
|
-
- Entry rebuild order: `setGeometry`/`setMaterial` re-add the entry at the
|
|
1753
|
-
list END and dirty the order, so the next sync() re-sorts and the mesh
|
|
1754
|
-
keeps its place. `_transparent` on the mesh is the flag AS ATTACHED
|
|
1755
|
-
(setMaterial swaps `mesh.material` before the rebuild, so _detach must
|
|
1756
|
-
not read the new material's flag).
|
|
1757
|
-
- `lathe` takes a CLOSED profile (a cross-section with thickness, or run
|
|
1758
|
-
to the axis at x = 0) - it is a solid of revolution, NOT Three's open
|
|
1759
|
-
polyline shell. An "open" outline must be closed by the author;
|
|
1760
|
-
otherwise the shape is simply wrong, there is no open-profile mode.
|
|
1761
|
-
- `useScene()`/`Group`/`Mesh` throw outside `<Scene>` (default-less
|
|
1762
|
-
context).
|
|
2094
|
+
|
|
2095
|
+
### Shader materials
|
|
2096
|
+
|
|
1763
2097
|
- A `shaderMaterial` INSTANCE is the pipeline handle: identical sources
|
|
1764
2098
|
compile twice - no dedupe by source value (deliberate; hidden
|
|
1765
2099
|
content-keyed caches are the anti-pattern the GPU layer avoids). Create
|
|
@@ -1786,10 +2120,24 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
|
|
|
1786
2120
|
vertex source - a comment counts - selects the "colored" layout, and
|
|
1787
2121
|
the material then rejects standard geometry at add(). Do not mention
|
|
1788
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
|
+
|
|
1789
2136
|
- Picking is triangle-accurate for ordinary meshes (`point` is a surface
|
|
1790
2137
|
point, hits carry `face`/`uv`/`normal`) but box-only for instanced
|
|
1791
|
-
meshes: there `point` is where the ray
|
|
1792
|
-
|
|
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
|
|
1793
2141
|
instanced hit as a surface hit. Both tiers run in the spatial core (Rust); never
|
|
1794
2142
|
add a per-triangle path in JS - rays at mesh scale are
|
|
1795
2143
|
interpreter-hostile, and the core already does it.
|
|
@@ -1801,50 +2149,10 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
|
|
|
1801
2149
|
leaf under a design size. Only a leaf whose layout size deliberately
|
|
1802
2150
|
differs from the target (supersampling) needs `handlersFor`, fed the
|
|
1803
2151
|
layout size the app itself set.
|
|
1804
|
-
- A custom fragment that ends in `fragColor = vec4(...)` bypasses the
|
|
1805
|
-
scene: no fog, no exposure or tone mapping, no encode - and a
|
|
1806
|
-
hand-rolled loop over `uLightDir` renders a spot light as a
|
|
1807
|
-
directional one (a lit rectangle on the floor, no cone). What you
|
|
1808
|
-
declare is what runs, and the engine injects nothing, so pick a tier
|
|
1809
|
-
(the three after the GLSL exports under "The model"): a stock fragment
|
|
1810
|
-
on your vertex stage,
|
|
1811
|
-
a `surface` function, or `SCENE` with `shadeBlinn`/`shadePbr` or
|
|
1812
|
-
`sceneLight` and `sceneOutput` at the end. Composing `FOG` and the
|
|
1813
|
-
`SHADOW_*` trio by hand still works and is no longer the shape.
|
|
1814
2152
|
- Hover (enter/leave) reacts to pointer MOTION only: a mesh animating
|
|
1815
2153
|
under a still pointer fires nothing until the next move - the same
|
|
1816
2154
|
limit the element hit test has (hit-test-per-frame is an open platform
|
|
1817
2155
|
item). Do not poll pick() per frame to fake it.
|
|
1818
|
-
- Geometry local bounds cache on the Geometry (like its GPU buffers):
|
|
1819
|
-
geometry is immutable after creation. Mutating `vertices` after a mesh
|
|
1820
|
-
used them leaves stale bounds AND a stale GPU buffer - make a new
|
|
1821
|
-
Geometry instead.
|
|
1822
|
-
- The background covers the whole target with depth off, drawn first: it
|
|
1823
|
-
REPLACES the clearColor visually (the clear still runs; you just never
|
|
1824
|
-
see it), and a `transparent: true` mesh blends over it in-pass since the
|
|
1825
|
-
background is always entry zero.
|
|
1826
|
-
- A color map created as plain rgba8 renders WASHED OUT: the fragment
|
|
1827
|
-
reads its encoded bytes as linear light and encodes them again. Create
|
|
1828
|
-
color images (base color, emissive, a sky's faces or panorama) with
|
|
1829
|
-
`format: "rgba8-srgb"`; keep data maps rgba8. A rendered rgba8 texture
|
|
1830
|
-
(a scene view, a shader target, a UI capture) holds encoded pixels and
|
|
1831
|
-
cannot be tagged, so as a `map` it needs `srgbToLinear` from SRGB in a
|
|
1832
|
-
custom fragment (no material option yet); a draw target you create
|
|
1833
|
-
yourself can be `format: "rgba8-srgb"` and then decodes on sample (it
|
|
1834
|
-
is sampler-only: no display, readback or copy).
|
|
1835
|
-
- Reading the scene texture back (a probe's readTexture, a snapshot)
|
|
1836
|
-
gives ENCODED pixels: an expected linear value v shows as
|
|
1837
|
-
`linearToSrgb(v) * 255` - intensity 0.5 reads 188, not 128.
|
|
1838
|
-
- The background pipeline/program are SCENE-OWNED (unlike shared
|
|
1839
|
-
material pipelines): setBackground(null), replacement, and dispose()
|
|
1840
|
-
destroy them. Do not hand the background's pipeline to anything else.
|
|
1841
|
-
A skybox is the same slot with the library's fragment; only a
|
|
1842
|
-
skybox-to-skybox replace keeps the entry (params and cube rewritten).
|
|
1843
|
-
- The environment binds through the light rewrite's map set (uEnv
|
|
1844
|
-
beside uShadowAtlas on every receiving target, new views included) and
|
|
1845
|
-
directly on setEnvironment; the placeholder cube is app-lifetime like
|
|
1846
|
-
the shadow placeholder. A `lit` without `reflectivity` declares no
|
|
1847
|
-
environment sampler - the flag is part of the class key.
|
|
1848
2156
|
- overlap()/sweep() test SURFACES, the trimesh contract everywhere: a
|
|
1849
2157
|
volume wholly inside a closed mesh with no triangle in reach touches
|
|
1850
2158
|
nothing, and a body whose center has passed through a wall reports
|
|
@@ -1856,3 +2164,26 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
|
|
|
1856
2164
|
floor absorbs the vertical part (a body never creeps down a slope it
|
|
1857
2165
|
can stand on), and with `floorSnap: 0` a standing body reports no
|
|
1858
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.
|