@solidrt/3d 0.0.57 → 0.0.59
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 +1479 -678
- package/README.md +4 -2
- package/examples/README.md +56 -4
- package/examples/bloom.tsx +113 -0
- package/examples/cascades.tsx +4 -4
- package/examples/collision.tsx +6 -6
- package/examples/exits.tsx +96 -0
- package/examples/first-person.tsx +5 -5
- package/examples/fleet.tsx +4 -4
- package/examples/fog.tsx +5 -5
- package/examples/instanced.tsx +70 -50
- package/examples/lamps.tsx +4 -4
- package/examples/lod.tsx +125 -0
- package/examples/materials.tsx +9 -9
- package/examples/morph.tsx +174 -0
- package/examples/normals.tsx +118 -0
- package/examples/{lit.tsx → phong.tsx} +9 -9
- package/examples/polyhedra.tsx +61 -0
- package/examples/scene-atlas.tsx +70 -0
- package/examples/scene-views.tsx +5 -5
- package/examples/shadows.tsx +9 -9
- package/examples/sky-lit.tsx +3 -3
- package/examples/skybox.tsx +5 -5
- package/examples/standard.tsx +2 -2
- package/examples/streams.tsx +122 -0
- package/examples/sweep-paths.tsx +5 -5
- package/examples/wireframe.tsx +129 -0
- package/package.json +3 -3
- package/src/clip.ts +18 -5
- package/src/color.ts +11 -2
- package/src/components/context.tsx +11 -2
- package/src/components/directional-light.tsx +2 -2
- package/src/components/first-person-camera.tsx +4 -4
- package/src/components/group.tsx +9 -5
- package/src/components/hemisphere-light.tsx +2 -2
- package/src/components/index.ts +4 -1
- package/src/components/instanced-mesh.tsx +28 -10
- package/src/components/lod.tsx +102 -0
- package/src/components/mesh.tsx +24 -6
- package/src/components/node-props.ts +35 -5
- package/src/components/orbit-camera.tsx +15 -12
- package/src/components/point-light.tsx +2 -2
- package/src/components/record-mesh.tsx +12 -7
- package/src/components/scene.tsx +55 -2
- package/src/components/spot-light.tsx +2 -2
- package/src/components/sprite.tsx +2 -2
- package/src/components/view3d.tsx +18 -1
- package/src/environment.ts +9 -8
- package/src/first-person.ts +125 -38
- package/src/geometry-gpu.ts +187 -22
- package/src/geometry.ts +1718 -203
- package/src/glsl.ts +398 -113
- package/src/gltf.ts +457 -154
- package/src/index.ts +32 -21
- package/src/input.ts +11 -2
- package/src/light.ts +7 -8
- package/src/lod.ts +148 -0
- package/src/material.ts +322 -190
- package/src/mesh.ts +492 -259
- package/src/mixer.ts +27 -14
- package/src/model-file.ts +54 -15
- package/src/model.ts +151 -54
- package/src/motion.ts +17 -0
- package/src/node.ts +423 -23
- package/src/orbit.ts +378 -116
- package/src/profile.ts +4 -25
- package/src/resolve.ts +181 -0
- package/src/scene-shadows.ts +94 -93
- package/src/scene.ts +608 -185
- package/src/skeleton.ts +211 -0
- package/src/sweep.ts +27 -8
- package/tools/model.ts +3 -1
package/AGENTS.md
CHANGED
|
@@ -6,260 +6,563 @@ 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
|
+
- [Level of detail](#level-of-detail)
|
|
28
|
+
- [Shadows](#shadows)
|
|
29
|
+
- [Retargeted motion](#retargeted-motion)
|
|
30
|
+
- [Geometry layout, indices and topology](#geometry-layout-indices-and-topology)
|
|
31
|
+
- [Material classes](#material-classes)
|
|
32
|
+
- [Pure pieces and checks](#pure-pieces-and-checks)
|
|
33
|
+
- [Components](#components)
|
|
34
|
+
- [Component props](#component-props)
|
|
35
|
+
- [Output composition](#output-composition)
|
|
36
|
+
- [Fill and fixed sizes](#fill-and-fixed-sizes)
|
|
37
|
+
- [Input](#input)
|
|
38
|
+
- [Orbit camera](#orbit-camera)
|
|
39
|
+
- [First-person camera](#first-person-camera)
|
|
40
|
+
- [Overlay projection](#overlay-projection)
|
|
41
|
+
- [Picking](#picking)
|
|
42
|
+
- [Collision](#collision)
|
|
43
|
+
- [Pointer events](#pointer-events)
|
|
44
|
+
- [Geometry generators](#geometry-generators)
|
|
45
|
+
- [Geometry as data](#geometry-as-data)
|
|
46
|
+
- [Profile kit](#profile-kit)
|
|
47
|
+
- [Materials](#materials)
|
|
48
|
+
- [Instancing](#instancing)
|
|
49
|
+
- [Background](#background)
|
|
50
|
+
- [Cube-map convention](#cube-map-convention)
|
|
51
|
+
- [Environment](#environment)
|
|
52
|
+
- [Reflection probes](#reflection-probes)
|
|
53
|
+
- [Baked sky](#baked-sky)
|
|
54
|
+
- [Panoramas](#panoramas)
|
|
55
|
+
- [Fog](#fog)
|
|
56
|
+
- [Color](#color)
|
|
57
|
+
- [Lighting GLSL](#lighting-glsl)
|
|
58
|
+
- [Custom looks: three tiers](#custom-looks-three-tiers)
|
|
59
|
+
- [Lights](#lights)
|
|
60
|
+
- [phong](#phong)
|
|
61
|
+
- [standard](#standard)
|
|
62
|
+
- [Models](#models)
|
|
63
|
+
- [Three layers](#three-layers)
|
|
64
|
+
- [Async loading](#async-loading)
|
|
65
|
+
- [Placement and sockets](#placement-and-sockets)
|
|
66
|
+
- [Wardrobe pieces](#wardrobe-pieces)
|
|
67
|
+
- [Applied glTF material fields](#applied-gltf-material-fields)
|
|
68
|
+
- [Animation](#animation)
|
|
69
|
+
- [Root motion](#root-motion)
|
|
70
|
+
- [Skins](#skins)
|
|
71
|
+
- [Not in the subset](#not-in-the-subset)
|
|
72
|
+
- [Traps](#traps)
|
|
73
|
+
- [Models and skinning](#models-and-skinning)
|
|
74
|
+
- [Materials and color](#materials-and-color)
|
|
75
|
+
- [Environment and background](#environment-and-background)
|
|
76
|
+
- [Coordinates and rotation](#coordinates-and-rotation)
|
|
77
|
+
- [Visibility and instancing](#visibility-and-instancing)
|
|
78
|
+
- [Shadows](#shadows)
|
|
79
|
+
- [Views and scene params](#views-and-scene-params)
|
|
80
|
+
- [Shader materials](#shader-materials)
|
|
81
|
+
- [Picking, pointer and collision](#picking-pointer-and-collision)
|
|
82
|
+
- [Geometry and components](#geometry-and-components)
|
|
83
|
+
|
|
9
84
|
## The model
|
|
10
85
|
|
|
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
|
-
|
|
86
|
+
### Two layers: core and components
|
|
87
|
+
|
|
88
|
+
Two layers. The imperative core is Solid-free: `createScene`,
|
|
89
|
+
`createMesh(geometry, material)`, `add`/`remove`, `setTransform`,
|
|
90
|
+
`lookAt`, `getRotation`, `setVisible`, `setRenderOrder` - plain objects
|
|
91
|
+
over the spatial core (`flux:spatial`, `alloy/src/spatial/`): every node
|
|
92
|
+
in a scene has a core node, JS keeps the LOCAL position/quaternion/scale
|
|
93
|
+
as the readable truth and forwards each write, and the core's flush
|
|
94
|
+
(one call per microtask) recomputes only the moved subtrees and writes
|
|
95
|
+
each entry's uModel (plus uNormal for materials declaring it) and its
|
|
96
|
+
visibility switch itself - a move costs its subtree, never the scene.
|
|
97
|
+
ONE `setTargetParams` (the shared uViewProj + uCamPos) per camera
|
|
98
|
+
change, however many meshes. World matrices live in the core only:
|
|
99
|
+
`worldPosition`/`lookAt`/picking read them back (`worldMatrix`, pending
|
|
100
|
+
writes included). See okf/backlog/spatial-core.md for what still runs
|
|
101
|
+
in JS and why. The component face (`Scene`/`View3d`/`Group`/`Mesh`/
|
|
102
|
+
`PerspectiveCamera`) syncs props into that core over context and renders
|
|
103
|
+
nothing itself.
|
|
104
|
+
|
|
105
|
+
### Node lifecycle
|
|
106
|
+
|
|
107
|
+
Two removal verbs, one meaning each, the same words with the same
|
|
108
|
+
meanings in @solidrt/2d. `remove(child)` DETACHES an intact subtree -
|
|
109
|
+
Three's `parent.remove`, Godot's remove_child: children stay under the
|
|
110
|
+
removed node, core nodes free on leave and recreate on re-enter, so a
|
|
111
|
+
removed subtree re-adds cleanly (`add(parent, child)` re-parents through
|
|
112
|
+
it); it SNAPS, since a detached node is coming back. `destroy(node)` is
|
|
113
|
+
Unity's Destroy, Godot's queue_free: the subtree is gone for good, every
|
|
114
|
+
handle in it inert (writes are no-ops, add throws, remove skips), and it
|
|
115
|
+
is the removal an `exit` rides on (see Retargeted motion). The components
|
|
116
|
+
destroy on unmount. Nothing else is destroyed: GPU resources stay on
|
|
117
|
+
their own disposers (`disposeGeometry`, `disposeInstances`,
|
|
118
|
+
`model.dispose`, the scene's `dispose`), each of which frees a node still
|
|
119
|
+
animating out on the spot. An instance node (addInstance on an instanced
|
|
120
|
+
mesh) is slot-bound to its mesh: `destroy` frees it (the slot recycles at
|
|
121
|
+
the free) and the generic add/remove throw on it. (@solidrt/2d has no
|
|
122
|
+
detach - a sprite cannot exist outside its layer - so `destroySprite` /
|
|
123
|
+
`destroyGroup` are its only removal, and a sprite that should come back
|
|
124
|
+
is hidden.)
|
|
125
|
+
|
|
126
|
+
### Rendering is the runtime's
|
|
127
|
+
|
|
128
|
+
Rendering is the runtime's. The target is `render: "auto"`: it
|
|
129
|
+
re-renders when entries change, so a STATIC scene costs zero passes and
|
|
130
|
+
the library registers no frame loop. Continuous animation is the app's
|
|
131
|
+
own `onFrame` writing a signal (declarative) or `setTransform` on a
|
|
132
|
+
`ref`-grabbed node (the frame-rate escape hatch - signals carry
|
|
133
|
+
structure, per-frame motion goes straight to the scene).
|
|
134
|
+
|
|
135
|
+
### Views and layers
|
|
136
|
+
|
|
137
|
+
VIEWS: `scene.createView({ width, height, overrideMaterial?, depth?,
|
|
138
|
+
clearColor?, ... })` renders the same scene into a second target from
|
|
139
|
+
its own camera (`view.setCamera`, the scene's CameraUpdate shape; in a
|
|
140
|
+
component tree `<View3d>` is the same as a Scene child, see Components).
|
|
141
|
+
Each mesh gets one entry in the view's target bound as one more draw sink of
|
|
142
|
+
its CORE node, so the one flush writes every target - the app writes
|
|
143
|
+
nothing per view. Geometry buffers and (without an override) materials
|
|
144
|
+
are shared; the light set and `scene.setParams` names fan out to every
|
|
145
|
+
view, `view.setParams` is the view's own channel - and names a view
|
|
146
|
+
sets itself (or its `fog` option, below) become VIEW-OWNED: the
|
|
147
|
+
scene's setParams/setFog fan-out skips them from then on, so a view
|
|
148
|
+
override survives scene-wide writes. A view's backdrop is its
|
|
149
|
+
clearColor (the scene background draws on PROBES, not views); a view
|
|
150
|
+
has no picking. A view is a buffer plus a resolve like the scene
|
|
151
|
+
(`view.hdrTexture`, `view.texture`, the `resolve` option and
|
|
152
|
+
`view.setResolve` - see Color); a tiled view (`into`) renders LINEAR
|
|
153
|
+
light into the atlas and the app resolves the atlas once: a
|
|
154
|
+
`createShaderTexture` over `resolveFragment()` from `/glsl` (the stock
|
|
155
|
+
source with `uScene` and the RESOLVE set declared) with the atlas bound
|
|
156
|
+
as `uScene` and `resolveParams({ toneMapping, exposure })` as its params
|
|
157
|
+
- `examples/scene-atlas.tsx`. LAYERS select what a target
|
|
158
|
+
draws, Three's model exactly: `layers` on a mesh is its membership
|
|
159
|
+
bitmask (default 1, `setLayers`/the `layers` prop, NOT inherited from
|
|
160
|
+
Groups), and each target carries a mask (default 1) - `layers` on
|
|
161
|
+
createScene/createView, live via `setLayers` on the scene handle and
|
|
162
|
+
each view. A mesh draws where mask & layers is non-zero, so a minimap's
|
|
163
|
+
marker meshes live on bit 2: invisible in the main render, drawn by
|
|
164
|
+
the map view whose mask admits them. Shadow views follow the SCENE's
|
|
165
|
+
mask (what the scene cannot see must not darken it), and
|
|
166
|
+
pick()/raycast()/overlap()/sweep() skip scene-masked-out meshes like
|
|
167
|
+
invisible ones - unless the query passes its own `{ layers }`, which is
|
|
168
|
+
how a low-poly collision mesh lives undrawn in the scene yet answers
|
|
169
|
+
ground and collision queries (the physics-collider pattern).
|
|
170
|
+
Per-view fog: `fog: FogOptions | null` on createView overrides the
|
|
171
|
+
scene's fog for that view (null = unfogged - the clear minimap over a
|
|
172
|
+
fogged scene); absent follows the scene. `overrideMaterial` (Three's
|
|
173
|
+
`scene.overrideMaterial`, scoped to the view) draws every mesh with one
|
|
174
|
+
material - a depth pass, a normal/id visualizer - skips instanced
|
|
175
|
+
meshes (unless the override itself declares their exact
|
|
176
|
+
`instanceBuffers` record layouts) and draws in add order. `depth: "texture"` exposes `view.depthTexture`, the shadow-map
|
|
177
|
+
input; the same option on createScene exposes `scene.depthTexture`,
|
|
178
|
+
the input for a depth-reading post effect in `output` (not combinable
|
|
179
|
+
with `samples` - no multisampled sampleable depth). `ortho: { left, right, top, bottom }` on any camera swaps
|
|
180
|
+
perspective for `orthographic()` (`fov` ignored; `ortho: null` returns);
|
|
181
|
+
the scene's own camera takes it too, and pick() follows.
|
|
182
|
+
`examples/scene-views.tsx` is the shape.
|
|
183
|
+
|
|
184
|
+
### Culling
|
|
185
|
+
|
|
186
|
+
CULLING is the core's, per target, on by default: every camera write
|
|
187
|
+
(the scene's, a view's, a shadow tile's) also sets that target's
|
|
188
|
+
frustum in the spatial core, and the flush switches an entry whose
|
|
189
|
+
world box falls wholly outside it to instance count 0 - the same
|
|
190
|
+
switch as `visible`, so a culled mesh costs nothing per frame and a
|
|
191
|
+
still camera re-tests nothing (a camera move re-tests every sink on
|
|
192
|
+
that target, in Rust; a node move re-tests its own). The box is the
|
|
193
|
+
picking box (the local bounds through the world matrix, the
|
|
194
|
+
Godot/Unity AABB test; Three uses spheres); an instanced mesh without
|
|
195
|
+
explicit `bounds` is culled by the union of its live instances' boxes
|
|
196
|
+
(the joint group below over the instance nodes, so the box follows
|
|
197
|
+
them: Godot's MultiMesh AABB, with `bounds` as its custom_aabb; Three
|
|
198
|
+
computes an InstancedMesh sphere once and leaves it stale, Unity wants
|
|
199
|
+
worldBounds), and a record mesh without `bounds` is never culled. Per
|
|
200
|
+
mesh: `frustumCulled: false` (Three's name; `setCulling`) for geometry
|
|
201
|
+
a vertex stage moves beyond its box - a fullscreen quad, a custom
|
|
202
|
+
displacement - and `cullMargin` (world units, Godot's
|
|
203
|
+
`extra_cull_margin`) for bounded displacement such as wind. Sprites
|
|
204
|
+
cull by their quad's reach at any facing. A SKINNED part is culled by
|
|
205
|
+
the union of its joints' boxes (the bake computes each joint's
|
|
206
|
+
influence box in joint space, `ModelSkin.jointBounds`, .srtm VERSION
|
|
207
|
+
5; the joint nodes carry them as culling-only bounds, outside the
|
|
208
|
+
picking index), so the box follows the pose with no per-frame JS -
|
|
209
|
+
Unity's bone bounds, Godot's per-bone AABBs; no `updateWhenOffscreen`
|
|
210
|
+
knob is needed. Probe faces set no frustum (six cameras, one target).
|
|
211
|
+
Shadow tiles cull casters against their light frustum, which is what
|
|
212
|
+
makes `shadow.distance` and cascades cheaper.
|
|
213
|
+
|
|
214
|
+
### Level of detail
|
|
215
|
+
|
|
216
|
+
LOD is the core's too, beside culling: a group's level is picked per
|
|
217
|
+
target after each flush by PROJECTED SIZE - the sphere around the
|
|
218
|
+
group's boxes (radius = half the box diagonal, conservative like the
|
|
219
|
+
frustum box), its diameter as a fraction of the viewport height,
|
|
220
|
+
`radius / (distance * tan(fov / 2))` under a perspective camera and
|
|
221
|
+
`radius * 2 / (top - bottom)` under an orthographic one (Unity's
|
|
222
|
+
screen-relative model; Three's distance is wrong for an ortho minimap,
|
|
223
|
+
a zoomed camera or a scaled-up tree). `createLod([{ node, size }, ...],
|
|
224
|
+
{ fade? })` / `<Lod fade?>` with `lodSize` on its direct children: the
|
|
225
|
+
levels nearest first, `size` the projected size BELOW which a level
|
|
226
|
+
hands over to the next, the last one's the cull threshold (0 = never
|
|
227
|
+
culled); a level is a direct child (a parentless node is adopted by
|
|
228
|
+
`setLod`, one parented elsewhere is refused, never moved). Every camera write also sets its target's LOD view (eye,
|
|
229
|
+
focal, ortho flag, `lodBias`): the scene and each view pick by their
|
|
230
|
+
own camera (a minimap sees the far level of the same tree), shadow
|
|
231
|
+
tiles by the SCENE camera (a caster draws the level the camera sees, so
|
|
232
|
+
its shadow matches - Unity's and Godot's rule), probe faces pick
|
|
233
|
+
nothing. The gate composes with the frustum and `visible` into the same
|
|
234
|
+
instance-count switch, so a still camera re-tests nothing and a thousand
|
|
235
|
+
groups cost no JS per frame; a child of the group that carries no
|
|
236
|
+
`lodSize` is drawn always. Without a `fade` the switch is hard with a
|
|
237
|
+
one-sided hysteresis band (a level is left at its threshold and
|
|
238
|
+
re-entered only once the size climbs a tenth above it), so a boundary
|
|
239
|
+
never flickers; with one, each threshold `s` widens into `[s, s * (1 +
|
|
240
|
+
fade))` where both levels draw with COMPLEMENTARY screen-hash dithers
|
|
241
|
+
(the near one keeps the pixels below the band position, the far one
|
|
242
|
+
the rest - a partition, never a double draw) through the `uLodFade`
|
|
243
|
+
vec2 every stock fragment composes, its band position in 64 steps so an
|
|
244
|
+
entry is rewritten when its step changes, not every frame, and written
|
|
245
|
+
solid again when the entry switches off; a custom class opts in by composing
|
|
246
|
+
`LOD_FADE` from `@solidrt/3d/glsl` and calling `lodFade()` first in
|
|
247
|
+
main. Shadow tiles never fade: their depth pass switches hard at the
|
|
248
|
+
band's midpoint, invisible for two variants of one shape.
|
|
249
|
+
`createInstancedLod([{ geometry, material, size }, ...], opts)` /
|
|
250
|
+
`<InstancedLod levels capacity?>` is the population form: one instance
|
|
251
|
+
set, one matrix buffer PER LEVEL, and the core stages each instance's
|
|
252
|
+
record into the level ITS OWN size picks - a spread forest as three
|
|
253
|
+
entries, near trees full and far ones cards. Returned as the first
|
|
254
|
+
level's mesh (the other levels are its children at identity, sharing
|
|
255
|
+
its instance slots; `setLayers`/`setCastShadow` on it reach them,
|
|
256
|
+
`setInstanceStyle` writes every level's style stream; a pick or pointer
|
|
257
|
+
event on an instance names that first mesh whatever level the instance
|
|
258
|
+
is drawn at). Records are
|
|
259
|
+
target-agnostic, so instances pick by the SCENE camera and every view
|
|
260
|
+
draws that choice; instanced levels switch hard (`fade` is refused).
|
|
261
|
+
`scene.setLodBias` (Unity's lodBias) multiplies every measured size:
|
|
262
|
+
below 1 hands over sooner, the one-line quality knob for a low-end
|
|
263
|
+
device. Picking and collision see the level the queried target draws
|
|
264
|
+
(`scene.pick`/overlap/sweep/moveAndSlide the scene's, `view.pick` the
|
|
265
|
+
view's); a collider that must not follow the camera is its own undrawn
|
|
266
|
+
mesh on a collision layer. Nested groups chain. Mesh simplification is
|
|
267
|
+
a bake job, not a runtime one. `examples/lod.tsx` is the shape;
|
|
268
|
+
`probes/3d-lod-bench.tsx` the cost.
|
|
269
|
+
|
|
270
|
+
### Shadows
|
|
271
|
+
|
|
272
|
+
SHADOWS are a view: `<DirectionalLight castShadow shadow={{ mapSize?,
|
|
273
|
+
bias?, normalBias?, radius?, camera? }}>` (`createDirectionalLight({ castShadow,
|
|
274
|
+
shadow })`, `setLight`) makes the scene own an internal
|
|
275
|
+
`createView({ depth: "texture", overrideMaterial: depth pass })` drawing
|
|
276
|
+
the `castShadow` meshes (`<Mesh castShadow>`, `setCastShadow`) from an
|
|
277
|
+
orthographic camera at the light's WORLD position along its world
|
|
278
|
+
direction, `shadow.camera` (+-5, 0.5..500 by default) as the frustum.
|
|
279
|
+
Any light may cast, bounded by the shadow-slot budget
|
|
280
|
+
(`MAX_SHADOW_MAPS` = 8, its own constant, exported from the root and
|
|
281
|
+
`/glsl` with `MAX_LIGHTS` and `MAX_CASCADES`: a directional light claims
|
|
282
|
+
`shadow.cascades` consecutive slots, a point light six, a spot one).
|
|
283
|
+
The budget is checked when the set settles (the scene's sync), never
|
|
284
|
+
at attach, so a `<Show>`/`<Switch>` swap whose branches each fit is
|
|
285
|
+
fine even though both are attached for an instant. A set over budget
|
|
286
|
+
once settled is reported once per change of the set: `<Scene>`
|
|
287
|
+
rethrows it inside the tree, so the app's error boundary (or an
|
|
288
|
+
`<Errored>` closer in) shows it, and Reset returns to the scene, which
|
|
289
|
+
kept rendering - the casters that fit in attach order keep their maps,
|
|
290
|
+
the rest light the scene unshadowed. A bare `createScene` throws it
|
|
291
|
+
from its sync unless given `onError`. A deliberate overlap must fit
|
|
292
|
+
alongside its successor: a casting light with its own exit transition
|
|
293
|
+
keeps casting until it frees. `<SpotLight castShadow
|
|
294
|
+
shadow={{ mapSize?, bias?, normalBias?, near? }}>` is the same
|
|
295
|
+
machinery with a PERSPECTIVE camera: at the light's world position
|
|
296
|
+
along its world direction, fov = its cone (2 * angle), near from
|
|
297
|
+
`shadow.near` (default 0.5), far from the light's `distance` (or the
|
|
298
|
+
directional default 500 when 0) - one map, one slot, the same atlas
|
|
299
|
+
and lookup. A perspective map's depth is nonlinear, so `normalBias`
|
|
300
|
+
(world units) is the acne knob to reach for; `bias` acts in that
|
|
301
|
+
nonlinear depth. `<PointLight castShadow>` casts in every direction
|
|
302
|
+
with the same option set: six 90-degree face maps (world-axis
|
|
303
|
+
aligned, slot order +X, -X, +Y, -Y, +Z, -Z) as six consecutive tiles
|
|
304
|
+
of the same atlas, far from `distance` like a spot - so give a
|
|
305
|
+
casting bulb a distance. No cube map: a receiver picks the face by
|
|
306
|
+
the dominant axis of the light-to-point vector (SHADOW_LOOKUP), one
|
|
307
|
+
projection, one hardware-compare tap - the Three/Godot/Unity-URP
|
|
308
|
+
atlas route. `shadow.radius` (every casting light, default 1 = that
|
|
309
|
+
one tap, a 2x2 bilinear compare) softens the edge: above 1 a 3x3 grid
|
|
310
|
+
of hardware taps `radius` texels apart, Three's `shadow.radius` /
|
|
311
|
+
Godot's `shadow_blur`, nine taps per receiver fragment. A first-person
|
|
312
|
+
eye over a floor shows the texel stairs at any map size; 2 is the
|
|
313
|
+
usual figure, past ~3 the taps separate into bands. Each face map renders a few degrees wider than its face
|
|
314
|
+
(URP's fovBias) so a seam fragment's occluder is inside the map it
|
|
315
|
+
samples - without the guard band every seam shows a lit slit - and
|
|
316
|
+
PCF taps clamp at face-tile edges, so a face seam hardens slightly
|
|
317
|
+
instead of bleeding into the neighbour.
|
|
318
|
+
`shadow: { cascades: N }` (1..MAX_CASCADES = 4) replaces the box with
|
|
319
|
+
N maps fitted to slices of the SCENE camera's frustum (near ..
|
|
320
|
+
`shadow.distance`, default the camera far; the practical split; each
|
|
321
|
+
slice's bounding sphere as an ortho box along the light, its centre
|
|
322
|
+
snapped to the map's texel grid so edges do not swim; re-fitted
|
|
323
|
+
whenever the scene camera or the light moves) - a receiver samples the
|
|
324
|
+
tightest map that covers the point, fading into the next over the
|
|
325
|
+
map's outer 10% (`SHADOW_BLEND`) so the hand-over is a band, not a
|
|
326
|
+
seam; contact shadows stay sharp near the camera while the horizon
|
|
327
|
+
still has coarse ones, and pulling `distance` in sharpens all of them.
|
|
328
|
+
The box is the honest tier for a bounded scene; cascades are for a
|
|
329
|
+
scene that outgrows it, at N times the shadow fill. Every map is a
|
|
330
|
+
TILE of the scene's one shadow atlas (a `depth: "texture"` draw target,
|
|
331
|
+
a grid of cells the largest `mapSize` wide, scaled down uniformly
|
|
332
|
+
against `limits.maxTextureSize`), so N maps are ONE pass: the atlas
|
|
333
|
+
depth binds as the target-level `uShadowAtlas` of the scene and every
|
|
334
|
+
non-shadow view (a white texel when nothing casts); maps are MAP slots
|
|
335
|
+
dealt in light order, a light's cascades consecutive and tightest
|
|
336
|
+
first - `uShadowRect[j]` slot j's tile in atlas UV, `uShadowMatrix[j]`
|
|
337
|
+
its view's own view-projection (the whole array is one write per
|
|
338
|
+
shadow-camera move) - and per light i `uShadowFirst[i]`/`uShadowCount[i]`
|
|
339
|
+
name its slots (count 0 = it does not cast) with
|
|
340
|
+
`uShadowBias[i]`/`uShadowNormalBias[i]` its knobs; `SHADOW_SLOTS` in
|
|
341
|
+
glsl declares the set. Every `phong` material RECEIVES by default
|
|
342
|
+
(Godot's and Three's default); `phong({ receiveShadow: false })` opts a
|
|
343
|
+
material out and drops the map from its program - a material option,
|
|
344
|
+
as with vertexColors/triplanar, because the material picks the program
|
|
345
|
+
(Godot's `disable_receive_shadows`). The factor is `SHADOW`'s one
|
|
346
|
+
hardware-compare tap (sampler2DShadow, LEQUAL, the driver's 2x2 PCF)
|
|
347
|
+
on each casting light's own term. `examples/shadows.tsx` (three
|
|
348
|
+
casting lights) is the shape; `examples/cascades.tsx` the cascaded sun.
|
|
349
|
+
|
|
350
|
+
### Retargeted motion
|
|
351
|
+
|
|
352
|
+
RETARGETED motion is native: `setTransition(node, { position:
|
|
353
|
+
{ duration: 400 }, ... })` makes setTransform writes TARGETS the core
|
|
354
|
+
animates toward every frame (position/scale per lane, rotation along
|
|
355
|
+
the quaternion geodesic - a spring keeps its velocity through
|
|
356
|
+
retargets), so a mesh gliding to a slot or a camera rig easing costs
|
|
357
|
+
one JS write per target change, zero per frame. The declaration lives
|
|
358
|
+
on the SceneNode and re-applies on every scene enter; the pose a node
|
|
359
|
+
enters with snaps, unless a component's `from` (`position: { duration,
|
|
360
|
+
from: [x, y, z] }`, a quaternion for `rotation`) animates it in from
|
|
361
|
+
there - at every scene enter, since each enter creates the core node
|
|
362
|
+
anew. A component's `exit` is where it animates to when `destroy` lets
|
|
363
|
+
go of the node: it stays drawn while it leaves and is a GHOST meanwhile -
|
|
364
|
+
no pick, raycast, overlap, sweep or pointer event sees it - then frees
|
|
365
|
+
when the last exit settles; children go first and the parent stays their
|
|
366
|
+
frame until the last of them has settled, so a dying character's parts
|
|
367
|
+
leave in its frame (a mixer driving its joints keeps driving them). Either
|
|
368
|
+
endpoint takes `{ value, duration?, curve?, bounce?, delay? }` to own its
|
|
369
|
+
direction's motion (an ease-out enter, an ease-in exit), and `delay` on
|
|
370
|
+
an entry holds its writes on the animation clock (a late frame catches
|
|
371
|
+
up). `stagger` (ms) goes on an ANCESTOR's declaration (a `<Group
|
|
372
|
+
transition={{ stagger: 40 }}>`), never on the animating nodes: it spaces
|
|
373
|
+
the enters and exits of its descendants that begin in one frame by
|
|
374
|
+
`index * stagger` (add order in, tree order out whatever order the
|
|
375
|
+
unmount lets them go in; nearest declaring ancestor wins) and cascades
|
|
376
|
+
nothing unless they declare `from`/`exit` -
|
|
377
|
+
a squad of instances spawning in is one `stagger` on their group, and
|
|
378
|
+
for the nodes straight under the scene root it is `<Scene stagger>` /
|
|
379
|
+
`createScene({ stagger })` (a declaration on `scene.root`). A populated
|
|
380
|
+
mesh unmounts as `destroy(mesh)` then `disposeInstances(mesh)`, and the
|
|
381
|
+
dispose waits for a mesh still animating out, so an `<Instance>`'s exit
|
|
382
|
+
plays through its `<InstancedMesh>`'s unmount (`model.dispose` waits the
|
|
383
|
+
same way); the scene's and a layer's `dispose` cut a corpse short - the
|
|
384
|
+
whole target is going. `examples/exits.tsx` is the live example: crates
|
|
385
|
+
that pop in and shrink away on tap, the shelf cascading out and back
|
|
386
|
+
through `<Show>`. Each natural settle calls the node's `onTransitionEnd`
|
|
387
|
+
(plain field like the pointer handlers) with `{ component }` - never on
|
|
388
|
+
a cancel, snap, leave or exit; the raw "spatialTransitionEnd" engine
|
|
389
|
+
event (srt:events, carrying the CORE node id `_node`) stays for
|
|
390
|
+
flux:spatial consumers.
|
|
391
|
+
|
|
392
|
+
### Geometry layout, indices and topology
|
|
393
|
+
|
|
394
|
+
One interleaved vertex buffer per geometry, described by an open layout
|
|
395
|
+
(`Geometry.layout`, absent = "base"): an ordered attribute list that
|
|
396
|
+
starts with `aPos` float32x3 and may carry any named channels after it,
|
|
397
|
+
each in a vertex format. Formats are WebGPU's spelling of (component
|
|
398
|
+
type, count, normalized): the float32 family (`float32` .. `float32x4`),
|
|
399
|
+
`float16x2/x4`, the normalized integers `unorm8x4`, `snorm8x4`,
|
|
400
|
+
`unorm16x2/x4`, `snorm16x2/x4`, the unsigned integers `uint8x4`,
|
|
401
|
+
`uint16x2/x4`, `uint32` .. `uint32x4` and the signed integers `sint8x4`,
|
|
402
|
+
`sint16x2/x4`, `sint32` .. `sint32x4`; `VERTEX_FORMATS` is the table.
|
|
403
|
+
Every format is a multiple of 4 bytes, so offsets and strides are
|
|
404
|
+
4-aligned by construction. A format feeds the shader `in` of its KIND
|
|
405
|
+
and component count, WebGPU's rule: float and normalized formats feed
|
|
406
|
+
`in vec4` (the fetch converts: `in vec4 aColor` reads a `unorm8x4` as
|
|
407
|
+
0..1), `uint*` formats feed `in uvec4` and `sint*` formats `in ivec4`,
|
|
408
|
+
exact at every width - so a per-vertex id or an index into a wide
|
|
409
|
+
table rides as `uint32` into `in uint aId`, and the "skinned" layout's
|
|
410
|
+
`aJoints` is `uint8x4` into `in uvec4 aJoints`. A layout that crosses
|
|
411
|
+
kinds (`uint8x4` into `in vec4`) is the missing-attribute error at
|
|
412
|
+
add() (`formatFeeds` is the rule). The base prefix `aPos` float32x3 + `aNormal`
|
|
413
|
+
float32x3 + `aUV` float32x2 is what every generator emits and what the
|
|
414
|
+
stock materials read, not a rule: a hand-built geometry declares what
|
|
415
|
+
it has, so a point cloud may carry `[aPos float32x3, aData float32]` at
|
|
416
|
+
16 bytes a point instead of 36, or a color as `unorm8x4` at 4 bytes
|
|
417
|
+
instead of 16. Pair a prefixless layout with a material that reads only
|
|
418
|
+
what it carries: `unlit` (aPos only) or a shaderMaterial; a stock lit
|
|
419
|
+
material over it throws the ordinary missing-attribute error at add().
|
|
420
|
+
A layout handed to a generator must start with the prefix and stay
|
|
421
|
+
float32-family (the generator writes floats); packing is a pass over
|
|
422
|
+
the result. `withAttribute(geometry, { name, format }, fill)` appends
|
|
423
|
+
one channel in any format (Three's `setAttribute` for an interleave;
|
|
424
|
+
the fill is given in float and encoded on write); "colored" names the
|
|
425
|
+
common case, the prefix plus `aColor` float32x4 - the per-vertex data
|
|
426
|
+
channel (a tint, baked AO, any four scalars; standard name, your
|
|
427
|
+
contents) - and `withColors(geometry, fill)` is its spelling. Fill is a
|
|
428
|
+
flat size-per-vertex array or a per-vertex callback receiving `(index,
|
|
429
|
+
pos, normal, uv)` (zeros for a channel the layout lacks). The fill is
|
|
430
|
+
raw floats: a tint the stock materials read under `vertexColors` is
|
|
431
|
+
premultiplied linear like every shader color, so encode an sRGB pick
|
|
432
|
+
with `premultipliedColor(color)`. `Geometry.vertices` is an
|
|
433
|
+
`ArrayBufferView` (a Float32Array for an all-float layout, a Uint8Array
|
|
434
|
+
when a channel is packed; never the contract): read and write a channel
|
|
435
|
+
through `geometryAttribute(geometry, name)`, an accessor whose
|
|
436
|
+
`get(i, k)`/`set(i, k, v)` speak floats whatever the format, and count
|
|
437
|
+
vertices with `vertexCount`. Materials read attributes BY NAME: a
|
|
438
|
+
material's vertex stage may declare any subset of its geometry's
|
|
439
|
+
channels, and a channel the program reads that the geometry lacks (by
|
|
440
|
+
name, or with a different component count) throws at add(). What a program reads is the ENGINE's word (`material.attributes()`
|
|
441
|
+
= `programAttributes` reflection of the linked program, instance
|
|
442
|
+
attributes excluded), not a parse of the GLSL: an `in` the compiler
|
|
443
|
+
dropped does not count, and the engine also rejects a pipeline whose
|
|
444
|
+
attribute lists leave a read attribute uncovered. The material
|
|
445
|
+
keeps one program and builds one pipeline per layout its meshes bring,
|
|
446
|
+
so a geometry may carry more than a material reads. The whole layout
|
|
447
|
+
ships whether a material reads every attribute or not (inactive
|
|
448
|
+
attributes only keep the stride), so extra channels cost their bytes on
|
|
449
|
+
every draw of that geometry - keep data-light passes (a wireframe
|
|
450
|
+
reading only aPos) on base-layout geometry. `layoutStride` (bytes)/
|
|
451
|
+
`layoutSlot` (byte offset, format, components)/`layoutKey`/
|
|
452
|
+
`layoutAttributes` are the layout arithmetic; two layouts with equal
|
|
453
|
+
keys interleave identically (merge requires that).
|
|
454
|
+
STREAMS: a geometry may carry more than one vertex buffer.
|
|
455
|
+
`Geometry.vertices` under `layout` is stream 0 (where aPos lives) and
|
|
456
|
+
`Geometry.streams` holds extra `{ layout, vertices }` buffers, each with
|
|
457
|
+
its own attribute list and the same vertex count (Unity's vertex
|
|
458
|
+
streams; Three's one buffer per channel when a stream carries one
|
|
459
|
+
attribute). `withAttribute(geometry, attr, fill, { stream })` appends a
|
|
460
|
+
channel to stream 1..n or opens stream n + 1; `geometryAttribute`,
|
|
461
|
+
`geometrySlot`, `geometryKey`, `geometryLayouts`, `geometryStreams`
|
|
462
|
+
and `geometryVertexCount` read across streams, and a material's
|
|
463
|
+
pipeline is one per `geometryKey` (every stream's layout, `|` between
|
|
464
|
+
them). Streams exist for data on its own schedule: `updateVertices(
|
|
465
|
+
geometry, { stream?, first?, count? })` re-uploads that vertex range of
|
|
466
|
+
that stream from the geometry's own array in place (write the array
|
|
467
|
+
through the accessor first; Three's `needsUpdate` with an update
|
|
468
|
+
range), reaching every mesh, view and wireframe over the geometry
|
|
469
|
+
while the other streams never move; a stream-0 update drops the cached
|
|
470
|
+
bounds and rewrites the same range of the core's copy of the positions,
|
|
471
|
+
so the mesh picks and culls where it is now drawn (the box follows at
|
|
472
|
+
the next flush, the triangle index is rebuilt by the next query - a
|
|
473
|
+
deforming mesh needs no re-attach). transformGeometry and
|
|
474
|
+
the edge builders share extra streams with their source; merge
|
|
475
|
+
concatenates every stream; the `.srtm` container refuses streams (run
|
|
476
|
+
time data). `setDrawRange(mesh, first, count?)` draws indices `[first,
|
|
477
|
+
first + count)` of the mesh's geometry (Three's `setDrawRange`, on the
|
|
478
|
+
mesh because the scene owns the entries), applied to every entry of the
|
|
479
|
+
mesh and reset by setGeometry. The worked example is
|
|
480
|
+
`examples/streams.tsx`.
|
|
481
|
+
Indices are uint16 or uint32 - the `Geometry.indices` array type picks
|
|
482
|
+
the draw's index format, so hand-built geometry past 64k vertices just
|
|
483
|
+
uses a Uint32Array (generators emit uint16). What the indices LIST is
|
|
484
|
+
`Geometry.topology` (`"triangles"` when absent; `"lines"`,
|
|
485
|
+
`"line-strip"`, `"points"`, `"triangle-strip"`): the index buffer and
|
|
486
|
+
its primitive travel together (Godot's surface primitive, Unity's
|
|
487
|
+
SetIndices topology), a material builds one pipeline per (layout,
|
|
488
|
+
topology) pair its meshes bring, and validateGeometry enforces the
|
|
489
|
+
count rule at add() (a whole number of triangles or lines, one
|
|
490
|
+
primitive's worth for a strip). Materials have no topology of their
|
|
491
|
+
own. Only a triangle list gets a triangle narrowphase; lines and points
|
|
492
|
+
pick and collide by their bounds box (which follows `updateVertices` all
|
|
493
|
+
the same), and cast no shadow. `geometryTopology`
|
|
494
|
+
reads the field with the default applied. POINTS: a point cloud is
|
|
495
|
+
ordinary indexed geometry - one vertex per point, the index buffer
|
|
496
|
+
listing them, `topology: "points"`, no constraint on the index count.
|
|
497
|
+
Under `"points"` the vertex stage writes `gl_PointSize` (honored to the
|
|
498
|
+
pixel; `size / gl_Position.w` attenuates it with distance) and the
|
|
499
|
+
fragment reads `gl_PointCoord` (0..1 across the sprite) to shape the
|
|
500
|
+
splat - `discard` outside the inscribed circle for round ones. The
|
|
501
|
+
worked example is `@solidrt/core`'s `examples/gpu-particles.tsx`. Do
|
|
502
|
+
NOT reach for instancing to draw a cloud (Instancing, below, has the
|
|
503
|
+
cost model). Geometry GPU buffers are lazy, shared, and
|
|
504
|
+
reference-counted by draw entries: removing the last
|
|
505
|
+
entry frees them at the end of the microtask (a same-tick rebuild keeps
|
|
506
|
+
the upload), so swapping `<Mesh geometry>` reactively never accumulates
|
|
507
|
+
old generations; the vertex upload is keyed on the vertices view
|
|
508
|
+
itself, so geometries sharing a vertex array (a wireframe over its
|
|
509
|
+
source) share one buffer. `disposeGeometry` is the immediate explicit
|
|
510
|
+
free.
|
|
511
|
+
|
|
512
|
+
### Material classes
|
|
513
|
+
|
|
514
|
+
Materials dedupe hard: one program + one pipeline per material CLASS
|
|
515
|
+
(a `shaderMaterialClass` per option combination for unlit, lit and
|
|
516
|
+
sprite alike: map x transparent x cull x alphaTest, phong's extras on
|
|
517
|
+
top), `depth: true` + `cull: "back"` unless the material says otherwise
|
|
518
|
+
(`cull: "none"` for double-sided geometry; phong flips the normal on back
|
|
519
|
+
faces); an instance is just per-entry uniforms (`uColor`) and bindings
|
|
520
|
+
(`uMap`).
|
|
521
|
+
|
|
522
|
+
### Pure pieces and checks
|
|
523
|
+
|
|
524
|
+
The pure pieces (`math.ts`, `order.ts`, `geometry.ts`,
|
|
525
|
+
`profile.ts`, `sweep.ts`, `gltf.ts`, `model-file.ts`) are Solid-free and
|
|
526
|
+
GPU-free BY DESIGN so they can be checked headless (and, for the two
|
|
527
|
+
model modules, run under bun in `tools/model.ts`); keep them that way.
|
|
528
|
+
The rigs under `checks/`
|
|
529
|
+
(`geometry-check`, `sweep-check`, `pick-check`, `dispatch-check`,
|
|
530
|
+
`order-check`, `gltf-check`) run on
|
|
531
|
+
flux from the repo root: `bunx srt bundle -f --stdout
|
|
532
|
+
packages/3d/checks/<name>.ts | target/release/flux -`. Run the ones
|
|
533
|
+
touching what you changed. `raycast-check.tsx` and
|
|
534
|
+
`collision-check.tsx` are the exceptions: they assert the documented
|
|
535
|
+
picking contract (triangle accuracy, the box tier, pick/raycast
|
|
536
|
+
parity, layer masks, the `{ meshes }` filter) and collision contract
|
|
537
|
+
(exact sweep times, the surface rule, the slide filter, layers and
|
|
538
|
+
meshes on overlap/sweep, moveAndSlide's landing) against a real scene,
|
|
539
|
+
so they run on the playback client instead:
|
|
540
|
+
`bunx srt render packages/3d/checks/<name>.tsx --project --duration 3
|
|
541
|
+
--size 128x128`. Run them whenever a doc edit touches picking or
|
|
542
|
+
collision claims - two copies of this contract have drifted before.
|
|
246
543
|
|
|
247
544
|
## Components
|
|
248
545
|
|
|
546
|
+
### Component props
|
|
547
|
+
|
|
249
548
|
| Component | Props |
|
|
250
549
|
| --- | --- |
|
|
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 `"
|
|
252
|
-
| `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
|
-
| `Group` | `position?`, `rotation?` (Euler radians, XYZ order), `quaternion?` (either, not both), `scale?` (number = uniform), `visible?`, pointer events (below), `ref?(node)` |
|
|
550
|
+
| `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, `"aces"`, `"agx"` or `"neutral"`), `exposure?` (default 1), `bloom?` (`{ threshold?, intensity?, radius? }`, the stock bloom on the resolve, reactive), `layers?` (target mask, default 1), `depth?` (`"texture"` exposes scene.depthTexture; not with samples), `samples?` (1/2/4/8 MSAA), `label?`, `ref?(scene)`, `output?(texture)`, `resolve?` (the source, `{ source, textures }`, or a function of the buffer id returning either; fixed at creation - see Color), `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) |
|
|
551
|
+
| `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), `resolve?` (as Scene's; not with `into`), `bloom?` (BloomOptions overrides the scene's, null turns it off in this view, absent follows the scene; not with `into`), `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 |
|
|
552
|
+
| `Group` | `position?`, `rotation?` (Euler radians, XYZ order), `quaternion?` (either, not both), `scale?` (number = uniform), `visible?`, the bubbling pointer events (below: down/move/up/wheel/tap from a hit descendant; a group is never the struck node, so it takes no `onPointerEnter`/`onPointerLeave` - `Lod` likewise), `ref?(node)` |
|
|
553
|
+
| `Lod` | a Group whose direct children carrying `lodSize` (a prop every node component takes: the projected size below which that child hands over, see Level of detail) are its levels, sorted by size, never by JSX position; plus `fade?` (the cross-fade band fraction, default 0); a child without `lodSize` is drawn always |
|
|
554
|
+
| `InstancedLod` | as InstancedMesh minus `material`, plus `levels` (`[{ geometry, material, size }]` nearest first, fixed at creation; instanced materials as InstancedMesh's), `castShadow?` (every level); `<Instance>` children populate it as under `InstancedMesh`, each drawing the level its own projected size picks |
|
|
254
555
|
| `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)` |
|
|
255
556
|
| `Sprite` | as Mesh minus `geometry`: a camera-facing unit quad, `scale` is its world size, rotation is ignored; pair with a `sprite()` material |
|
|
256
557
|
| `InstancedMesh` | as Mesh (an instanced `material`: a stock one with `instanced`/`instanceColors`, or a class declaring INSTANCE_MATRIX_ATTRIBUTES), plus `capacity?` (instance slots, default 64; the buffers double past it), `bounds?` (optional: instances pick by themselves and the mesh culls by their union), `label?`; a PARENT: `<Instance>` children populate it, `<Group>` children are squads; the record buffers are component-owned and freed on unmount |
|
|
257
|
-
| `Instance` | one instance of the enclosing `InstancedMesh`: transforms, `transition`, pointer events as Group, plus `style?` (the material's
|
|
258
|
-
| `RecordMesh` | as Mesh, plus `records` (
|
|
558
|
+
| `Instance` | one instance of the enclosing `InstancedMesh`: transforms, `transition`, pointer events as Group, plus `style?` (the material's style record, one value per component - `[r, g, b, a]` under `instanceColors`), `ref?(instance)`; a parent too (a `<Mesh>` under an instance rides with it) |
|
|
559
|
+
| `RecordMesh` | as Mesh, plus `records` (the per-instance records in the material's first instance layout, an ArrayBufferView; buffer capacity starts at the first value and grows on larger rewrites), `count?` (records drawn, default all), `bounds?` (local [minX..maxZ] over the population - without it the mesh never picks); the record buffers are component-owned and freed on unmount |
|
|
259
560
|
| `PerspectiveCamera` | `fov?` (vertical DEGREES, default 60), `near?`, `far?`, `position?`, `lookAt?`, `up?` - or the Scene `camera` prop, the same state (last write wins) |
|
|
260
561
|
| `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
562
|
| `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
563
|
|
|
564
|
+
### Output composition
|
|
565
|
+
|
|
263
566
|
Output composition: without `output`, `Scene` emits a minimal
|
|
264
567
|
`<texture width height>` leaf and nothing else is forwarded - anything
|
|
265
568
|
more goes through `output(texture)`, which renders in place of that leaf:
|
|
@@ -267,11 +570,15 @@ a `<d-texture>`, a leaf with blendMode/fit/pointer/layout props, or a
|
|
|
267
570
|
post-effect chain (`createShaderTarget` sampling the id with a
|
|
268
571
|
covering-triangle pass; created in the callback it disposes with the
|
|
269
572
|
Scene). Return null for no leaf at all and compose `scene.texture`
|
|
270
|
-
elsewhere. Called once, untracked, inside the scene context.
|
|
573
|
+
elsewhere. Called once, untracked, inside the scene context. `output`
|
|
574
|
+
composes the DISPLAYED texture, the resolve's output: an LDR effect
|
|
575
|
+
lives here; one that needs the scene's radiance is a `resolve` (Color). Scene
|
|
271
576
|
`width`/`height` are target pixels and the leaf's own width/height are
|
|
272
577
|
layout, so render and display size separate - render at 2x and display
|
|
273
578
|
smaller for supersampling.
|
|
274
579
|
|
|
580
|
+
### Fill and fixed sizes
|
|
581
|
+
|
|
275
582
|
Fill (the default): omit `width`/`height` and the built-in leaf is laid
|
|
276
583
|
out at 100% of its parent's box (give it a sized parent, as on the web)
|
|
277
584
|
while the target follows the leaf's on-screen size in DEVICE pixels -
|
|
@@ -302,6 +609,8 @@ everything: the `pointer` feed listens at the scene's root, so
|
|
|
302
609
|
handlers and the feed alike (a detached leaf gives its feed a `layout`,
|
|
303
610
|
it has no layout box to normalize by).
|
|
304
611
|
|
|
612
|
+
### Input
|
|
613
|
+
|
|
305
614
|
Input, the rule (ARCHITECTURE.md): a camera control consumes a
|
|
306
615
|
device-free abstraction and never handles events or reads a device
|
|
307
616
|
itself. The controls expose `axes` (core's createAxes contract - rates
|
|
@@ -334,23 +643,46 @@ sticks move the CAMERA where a drag moves the content, so the presets
|
|
|
334
643
|
bind them to `rotate`/`pan` through `invert()`; `look` is the exception,
|
|
335
644
|
a drag and a stick both turn the eye.
|
|
336
645
|
|
|
646
|
+
### Orbit camera
|
|
647
|
+
|
|
337
648
|
Camera control: `createOrbitCamera(scene, { target?, azimuth?, elevation?,
|
|
338
649
|
distance?, min/maxDistance?, min/maxElevation?, orbitSpeed?, rotateSpeed?,
|
|
339
|
-
zoomSpeed?, panSpeed?,
|
|
340
|
-
azimuth/elevation/distance around a target with optional auto-orbit. The
|
|
341
|
-
first argument is anything with the scene's `setCamera
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
650
|
+
zoomSpeed?, panSpeed?, damping?, clampPose?, zoomAnchor?, rotateAnchor? })`
|
|
651
|
+
- azimuth/elevation/distance around a target with optional auto-orbit. The
|
|
652
|
+
first argument is anything with the scene's `setCamera`, `camera()` (the
|
|
653
|
+
fov maps pan travel to world) and `size()` (the aspect `fit` frames
|
|
654
|
+
against): a Scene, or a View to drive one view's camera independently.
|
|
655
|
+
Its `axes` are `rotate` (a delta of one element height sweeps one full
|
|
656
|
+
turn, Three's OrbitControls convention, so a drag feels the same on a
|
|
657
|
+
phone and a 4k window; a rate turns at 0.5 turn/s at full deflection),
|
|
658
|
+
`zoom` (octaves: a delta of 1 halves the distance, a rate of 1 halves it
|
|
659
|
+
per second) and `pan` (the target slides so the scene tracks the fingers
|
|
660
|
+
1:1 at the target's depth, three.js DOLLY_PAN, weighted by `panSpeed`). A
|
|
661
|
+
delta bracketed by a gesture (a drag, a pinch, two fingers) applies at
|
|
662
|
+
once, the content staying under the fingers; an unbracketed one (a wheel
|
|
663
|
+
notch, a key step) is an impulse and glides in - `damping` scales that
|
|
664
|
+
settle time, 0 applies it at once - notches compounding on the pending
|
|
665
|
+
value so a fast scroll is one push, the 2d camera's rule and the same
|
|
666
|
+
knob there. The verbs: `rotateBy(azimuth, elevation)` radians,
|
|
667
|
+
`zoomBy(factor, anchor?)` (factor > 1 in, as the 2d camera's zoomAt),
|
|
668
|
+
`panBy(right, up)` world units, `setPivot(point)`, `set(pose)`; every one
|
|
669
|
+
pushes the pose at once. Two commanded moves ease instead, inside
|
|
670
|
+
update(dt): `glideTo({ azimuth?, elevation?, distance?, target? })`, and
|
|
671
|
+
`fit(bounds, { glide? })`, which frames a `[minX, minY, minZ, maxX, maxY,
|
|
672
|
+
maxZ]` box (geometryBounds, a model's `bounds`) - target to its centre,
|
|
673
|
+
distance to where the bounding sphere fills the tighter of the vertical
|
|
674
|
+
and horizontal fov at the target's size, azimuth and elevation kept,
|
|
675
|
+
clamps applied; a snap unless `glide`, so a park-then-snapshot repeats
|
|
676
|
+
(under ortho only the target moves). Any input drops a glide (a finger
|
|
677
|
+
landing holds the view), and a `set()` that writes a pose field snaps
|
|
678
|
+
and drops any motion; `set({})` and `set({ orbiting })` leave it running.
|
|
679
|
+
Every write - input, verb, glide frame, set() - goes through the range
|
|
680
|
+
clamps and then `clampPose(pose)`, which sees the whole pose and returns
|
|
681
|
+
the fields to change: bound where a pan may put the target, or hold the
|
|
682
|
+
eye above a floor (an elevation floor that depends on the distance, which
|
|
683
|
+
a fixed `minElevation` cannot say); a glide's goal is clamped when set
|
|
684
|
+
and its frames as they land, so it never shows an illegal pose.
|
|
685
|
+
Zoom aims at the target unless `zoomAnchor(focal, {eye, target})`
|
|
354
686
|
maps the gesture's focal point - a FRACTION of the element, [0..1, 0..1] -
|
|
355
687
|
to a world point (ground hit, target-depth plane, ...): then that point
|
|
356
688
|
stays pinned under the pointer and the target slides toward it; only the
|
|
@@ -363,13 +695,14 @@ called when a rotate gesture begins, its point is projected onto the
|
|
|
363
695
|
view axis and re-seats the pivot without moving the picture, so a drag
|
|
364
696
|
after an anchored zoom orbits what the camera looks at, not wherever the
|
|
365
697
|
zoom left the target. Call `orbit.update(dt)` from your onFrame to
|
|
366
|
-
integrate the rates
|
|
367
|
-
use its return - true when the pose changed since the previous
|
|
368
|
-
nudges included - to gate per-frame dependents like reprojecting
|
|
369
|
-
overlays. `orbiting()` (the auto-orbit switch) and `active()` (the
|
|
370
|
-
frame-loop gate: orbiting with a non-zero rate,
|
|
371
|
-
|
|
372
|
-
|
|
698
|
+
integrate the rates, the auto-orbit and any glide (no frame loop of its
|
|
699
|
+
own), and use its return - true when the pose changed since the previous
|
|
700
|
+
update, nudges included - to gate per-frame dependents like reprojecting
|
|
701
|
+
HUD overlays. `orbiting()` (the auto-orbit switch) and `active()` (the
|
|
702
|
+
frame-loop gate: orbiting with a non-zero rate, a glide or damped notch
|
|
703
|
+
in flight, or any rate driving - the predicate every camera control
|
|
704
|
+
shares) are reactive (HUD-safe); the pose is plain state via
|
|
705
|
+
`pose()`/`set()` (also the debug-command shape). It
|
|
373
706
|
drives position and target only; fov/near/far stay on scene.setCamera
|
|
374
707
|
(or the Scene `camera` prop). The auto-orbit pauses while a gesture is
|
|
375
708
|
open.
|
|
@@ -383,10 +716,15 @@ reconnects) and nothing else, and runs a frame loop only while
|
|
|
383
716
|
`active()`, so a camera moved by drags alone keeps the app demand-driven
|
|
384
717
|
idle. The pose props are initial values: runtime pose changes (and the
|
|
385
718
|
debug-command hookup) go through `ref`'s handle, whose set() and verbs
|
|
386
|
-
push the pose. Every other prop is live -
|
|
387
|
-
getter and read where it applies, never
|
|
388
|
-
|
|
389
|
-
|
|
719
|
+
push the pose and whose glideTo/fit ease it. Every other prop is live -
|
|
720
|
+
forwarded to the control as a getter and read where it applies, never
|
|
721
|
+
snapshotted - so clamps, rates, `damping` and anchors follow their props
|
|
722
|
+
without a remount, and a clamp change (`clampPose` included) re-clamps
|
|
723
|
+
the pose at once. A `fit` from `ref` at mount reads the scene's size of
|
|
724
|
+
that moment: a fill-mode `<Scene>` has its creation size until its first
|
|
725
|
+
layout, so fit after the first frame (or hand a fixed-size scene).
|
|
726
|
+
|
|
727
|
+
### First-person camera
|
|
390
728
|
|
|
391
729
|
First-person control: `createFirstPersonCamera(scene, { position?, yaw?,
|
|
392
730
|
pitch?, min/maxPitch?, moveSpeed?, lookSpeed?, fly?, clampPosition? })` -
|
|
@@ -403,7 +741,12 @@ length so they walk no faster) and `rise` (world up at `moveSpeed`, fly
|
|
|
403
741
|
mode only). Walking (the default) flattens the heading onto the ground
|
|
404
742
|
plane at fixed height; `fly` moves along the view. The verbs:
|
|
405
743
|
`lookBy(yaw, pitch)` radians, `moveBy(right, forward, up?)` in the
|
|
406
|
-
walker's frame, `set(pose)
|
|
744
|
+
walker's frame, `set(pose)`, and `glideTo({ position?, yaw?, pitch? })`,
|
|
745
|
+
which eases there inside update(dt) (yaw to the number given, not the
|
|
746
|
+
shortest turn), dropped by any input and by a set() of a pose field -
|
|
747
|
+
the orbit camera's glide, without its damping: this control's unbracketed
|
|
748
|
+
delta is mouse motion under pointer lock, and that is never eased. Every
|
|
749
|
+
option but the initial pose is read
|
|
407
750
|
where it applies (`fly` per step, `clampPosition` per move, the rates and
|
|
408
751
|
pitch clamps per input), so a field changed on the options object takes
|
|
409
752
|
effect on the next move: walk and fly are one control. The control NEVER
|
|
@@ -413,8 +756,10 @@ collision of its own: `clampPosition(next, current)` is the whole hook -
|
|
|
413
756
|
bounds, a floor height, or `moveAndSlide` over `next - current` against
|
|
414
757
|
the collision layer (see `examples/collision.tsx`, whose `jump` is one
|
|
415
758
|
more action on the same map: Space or the pad's south button, read by
|
|
416
|
-
name)
|
|
417
|
-
|
|
759
|
+
name); a glide consults it every frame as a walk does every step. Call
|
|
760
|
+
`update(dt)` from onFrame; `active()` is reactive - a rate driving (a
|
|
761
|
+
held key, a deflected stick), or a glide in flight - and gates the loop.
|
|
762
|
+
The map
|
|
418
763
|
tracks held keys from the down/up pair through `input.handlers`; spread
|
|
419
764
|
its `onBlur` too, since the up never arrives once focus has left.
|
|
420
765
|
|
|
@@ -426,10 +771,15 @@ its `pointer` feed. The frame loop runs only while `active()`. Every prop
|
|
|
426
771
|
but the initial pose is live: `fly={flying()}` toggles walk/fly on the
|
|
427
772
|
running control (pose carries over, no remount, and `clampPosition` may
|
|
428
773
|
swap with it), `moveSpeed`/`lookSpeed` follow their props, and a pitch
|
|
429
|
-
clamp change re-clamps at once.
|
|
774
|
+
clamp change re-clamps at once. Like the orbit control it drives
|
|
775
|
+
position and yaw/pitch only: fov/near/far stay on scene.setCamera (or
|
|
776
|
+
the Scene `camera` prop), so a walker in a scene bigger than the default
|
|
777
|
+
`far` of 100 sets one there. checks/orbit-check.ts and
|
|
430
778
|
checks/first-person-check.ts pin both controls headless (they import
|
|
431
779
|
`@solidrt/core/input` only).
|
|
432
780
|
|
|
781
|
+
### Overlay projection
|
|
782
|
+
|
|
433
783
|
Overlay projection: `scene.project(point)` maps a world point to scene
|
|
434
784
|
pixels (top-left origin, y down - the output texture's own space; `w` is
|
|
435
785
|
the camera-forward distance in world units under either projection) and
|
|
@@ -444,6 +794,8 @@ unproject each move). `scene.viewProj(out?)` copies the view-projection
|
|
|
444
794
|
matrix for batch work. Never rebuild the camera matrices by hand for a
|
|
445
795
|
HUD.
|
|
446
796
|
|
|
797
|
+
### Picking
|
|
798
|
+
|
|
447
799
|
Picking: `scene.pick(x, y)` is project()'s inverse - the camera ray
|
|
448
800
|
through a scene pixel, returning `Hit[]` (`{ mesh, distance, point }`,
|
|
449
801
|
world units, nearest first; every hit along the ray, not just the front
|
|
@@ -461,19 +813,25 @@ The index and the narrowphase live in the spatial core: every attached
|
|
|
461
813
|
mesh's local box is a leaf in a dynamic AABB tree the flush refits from
|
|
462
814
|
the fresh world matrices (O(moved) per frame, a query O(log meshes)), and
|
|
463
815
|
an ordinary mesh is then tested per triangle against its geometry's
|
|
464
|
-
shape (one CPU copy per distinct geometry, created with
|
|
816
|
+
shape (one CPU copy of the positions per distinct geometry, created with
|
|
817
|
+
its GPU buffers and rewritten by `updateVertices`; the mesh's box comes
|
|
818
|
+
from the same copy, so a deforming mesh is found where it is drawn),
|
|
465
819
|
so hits carry `face`, `uv` and a world-space `normal` facing the ray, and
|
|
466
820
|
a ray through a knot's hole misses. A large geometry's triangles are
|
|
467
821
|
BVH-indexed too - built by the first ray that reaches the shape, log-cost
|
|
468
822
|
after - so raycasting a merged static scene stays cheap (see the batching
|
|
469
823
|
advice). An instanced mesh is box-only (its
|
|
470
|
-
explicit population bounds; records are opaque)
|
|
824
|
+
explicit population bounds; records are opaque), and so is a mesh whose
|
|
825
|
+
geometry is not a triangle list (lines, points, strips have no triangle
|
|
826
|
+
narrowphase): it is tested by the
|
|
471
827
|
box's twelve triangles, so its hits carry the struck face's `normal` and
|
|
472
828
|
no `face`/`uv` - and a ray from inside it meets the far side, the
|
|
473
829
|
surface contract overlap/sweep share. Both methods
|
|
474
830
|
flush pending writes first (the lookAt/project immediacy contract), and
|
|
475
831
|
both skip invisible meshes.
|
|
476
832
|
|
|
833
|
+
### Collision
|
|
834
|
+
|
|
477
835
|
Collision: `scene.overlap(volume, opts?)` and `scene.sweep(volume,
|
|
478
836
|
motion, opts?)` are the same index's other two questions - what a volume
|
|
479
837
|
touches, and where a moving one first touches: Unity's
|
|
@@ -531,6 +889,8 @@ one overlap per move. Deliberately absent and additive when asked: a
|
|
|
531
889
|
step offset (Unity's stepOffset; Godot has none either) and a cylinder
|
|
532
890
|
volume (Godot only).
|
|
533
891
|
|
|
892
|
+
### Pointer events
|
|
893
|
+
|
|
534
894
|
Pointer events (scene-pointer.ts): the element event model one tree
|
|
535
895
|
deeper, with the SCENE as the root of the walk (under a `<View3d>` leaf:
|
|
536
896
|
the view). `onPointerDown/Move/Up/Enter/Leave/Wheel/Tap` are plain
|
|
@@ -569,7 +929,9 @@ pointerType, button, modifiers) plus `x`/`y` in scene pixels
|
|
|
569
929
|
(screenRay's input - a drag plane is one intersection away). Root
|
|
570
930
|
listeners all run, in registration order (the root is the last stop,
|
|
571
931
|
nothing is left to claim). Wiring: the built-in `<Scene>` leaf carries
|
|
572
|
-
`scene.handlers` automatically (opt out: `events={false}
|
|
932
|
+
`scene.handlers` automatically (opt out: `events={false}`, which throws
|
|
933
|
+
at mount together with a `pointer` feed - the feed would listen at a root
|
|
934
|
+
no event reaches; the same on `<View3d>`); an `output`
|
|
573
935
|
leaf or imperative composition spreads `{...scene.handlers}` onto the
|
|
574
936
|
element showing the texture. `scene.handlers` assumes that leaf is LAID
|
|
575
937
|
OUT at the target size - true for the built-in leaf and a d-texture at
|
|
@@ -580,21 +942,44 @@ leaf laid out at a different size (the supersampling pattern) uses
|
|
|
580
942
|
checks/dispatch-check.ts pins the walk, claiming, capture, hover, wheel
|
|
581
943
|
and tap rules headless; examples/pick.tsx is the live guard.
|
|
582
944
|
|
|
945
|
+
### Geometry generators
|
|
946
|
+
|
|
583
947
|
Geometry generators take ONE options object, every field optional with
|
|
584
948
|
a default, named as Three names them: `box({ width, height, depth })`
|
|
585
949
|
(1x1x1); `plane({ width, height })`, `circle({ radius, segments })` and
|
|
586
950
|
`ring({ innerRadius, outerRadius, segments })` (XY, facing +z - rotate
|
|
587
951
|
`[-Math.PI/2, 0, 0]` for a floor); `sphere({ radius, widthSegments,
|
|
588
952
|
heightSegments })`; `cylinder({ radiusTop, radiusBottom, height,
|
|
589
|
-
radialSegments })` (y axis, capped; unequal radii taper
|
|
590
|
-
`cone({ radius, height, radialSegments })`;
|
|
953
|
+
radialSegments, heightSegments })` (y axis, capped; unequal radii taper
|
|
954
|
+
it) and `cone({ radius, height, radialSegments, heightSegments })`;
|
|
955
|
+
`capsule({ radius, height, capSegments, radialSegments, heightSegments
|
|
956
|
+
})` (y axis, `height` the TOTAL extent like
|
|
957
|
+
cylinder's and Godot's/Unity's where Three's is the middle section
|
|
958
|
+
only, so `height: 2 * radius` is a sphere and less throws; its collision
|
|
959
|
+
volume is `{ a: [0, -(height / 2 - radius), 0], b: [0, height / 2 -
|
|
960
|
+
radius, 0], radius }`); `torus({ radius, tube,
|
|
591
961
|
radialSegments, tubularSegments })` (lying flat, hole on the y axis) and
|
|
592
962
|
`torusKnot({ radius, tube, tubularSegments, radialSegments, p, q })`
|
|
593
963
|
(standing y-up) - both oriented for the y-up world, unlike Three's z-up.
|
|
594
964
|
No positional form: `box()` is the default cube, `box({ label: "rock" })`
|
|
595
|
-
names it.
|
|
965
|
+
names it. The polyhedra: `tetrahedron`, `octahedron`, `icosahedron` and
|
|
966
|
+
`dodecahedron` (`{ radius, detail }`, radius 0.5 like sphere where
|
|
967
|
+
Three's is 1) and the generic `polyhedron(vertices, indices, { radius,
|
|
968
|
+
detail })` over Three's data form (flat xyz list, CCW triangle list, the
|
|
969
|
+
data first like `box3Helper`), each solid projected onto its
|
|
970
|
+
circumsphere. `detail` splits every edge `detail + 1` ways first, so
|
|
971
|
+
`detail: 0` is the flat-shaded solid (face normals) and anything above a
|
|
972
|
+
sphere of uniform triangles with radial normals: `icosahedron({ detail:
|
|
973
|
+
3 })` is the icosphere, no pole pinch. Non-indexed like Three, every
|
|
974
|
+
triangle owning its three vertices; `edgesGeometry` welds by position,
|
|
975
|
+
so `edgesGeometry(dodecahedron())` is the twelve pentagons (the fan
|
|
976
|
+
diagonals are coplanar and drop out) where `wireframeGeometry` shows the
|
|
977
|
+
triangulation. UVs are the spherical map with Three's seam patch,
|
|
978
|
+
stretched toward the poles: the textured sphere stays `sphere()`, the
|
|
979
|
+
polyhedra are for flat-shaded, low-poly and procedurally shaded looks
|
|
980
|
+
(examples/polyhedra.tsx). Every options object (the profile kit's `extrude`/`lathe`/
|
|
596
981
|
`sweep`/`tube` too) also takes `label` and `layout` - `layout` makes the
|
|
597
|
-
generator emit that layout in one pass (
|
|
982
|
+
generator emit that layout in one pass (base channels written, the
|
|
598
983
|
extra slots zero), so `box({ layout: "colored" })` then
|
|
599
984
|
`fillColors(g, fill)` builds colored geometry without the
|
|
600
985
|
generate-then-repack copy; the result is byte-identical to
|
|
@@ -603,7 +988,8 @@ the tail every generator ends in, for your own generators.
|
|
|
603
988
|
`withAttribute(geometry, attr, fill, label?)` derives a copy of any
|
|
604
989
|
geometry (generator or hand-built) with one more channel after its
|
|
605
990
|
current layout; the source is untouched. `withColors(geometry, fill,
|
|
606
|
-
label?)` is the aColor
|
|
991
|
+
label?)` is the aColor float32x4 case, keeping the "colored" preset
|
|
992
|
+
name (`withAttribute` with `unorm8x4` is the 4-byte color).
|
|
607
993
|
`fillAttribute(geometry, name, fill, first?, count?)` is the in-place
|
|
608
994
|
primitive under both: overwrites one channel the geometry's layout
|
|
609
995
|
already carries (withAttribute ADDS one), reading pos/normal/uv from the
|
|
@@ -611,6 +997,8 @@ buffer itself - so a builder that bakes transforms while writing hands
|
|
|
611
997
|
the baker world-space vertices. `fill` indexes relative to `first`.
|
|
612
998
|
`fillColors(geometry, fill, first?, count?)` is its aColor spelling.
|
|
613
999
|
|
|
1000
|
+
### Geometry as data
|
|
1001
|
+
|
|
614
1002
|
Geometry as data: `transformGeometry(geometry, { position?, rotation?,
|
|
615
1003
|
quaternion?, scale? }, label?)` bakes a placement into a copy (the
|
|
616
1004
|
setTransform shape: Euler XYZ radians or a quaternion, number = uniform
|
|
@@ -626,13 +1014,100 @@ does not tax picking: a merged geometry's raycast narrowphase runs
|
|
|
626
1014
|
through its triangle BVH (built on the first ray), so merge for draw
|
|
627
1015
|
count without giving up ground queries. Both are
|
|
628
1016
|
pure array math (Three's `applyMatrix4` + `mergeGeometries`), no GPU
|
|
629
|
-
call, and the source geometries are untouched. `
|
|
1017
|
+
call, and the source geometries are untouched. `wireframeGeometry(geometry,
|
|
1018
|
+
label?)` and `edgesGeometry(geometry, thresholdAngle?, label?)` (Three's
|
|
1019
|
+
WireframeGeometry/EdgesGeometry) build `"lines"` geometry over a triangle
|
|
1020
|
+
geometry's OWN vertex array and layout, shared by reference: every edge
|
|
1021
|
+
once for the wireframe, for the edges only those where two faces meet at
|
|
1022
|
+
the threshold (degrees, default 1) or more plus the open borders. Edges
|
|
1023
|
+
are matched by position, so a uv seam or a per-face normal split draws
|
|
1024
|
+
one line; a generated round shape is faceted and keeps its facet lines
|
|
1025
|
+
until the threshold passes 360 / radialSegments. The wireframe of a
|
|
1026
|
+
"skinned" part draws in its pose under `unlit({ skinned: true })` with
|
|
1027
|
+
the mesh's palette, and the swap onto a live mesh is
|
|
1028
|
+
`setGeometry` + `setMaterial` (`examples/wireframe.tsx`). Lines are one
|
|
1029
|
+
pixel wide on GL ES; thick lines are quad geometry. The debug helpers are
|
|
1030
|
+
`"lines"` builders too, Three's helper classes camelCased:
|
|
1031
|
+
`gridHelper({ size?, divisions?, color?, centerColor? })` is the XZ grid
|
|
1032
|
+
at y 0 (defaults 10 by 10, Three's grays; the two lines through the
|
|
1033
|
+
origin take `centerColor` and exist only for an even `divisions`),
|
|
1034
|
+
`axesHelper({ size? })` the X red, Y green, Z blue triad from the origin,
|
|
1035
|
+
`box3Helper(bounds, options?)` the twelve edges of a
|
|
1036
|
+
`[minX, minY, minZ, maxX, maxY, maxZ]` box in local space, and
|
|
1037
|
+
`planeHelper({ size? })` a square outline with its diagonals and a unit
|
|
1038
|
+
normal tick in the XY plane facing +z, placed like `plane()`, and
|
|
1039
|
+
`arrowHelper({ length?, headLength?, headWidth? })` an arrow along +y
|
|
1040
|
+
with a pyramid-outline head, aimed by the node's rotation
|
|
1041
|
+
(`quatFromTo(out, [0, 1, 0], direction)`), and `capsuleHelper(volume,
|
|
1042
|
+
{ segments? })` the outline of a `{ a, b, radius }` collision capsule
|
|
1043
|
+
(rings at both ends, four lines, two half circles per cap; `a == b`
|
|
1044
|
+
draws a sphere's three great circles) in the volume's own space, the
|
|
1045
|
+
gizmo Unity and Godot draw for a capsule collider. Godot and
|
|
1046
|
+
Unity keep these in the editor and Three makes them scene objects; here
|
|
1047
|
+
they are plain geometry a node places and a material draws. The grid and
|
|
1048
|
+
the triad are "colored" layout (sRGB colors in, premultiplied linear
|
|
1049
|
+
aColor out, the material contract) drawn by `unlit({ vertexColors:
|
|
1050
|
+
true })`; a material that reads no aColor draws them in its own color.
|
|
1051
|
+
The box, the plane, the arrow and the capsule are base layout. For a bounds box that moves
|
|
1052
|
+
every frame, draw `edgesGeometry(box())` on a node whose position is the
|
|
1053
|
+
box center and whose scale is its size and update the transform (Three's
|
|
1054
|
+
Box3Helper does exactly that) instead of rebuilding.
|
|
1055
|
+
`examples/wireframe.tsx` draws a grid, the triad and the rover's bounds
|
|
1056
|
+
box. `geometryBounds(geometry)`
|
|
630
1057
|
returns the cached local AABB `[minX, minY, minZ, maxX, maxY, maxZ]`, and
|
|
631
1058
|
`rayBoxDistance(ox, oy, oz, dx, dy, dz, minX, .., maxZ)` is the picking
|
|
632
1059
|
slab test (entry t >= 0 in units of the direction's length, 0 from
|
|
633
1060
|
inside, -1 for a miss) - for ray-testing boxes you keep yourself
|
|
634
1061
|
(triggers, collision volumes) without meshes you do not want to draw.
|
|
635
1062
|
|
|
1063
|
+
Normals as data: two ops, split by whether the vertex count survives.
|
|
1064
|
+
`computeVertexNormals(geometry)` recomputes a triangle geometry's
|
|
1065
|
+
normals IN PLACE from the faces that name each vertex by index (Three's
|
|
1066
|
+
`computeVertexNormals`, Unity's `RecalculateNormals`, Godot's
|
|
1067
|
+
`generate_normals`, all in place), each face weighted by its corner
|
|
1068
|
+
angle so the result does not depend on how a surface was triangulated.
|
|
1069
|
+
The math runs in the core in one call over the vertex floats (so aNormal
|
|
1070
|
+
must be float32x3; a packed normal is an authoring format, pack after
|
|
1071
|
+
withNormals), and it returns the stream carrying aNormal, which
|
|
1072
|
+
`updateVertices` re-uploads: the CPU deformation loop is
|
|
1073
|
+
`fillAttribute(g, "aPos", ...)`, `computeVertexNormals(g)`,
|
|
1074
|
+
`updateVertices(g)`. That loop is for data that genuinely changes on the
|
|
1075
|
+
CPU - a streamed cloud, a cloth solved in JS, an editor edit. A
|
|
1076
|
+
per-frame ripple, sway or wave belongs in a vertex shader: displace and
|
|
1077
|
+
derive the normal in the vertex stage (`shaderMaterial`), and no
|
|
1078
|
+
per-vertex work touches the CPU at all; the fillAttribute callback alone
|
|
1079
|
+
is an interpreter call per vertex per frame. A vertex shared across faces
|
|
1080
|
+
shades smooth and a split one per face, so a merged or deformed
|
|
1081
|
+
generator geometry comes out right; a uv seam's copies each see one side
|
|
1082
|
+
and the seam shows (Three's artifact too). `withNormals(
|
|
1083
|
+
geometry, creaseAngle = 60, label?)` is the authoring path and a COPY:
|
|
1084
|
+
faces are matched by position, so a triangle soup, a hand-written face
|
|
1085
|
+
list, a merged result or a geometry with no aNormal channel at all (one
|
|
1086
|
+
is added, float32x3) all work and a seam shades smooth across it; 0 is
|
|
1087
|
+
flat shading, 180 smooths everything, 60 (Three's `toCreasedNormals`
|
|
1088
|
+
and Unity's import smoothing angle) keeps hard edges hard. It is
|
|
1089
|
+
`toNonIndexed` + per-corner normals + `mergeVertices`, so the result is
|
|
1090
|
+
indexed and split only where a crease or a seam needs it (a box stays
|
|
1091
|
+
24 vertices under 90 degrees). Both pieces are exported:
|
|
1092
|
+
`toNonIndexed(geometry, label?)` gives every index its own vertex
|
|
1093
|
+
(identity indices; per-face colors via fillAttribute, flat shading via
|
|
1094
|
+
computeVertexNormals), `mergeVertices(geometry, tolerance = 1e-4,
|
|
1095
|
+
label?)` welds vertices equal in every channel back together, the exact
|
|
1096
|
+
inverse (a normal or uv that differs keeps them apart). Neither takes a
|
|
1097
|
+
morphed geometry: split or weld before `withMorphTargets`.
|
|
1098
|
+
`normalsHelper(geometry, { size?, color? })` draws one line per vertex
|
|
1099
|
+
along its normal (Three's VertexNormalsHelper as a static builder,
|
|
1100
|
+
"colored" layout, red by default, drawn by `unlit({ vertexColors: true
|
|
1101
|
+
})` under the mesh's node), the way to look at any of this;
|
|
1102
|
+
`examples/normals.tsx` shows the three crease angles on one soup, a
|
|
1103
|
+
rippling sphere on the per-frame loop, and the helper refreshed in place
|
|
1104
|
+
with the same loop. Three's `center()` is `transformGeometry(g, {
|
|
1105
|
+
position: [-(b[0] + b[3]) / 2, -(b[1] + b[4]) / 2, -(b[2] + b[5]) / 2]
|
|
1106
|
+
})` over `geometryBounds`; there is no bounding sphere op, the box is
|
|
1107
|
+
what every query and the LOD read.
|
|
1108
|
+
|
|
1109
|
+
### Profile kit
|
|
1110
|
+
|
|
636
1111
|
Profile kit (2D outlines to solids, real texture UVs): a `Profile` is a
|
|
637
1112
|
closed XY polygon, bare `[x, y]` points crease, `{ p, smooth }` points
|
|
638
1113
|
share an averaged normal - `fillet(points, radius, segs?)` and
|
|
@@ -653,92 +1128,137 @@ Closed loops are NOT supported yet - overlap the ends by a segment to
|
|
|
653
1128
|
fake one. `tube(path, { radius, radialSegments })` is the round-profile
|
|
654
1129
|
shorthand (wire, rope, pipe), and `pathFrames(path)` exports the
|
|
655
1130
|
per-segment frames (tangents, cross-section axes, arc lengths) for
|
|
656
|
-
custom work along a path. `
|
|
657
|
-
like circle); `triangulate(points)` is the ear-clipping core (fan
|
|
1131
|
+
custom work along a path. `polygon(profile, options?)` fills one flat (facing
|
|
1132
|
+
+z, like circle); `triangulate(points)` is the ear-clipping core (fan
|
|
658
1133
|
fallback, never drops a cap), exported for custom flat work. These pick
|
|
659
1134
|
uint16/uint32 indices by vertex count automatically.
|
|
660
1135
|
|
|
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
|
-
|
|
1136
|
+
### Materials
|
|
1137
|
+
|
|
1138
|
+
#### unlit
|
|
1139
|
+
|
|
1140
|
+
`unlit({ color?, map?, vertexColors?, transparent?, blend?, cull?, alphaTest?, fog? })` -
|
|
1141
|
+
straight `[r, g, b, a?]` 0..1 sRGB (decoded to linear light, see Color
|
|
1142
|
+
below), premultiplied internally; `vertexColors: true` multiplies by the
|
|
1143
|
+
"colored" layout's aColor (the geometry must carry it: withColors, a
|
|
1144
|
+
gridHelper or axesHelper); `blend` the factors of a transparent draw,
|
|
1145
|
+
"alpha" when absent, `"add"` for a glow (any mode but "none" implies
|
|
1146
|
+
`transparent` unless told `transparent: false`, the shaderMaterial
|
|
1147
|
+
rule); `cull` and
|
|
1148
|
+
`alphaTest` as on phong (a mapped cutout casts its cutout); `fog: false`
|
|
1149
|
+
opts out of the scene's fog (all four library materials take it).
|
|
1150
|
+
|
|
1151
|
+
#### sprite
|
|
1152
|
+
|
|
1153
|
+
`sprite({ color?, map?, transparent?, blend?, billboard? })` - unlit on a quad
|
|
1154
|
+
that turns to face the camera IN THE VERTEX STAGE (off the shared
|
|
1155
|
+
uCamRight/uCamUp, or uCamPos for `billboard: "fixed-y"`, which yaws
|
|
1156
|
+
only and stays upright on world y - Godot's BILLBOARD_FIXED_Y, the
|
|
1157
|
+
tree/character sprite; the default `"full"` is Three's Sprite, flat to
|
|
1158
|
+
the screen). No per-frame JS however many sprites. `transparent`
|
|
1159
|
+
defaults to TRUE here (cutouts; Three's SpriteMaterial default), cull is
|
|
1160
|
+
off. Draw with `createSprite(material)` / `<Sprite>`: a Mesh over a
|
|
1161
|
+
shared unit plane, no geometry argument, `scale` = world size, rotation
|
|
1162
|
+
ignored. Picks by a unit box around its center (its reach at any
|
|
1163
|
+
facing), so hits carry no normal/face/uv. `examples/sprites.tsx`.
|
|
1164
|
+
|
|
1165
|
+
#### shaderMaterial
|
|
1166
|
+
|
|
1167
|
+
`shaderMaterial({ vertex, fragment, params?, textures?, depth?,
|
|
1168
|
+
depthWrite?, blend?, cull?, label? })` - your own GLSL, the
|
|
1169
|
+
custom-look escape hatch. The STANDARD UNIFORM SET: the vertex stage
|
|
1170
|
+
MUST declare and use `uniform mat4 uModel` (the mesh's world matrix,
|
|
1171
|
+
per entry) and `uniform mat4 uViewProj` (the camera, shared
|
|
1172
|
+
target-level params) - transform with
|
|
1173
|
+
`uViewProj * uModel * vec4(aPos, 1.0)`; a source missing either throws
|
|
1174
|
+
at shaderMaterial() creation. The rest is opt-in by declare-and-use:
|
|
1175
|
+
`uniform vec3 uCamPos` (the camera's world position, shared and written
|
|
1176
|
+
with uViewProj - the specular/fresnel view vector is
|
|
1177
|
+
`normalize(uCamPos - worldPos)`), `uniform vec3 uCamRight` / `uCamUp`
|
|
1178
|
+
(the camera's world-space view axes, shared likewise - a billboard is
|
|
1179
|
+
`center + uCamRight * x + uCamUp * y`; do NOT rebuild them from
|
|
1180
|
+
uViewProj rows, that carries the clip flip), `uniform mat4
|
|
1181
|
+
uInvViewProj` (the camera's inverse view-projection, shared likewise -
|
|
1182
|
+
a clip position back to world, the world-space ray through a pixel
|
|
1183
|
+
without knowing the projection) and `uniform mat4 uNormal` (the world
|
|
1184
|
+
inverse-transpose, written beside uModel for this material's meshes;
|
|
1185
|
+
take `mat3(uNormal)` - correct under non-uniform scale, where
|
|
1186
|
+
mat3(uModel) bends normals off the surface). Attributes come from the
|
|
1187
|
+
geometry's layout by name; the ones the linked program actually reads
|
|
1188
|
+
(engine reflection, instance attributes excluded) must all be in the
|
|
1189
|
+
mesh's geometry layout or add() throws - so a used `in vec4 aColor`
|
|
1190
|
+
needs `withColors()` geometry and a custom channel needs
|
|
1191
|
+
`withAttribute()`. One program per class, one pipeline per layout met. Sources without `#version` get the standard
|
|
1192
|
+
pipeline preamble. App-driven uniforms beyond the standard set: seed
|
|
1193
|
+
via `params`, then write per mesh with
|
|
1194
|
+
`setMeshParams(mesh, { name: value })` (validated names; values persist
|
|
1195
|
+
across entry rebuilds; frame-rate-safe like setTransform) or declaratively
|
|
1196
|
+
with the `Mesh` `params` prop (same merge semantics - a key that
|
|
1197
|
+
disappears from the object keeps its old value; for per-frame values
|
|
1198
|
+
prefer `ref` + setMeshParams from onFrame, the setTransform split).
|
|
1199
|
+
Scene-wide values (a clock, a sun direction, fog) go through
|
|
1200
|
+
`scene.setParams({ uTime })` instead - one write for every mesh.
|
|
1201
|
+
|
|
1202
|
+
#### shaderMaterialClass
|
|
1203
|
+
|
|
1204
|
+
`shaderMaterialClass({ vertex, fragment, ...pipeline state })` - the
|
|
1205
|
+
class/instance split for your own GLSL: compiles once, and
|
|
1206
|
+
`cls.instance({ params?, textures? })` returns a Material sharing that
|
|
1207
|
+
pipeline with its own values. `dispose()` lives on the class alone.
|
|
1208
|
+
`shaderMaterial(opts)` is exactly a class with one instance (its
|
|
1209
|
+
`dispose` forwards to the class).
|
|
1210
|
+
|
|
1211
|
+
#### Instanced materials
|
|
1212
|
+
|
|
1213
|
+
`instanceBuffers: [{ attributes: [{ name, format }] }]` (one layout per
|
|
1214
|
+
per-instance buffer, instance-step by definition and tightly packed in
|
|
1215
|
+
list order like a geometry layout; `format` is any of the vertex
|
|
1216
|
+
vocabulary, so a per-instance tint is a `unorm8x4` at four bytes and a
|
|
1217
|
+
frame index a `uint16x2`; explicit strides and offsets stay the
|
|
1218
|
+
engine's, through core's pipeline API) on either shader-material form
|
|
1219
|
+
makes an INSTANCED material: the vertex stage reads the attributes as
|
|
1220
|
+
`in` variables beside the layout's own, and each drawn instance gets one
|
|
1221
|
+
record from each of the mesh's instance buffers. An instance buffer IS a
|
|
1222
|
+
vertex stream stepped per instance: on the mesh it is an `InstanceStream`
|
|
1223
|
+
(the layout, a byte mirror, a mesh-owned GPU buffer), written through
|
|
1224
|
+
`instanceAttribute(mesh, name)` - the accessor `geometryAttribute`
|
|
1225
|
+
returns, record index in, values as the shader sees them, the codec
|
|
1226
|
+
packing the bytes - and published with `updateRecords(mesh, { stream?,
|
|
1227
|
+
first?, count? })`, which is `updateVertices` for records: one coalesced
|
|
1228
|
+
buffer write per dirty stream at the scene's sync, so ten moved records
|
|
1229
|
+
of ten thousand cost ten. Its meshes come from `createInstancedMesh`
|
|
1230
|
+
(the first buffer is the core-written matrix, the rest are streams; the
|
|
1231
|
+
second is the STYLE record, below) or `createRecordMesh` (every buffer a
|
|
1232
|
+
stream); a `createMesh` mesh is rejected at add(). `instanceStyle: [..]`
|
|
1233
|
+
is the style record a fresh instance starts with, one value per
|
|
1234
|
+
component. The stock materials do the same with one flag:
|
|
1235
|
+
`lit/standard/unlit({ instanced: true })` places by the instance matrix
|
|
1236
|
+
(the shadow pass too, so the fleet casts), and `{ instanceColors: true
|
|
1237
|
+
}` adds a per-instance premultiplied `[r, g, b, a]` style record
|
|
1238
|
+
starting white, stored as `float16x4` (eight bytes, no banding in the
|
|
1239
|
+
darks) - Unity's per-material instancing switch with Three's setColorAt.
|
|
1240
|
+
|
|
1241
|
+
### Instancing
|
|
737
1242
|
|
|
738
1243
|
Instancing - one draw entry covering a population, in two forms. The
|
|
739
1244
|
axis between them is WHERE MOTION IS COMPUTED, the same split as
|
|
740
1245
|
@solidrt/2d's sprite and record layers.
|
|
741
1246
|
|
|
1247
|
+
FIRST, whether to instance at all. An instance costs the GPU a fixed
|
|
1248
|
+
per-instance setup, so records buy cheap MOTION, not cheap VERTICES:
|
|
1249
|
+
below roughly a hundred vertices per instance that setup dominates, and
|
|
1250
|
+
a plain geometry with a bigger vertex buffer draws faster. A point cloud
|
|
1251
|
+
is the extreme case - one vertex per point in an ordinary indexed
|
|
1252
|
+
`topology: "points"` geometry, no instancing, measured at 4x the frame
|
|
1253
|
+
rate of the same points as records. Packing more points per record does
|
|
1254
|
+
not recover it: every vertex of an instance fetches ALL of that
|
|
1255
|
+
instance's attributes, so attribute bandwidth grows with the packing.
|
|
1256
|
+
Reach for instancing when each instance is a real object (a ship, a
|
|
1257
|
+
tree, a crowd member), not to submit a lot of vertices. The tell in
|
|
1258
|
+
`/gpu` is a large `instanceCount` beside a tiny `indexCount`.
|
|
1259
|
+
|
|
1260
|
+
#### createInstancedMesh
|
|
1261
|
+
|
|
742
1262
|
`createInstancedMesh(geometry, material, { capacity?, bounds?, label? })`
|
|
743
1263
|
draws the geometry once per instance NODE: `addInstance(mesh,
|
|
744
1264
|
transform?, parent?)` returns a scene node (kind "instance") placed
|
|
@@ -748,32 +1268,36 @@ matrix RELATIVE to the mesh the spatial core writes into the record
|
|
|
748
1268
|
buffer (`bindMatrixRecord` anchored on the mesh node: one coalesced
|
|
749
1269
|
buffer write per flush however many instances moved, so native
|
|
750
1270
|
transitions and clip players move instances with zero per-frame JS).
|
|
751
|
-
The material declares `INSTANCE_MATRIX_ATTRIBUTES`
|
|
752
|
-
columns, 16 floats; anything else throws at creation)
|
|
1271
|
+
The material declares `INSTANCE_MATRIX_ATTRIBUTES` as its first instance
|
|
1272
|
+
buffer (four vec4 columns, 16 floats; anything else throws at creation)
|
|
1273
|
+
and its vertex
|
|
753
1274
|
stage splices `INSTANCE_MATRIX` from `@solidrt/3d/glsl`: `uModel *
|
|
754
1275
|
instanceMatrix() * vec4(aPos, 1.0)`, normals through
|
|
755
1276
|
`instanceNormalMatrix()` (Three's derivation, exact for any rotation and
|
|
756
1277
|
scale at no per-instance data; a sheared hierarchy - a non-uniformly
|
|
757
1278
|
scaled group above a rotated instance - takes
|
|
758
1279
|
`transpose(inverse(mat3(instanceMatrix())))` instead) - or is a stock
|
|
759
|
-
material with `instanced`.
|
|
760
|
-
|
|
761
|
-
(`MeshInstances.
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
1280
|
+
material with `instanced`. A second instance buffer, when the material
|
|
1281
|
+
declares one, gives every instance a STYLE record beside its matrix
|
|
1282
|
+
(`MeshInstances.streams[0]`): an app-owned stream in the material's
|
|
1283
|
+
layout, written per instance with `setInstanceStyle(instance, values)`
|
|
1284
|
+
(one value per attribute component, encoded by the codecs) or by name
|
|
1285
|
+
through `instanceAttribute` + `updateRecords`, into a JS mirror and
|
|
1286
|
+
published as ONE coalesced buffer write per stream at the scene's sync
|
|
1287
|
+
(a frame-rate path, like setTransform); a fresh or recycled slot starts
|
|
1288
|
+
from the material's `instanceStyle` (white under `instanceColors`, zeros
|
|
1289
|
+
otherwise); a third buffer is a further stream, reached by name only;
|
|
1290
|
+
under `alphaTest` the cutout shadow
|
|
767
1291
|
multiplies by the same color, so an instance faded below the cutoff
|
|
768
1292
|
casts nothing, like its pixels (Godot's alpha scissor holds in its
|
|
769
|
-
shadow pass too). The core's pose
|
|
1293
|
+
shadow pass too). The core's pose buffer and the app's style buffer are
|
|
770
1294
|
@solidrt/2d's split one dimension up. Slots are fixed
|
|
771
|
-
for an instance's life and recycle on `
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
and past `capacity` (default 64)
|
|
775
|
-
with the live records retargeted in one core call
|
|
776
|
-
republished. `parent` may be a group inside the mesh's subtree (a squad
|
|
1295
|
+
for an instance's life and recycle on `destroy` (at the free: an
|
|
1296
|
+
instance cannot exist outside its mesh, so the generic add/remove throw
|
|
1297
|
+
on one), the drawn count is the slot high-water mark,
|
|
1298
|
+
and past `capacity` (default 64) every buffer doubles into a
|
|
1299
|
+
replacement, with the live matrix records retargeted in one core call
|
|
1300
|
+
and each stream's mirror republished. `parent` may be a group inside the mesh's subtree (a squad
|
|
777
1301
|
in a fleet): the record stays mesh-relative through it. Picking is per
|
|
778
1302
|
instance: every instance node carries the geometry's bounds and
|
|
779
1303
|
triangle shape, so `Hit`, `Overlap` and `Impact` name the `instance` and
|
|
@@ -787,40 +1311,51 @@ MultiMesh is the record form. The components: `<InstancedMesh capacity>`
|
|
|
787
1311
|
with `<Instance position style transition onPointerDown>` children
|
|
788
1312
|
(`<Group>` children are squads, an `<Instance>` is a parent too);
|
|
789
1313
|
`examples/fleet.tsx` is a thousand of them springing between formations
|
|
790
|
-
with tap-to-tint
|
|
1314
|
+
with tap-to-tint.
|
|
791
1315
|
Mount cost is the one difference between the faces: an `<Instance>` row
|
|
792
1316
|
costs about eight times an `addInstance` call (the component machinery -
|
|
793
1317
|
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
|
-
|
|
1318
|
+
provider is built only when it has children, the biggest single
|
|
1319
|
+
saving), so a few thousand
|
|
796
1320
|
mount in tens of milliseconds either way; populations spawned per frame,
|
|
797
1321
|
or in five figures, belong to the function face.
|
|
798
1322
|
|
|
1323
|
+
#### createRecordMesh
|
|
1324
|
+
|
|
799
1325
|
`createRecordMesh(geometry, material, records, count?, { bounds?,
|
|
800
|
-
label? })` is the raw form: `records` is the
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
records
|
|
1326
|
+
label? })` is the raw form: `records` is the per-instance data laid out
|
|
1327
|
+
in the material's first instance layout - a Float32Array over an
|
|
1328
|
+
all-float layout, otherwise bytes built with `vertexView(layout, buffer)`
|
|
1329
|
+
and `attributeAccess` like a geometry stream (a length that is not whole
|
|
1330
|
+
records throws) - copied into the first stream's mirror, whose capacity
|
|
1331
|
+
starts at the records given; every further instance buffer the material
|
|
1332
|
+
declares is a zeroed stream of the same capacity, reached by name
|
|
1333
|
+
through `instanceAttribute`. `count` picks how many draw (default all).
|
|
805
1334
|
`setRecords(mesh, records, count?)` rewrites from the start (count
|
|
806
1335
|
defaults to the records written; more than capacity GROWS: capacity
|
|
807
|
-
doubles into
|
|
808
|
-
`setDrawBuffers`, the old
|
|
809
|
-
|
|
810
|
-
|
|
1336
|
+
doubles into replacement buffers, the entry is re-pointed via
|
|
1337
|
+
`setDrawBuffers`, the old ones are freed), `instanceAttribute` +
|
|
1338
|
+
`updateRecords(mesh, { first, count })` rewrites a few (the range is
|
|
1339
|
+
against capacity, so write ahead and dial after), and
|
|
1340
|
+
`setRecordCount(mesh, n)` is the population dial (clamped to capacity;
|
|
1341
|
+
frame-rate-safe). Records are opaque data (position/yaw/tint/whatever
|
|
1342
|
+
your shader reads), so the
|
|
811
1343
|
library cannot know where they place instances: a record mesh has NO
|
|
812
1344
|
picking leaf unless you pass `bounds` (local, covering the population) -
|
|
813
1345
|
then it picks and transparent-sorts conservatively as one box. The
|
|
814
1346
|
escape hatch for motion only JS can compute at scale (a particle sim, a
|
|
815
1347
|
crowd stepped in a worker).
|
|
816
1348
|
|
|
1349
|
+
#### Common to both
|
|
1350
|
+
|
|
817
1351
|
Everything mesh works on both: setTransform moves the whole population
|
|
818
1352
|
through one uModel, setVisible zeroes the drawn count and restores it on
|
|
819
1353
|
unhide, renderOrder/params/geometry/material swaps apply, and
|
|
820
1354
|
`disposeInstances(mesh)` detaches and frees the record buffers - the one
|
|
821
1355
|
explicit free, geometry-buffer rule. `examples/fleet.tsx` (instances,
|
|
822
|
-
components)
|
|
823
|
-
|
|
1356
|
+
components) and `examples/instanced.tsx` (records) are the live proofs.
|
|
1357
|
+
|
|
1358
|
+
### Background
|
|
824
1359
|
|
|
825
1360
|
Background: `scene.setBackground(source | null)`, the `background` option
|
|
826
1361
|
on createScene, and the reactive `Scene` prop. Drawn as the FIRST entry
|
|
@@ -840,11 +1375,10 @@ no separate resize plumbing. Two forms:
|
|
|
840
1375
|
written through `scene.setParams` (an app clock for an animated sky).
|
|
841
1376
|
Godot's sky shader and Unity's skybox material are the same idea; the
|
|
842
1377
|
radiance bake for environment lighting will consume this same source
|
|
843
|
-
later, so a procedural sky written here lights the scene then.
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
encoded pixels.
|
|
1378
|
+
later, so a procedural sky written here lights the scene then. A sky
|
|
1379
|
+
writes LINEAR light to fragColor (the skybox form does) and the
|
|
1380
|
+
scene's resolve exposes, tone maps and encodes it with everything
|
|
1381
|
+
else.
|
|
848
1382
|
- A skybox `{ cube, intensity?, rotation? }` (SkyboxOptions): a cube
|
|
849
1383
|
map from createCubeTexture sampled along the same ray - Three's
|
|
850
1384
|
`scene.background = cubeTexture` with `backgroundIntensity` and
|
|
@@ -856,6 +1390,8 @@ no separate resize plumbing. Two forms:
|
|
|
856
1390
|
the same way, so a skybox is one flat color there. A 2D texture id
|
|
857
1391
|
throws at the samplerCube binding. `examples/skybox.tsx`.
|
|
858
1392
|
|
|
1393
|
+
### Cube-map convention
|
|
1394
|
+
|
|
859
1395
|
The cube-map convention: a cube map holds what a GL lookup returns -
|
|
860
1396
|
each face as seen from OUTSIDE the cube, GL's own (RenderMan) frame -
|
|
861
1397
|
and every library lookup is a plain `texture(cube, dir)` in world space.
|
|
@@ -871,13 +1407,15 @@ number, so the object form keeps it unambiguous). Translucent grounds
|
|
|
871
1407
|
over a background still need blend factors (a separate shader texture
|
|
872
1408
|
underneath until then).
|
|
873
1409
|
|
|
1410
|
+
### Environment
|
|
1411
|
+
|
|
874
1412
|
Environment: `scene.setEnvironment({ cube, intensity?, rotation? } |
|
|
875
1413
|
null)`, the `environment` option on createScene and the reactive `Scene`
|
|
876
1414
|
prop - the cube map every `standard` material is lit by (always: the
|
|
877
1415
|
split sum `envRadiance` at its roughness times PBR's `envBrdf` for the
|
|
878
1416
|
specular, `envIrradiance` - the fully rough sample along the normal, as
|
|
879
1417
|
Three's getIBLIrradiance and Godot read it - added to the hemisphere
|
|
880
|
-
for the diffuse) and every `
|
|
1418
|
+
for the diffuse) and every `phong({ reflectivity })` material mirrors,
|
|
881
1419
|
typically the skybox's own cube turned with it. The cube to use is a
|
|
882
1420
|
BAKED one: `bunx srt tool 3d/environment sky.hdr -o assets/sky.srte`
|
|
883
1421
|
turns an equirectangular Radiance .hdr (Poly Haven's are CC0) into the
|
|
@@ -913,19 +1451,25 @@ tints instead; not offered). The reflection blurs with `shininess`:
|
|
|
913
1451
|
roughness `sqrt(2 / (shininess + 2))` picks a mip level of the cube
|
|
914
1452
|
(`textureLod`), so the environment cube wants `mipmap: true`; a cube
|
|
915
1453
|
without mipmaps stays sharp. `specularMap`'s red scales it like
|
|
916
|
-
`specular`. For `
|
|
1454
|
+
`specular`. For `phong` it is not an ambient light source: the hemisphere
|
|
917
1455
|
light stays its only ambient term (`standard` adds envIrradiance; SH9
|
|
918
1456
|
is a later, additive mode). A declared `reflectivity` with no
|
|
919
1457
|
environment set contributes nothing (uEnvOn 0), not a black reflection.
|
|
920
1458
|
`examples/skybox.tsx` (a JS-baked sky), `examples/environment.tsx` (a
|
|
921
1459
|
baked HDRI lighting the scene alone).
|
|
922
1460
|
|
|
1461
|
+
### Reflection probes
|
|
1462
|
+
|
|
923
1463
|
Reflection probes: `scene.createReflectionProbe({ position, size?,
|
|
924
1464
|
near?, far?, layers?, clearColor?, label? })` renders the scene into a
|
|
925
1465
|
cube map from a point - Three's CubeCamera, Unity's and Godot's
|
|
926
1466
|
realtime ReflectionProbe - and returns `{ cube, setPosition, update(),
|
|
927
1467
|
dispose() }`; `cube` is what `environment={{ cube }}` (a chrome ball
|
|
928
|
-
mirroring its surroundings) or `background` takes.
|
|
1468
|
+
mirroring its surroundings) or `background` takes. `dispose()` destroys
|
|
1469
|
+
the cube, and a scene whose environment or background names it drops
|
|
1470
|
+
that first (setEnvironment(null) / setBackground(null)), so a subtree
|
|
1471
|
+
that owned the environment leaves the scene unlit by it rather than
|
|
1472
|
+
sampling a destroyed texture; set another when it goes. A view under the
|
|
929
1473
|
hood: one entry list mirrored from the scene, the light set and scene
|
|
930
1474
|
params fanned out, its own layer mask (keep the mirroring object out of
|
|
931
1475
|
its own probe with `layers`), the scene's background drawn first on
|
|
@@ -952,24 +1496,24 @@ costs ~14 ms of GPU there against ~1 ms for the six face passes: fine at
|
|
|
952
1496
|
probes are the expensive option in every engine (Unity time-slices
|
|
953
1497
|
them): update a prefiltered probe when the surroundings changed, or
|
|
954
1498
|
every few frames, and keep `prefilter: false` for a probe that must
|
|
955
|
-
refresh every frame on a tight budget. The faces hold LINEAR light
|
|
956
|
-
|
|
957
|
-
names the scene's fan-out then skips), HALF FLOAT where the device
|
|
1499
|
+
refresh every frame on a tight budget. The faces hold LINEAR light like every buffer (a
|
|
1500
|
+
probe is a buffer without a resolve), HALF FLOAT where the device
|
|
958
1501
|
renders it (`limits.halfFloatRenderable`, every GLES 3 device here: a
|
|
959
1502
|
sun's or an emissive's range survives into the reflection, as in every
|
|
960
1503
|
engine's HDR probe) and 8-bit clamped elsewhere - the renderer decides
|
|
961
|
-
for
|
|
1504
|
+
for every target (Godot), not a per-probe knob (`bufferFormat()` in
|
|
962
1505
|
environment.ts) - and the probe never samples its own cube while
|
|
963
1506
|
rendering (a black environment stands in: one bounce). The face cameras are plain world-up cameras through an
|
|
964
1507
|
x-mirrored projection (`Camera.mirror`), because a GL cube face is seen
|
|
965
1508
|
from outside; the engine inverts the front-face rule on cube target
|
|
966
1509
|
passes so cull modes keep their meaning. `examples/probe.tsx`.
|
|
967
1510
|
|
|
1511
|
+
### Baked sky
|
|
1512
|
+
|
|
968
1513
|
Baked sky: `scene.bakeBackground(size?)` is a reflection probe at the
|
|
969
1514
|
origin that sees no mesh (layer mask 0): the scene's background - the
|
|
970
|
-
GLSL sky or the skybox - alone on its six faces, LINEAR (a sky
|
|
971
|
-
|
|
972
|
-
bytes as light), prefilters it like a probe and returns the chain's
|
|
1515
|
+
GLSL sky or the skybox - alone on its six faces, LINEAR (a sky fragment writes light, so the bake stores exactly
|
|
1516
|
+
what the backdrop draws), prefilters it like a probe and returns the chain's
|
|
973
1517
|
TextureId for `environment={{ cube }}`: Godot's sky-to-radiance bake, so
|
|
974
1518
|
a procedural sky lights the scene with no light nodes, and the runtime
|
|
975
1519
|
way to turn a hi-res LDR skybox into a properly convolved environment
|
|
@@ -977,13 +1521,15 @@ way to turn a hi-res LDR skybox into a properly convolved environment
|
|
|
977
1521
|
alternative). A snapshot at the probe format (half float where
|
|
978
1522
|
renderable; default size 128): bake again when the sky changes, and
|
|
979
1523
|
destroy the old cube - it is not auto-freed (an environment normally
|
|
980
|
-
lives as long as the app).
|
|
981
|
-
|
|
1524
|
+
lives as long as the app). A buffer is UNCLAMPED (the clamp sits in the
|
|
1525
|
+
resolve's encode), so a sky's sun disc of 40.0
|
|
982
1526
|
bakes as 40.0 and shows as the broad bright highlight on rough metal
|
|
983
1527
|
that HDR is for. A sky that reads
|
|
984
1528
|
`uCamPos` bakes from the origin; scene params (an app clock) are seen as
|
|
985
1529
|
of the call. `examples/sky-lit.tsx`.
|
|
986
1530
|
|
|
1531
|
+
### Panoramas
|
|
1532
|
+
|
|
987
1533
|
Panoramas: `equirectToCube(map, size, opts?)` converts an uploaded
|
|
988
1534
|
equirectangular 2D texture (createImage, createTexture) into a cube
|
|
989
1535
|
TextureId on the GPU, synchronously (six face passes straight into a
|
|
@@ -999,6 +1545,8 @@ the bake tool above, whose CPU pipeline (`src/environment-bake.ts`:
|
|
|
999
1545
|
decodeHdr, panoramaToCube, prefilterCube, the .srte encode/decode) is
|
|
1000
1546
|
pure TypeScript and bun-tested - there is no runtime .hdr decoder.
|
|
1001
1547
|
|
|
1548
|
+
### Fog
|
|
1549
|
+
|
|
1002
1550
|
Fog: `scene.setFog(fog | null)`, the `fog` option on createScene and
|
|
1003
1551
|
the reactive `Scene` prop, in Three's two shapes: linear `{ color, near,
|
|
1004
1552
|
far }` (`Fog`; fades from near to far, fully fogged past far) or exp2
|
|
@@ -1023,37 +1571,81 @@ material drops the code from the program (Three's `material.fog`) - a
|
|
|
1023
1571
|
sky sphere, a far backdrop. A shaderMaterial opts in by composing `FOG`
|
|
1024
1572
|
from `/glsl` (declares the set; `fog(rgb, alpha, worldPos, camPos)`, or
|
|
1025
1573
|
`fogAdditive(rgb, worldPos, camPos)` for a `blend: "add"` look, which
|
|
1026
|
-
fades toward black instead of the fog color
|
|
1574
|
+
fades toward black instead of the fog color; a stock material with
|
|
1575
|
+
`blend: "add"` composes that form by itself).
|
|
1027
1576
|
The BACKGROUND is not fogged: it is entry zero with no depth or
|
|
1028
1577
|
distance, so match the fog color to `clearColor` or the background's
|
|
1029
1578
|
horizon, and put `far` at or inside the camera's far plane to hide the
|
|
1030
1579
|
clip. `examples/fog.tsx` cycles the forms over a valley;
|
|
1031
1580
|
`examples/cascades.tsx` fogs its field to the sky.
|
|
1032
1581
|
|
|
1582
|
+
### Color
|
|
1583
|
+
|
|
1033
1584
|
Color: the scene shades in LINEAR light and outputs sRGB, like Three
|
|
1034
1585
|
(ColorManagement), Godot and Unity's linear space - no gamma mode. Every
|
|
1035
1586
|
`[r, g, b]` color option is sRGB, what a color picker shows: material
|
|
1036
1587
|
`color` and `emissive`, light `color`, the hemisphere's `sky`/`ground`,
|
|
1037
|
-
fog `color
|
|
1038
|
-
|
|
1039
|
-
to a uniform yourself). Color
|
|
1040
|
-
base color, emissive or sky
|
|
1041
|
-
(createTexture, createCubeTexture;
|
|
1042
|
-
color and emissive images) - a plain
|
|
1043
|
-
|
|
1044
|
-
stay rgba8, and an HDR image
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1588
|
+
fog `color`, the scene's and a view's `clearColor`; the library decodes
|
|
1589
|
+
it when it writes the uniform or the clear (`srgbToLinear`/`linearColor`
|
|
1590
|
+
are exported for values you write straight to a uniform yourself). Color
|
|
1591
|
+
MAPS decode through their format: create a base color, emissive or sky
|
|
1592
|
+
image with `format: "rgba8-srgb"` (createTexture, createCubeTexture;
|
|
1593
|
+
createModel does it for glTF's base color and emissive images) - a plain
|
|
1594
|
+
rgba8 map reads as linear data and renders washed out; data maps
|
|
1595
|
+
(normal, specular, roughness, light maps) stay rgba8, and an HDR image
|
|
1596
|
+
is "rgba16f". Vertex colors are linear, as glTF stores them.
|
|
1597
|
+
|
|
1598
|
+
Every target is a BUFFER plus a RESOLVE, Godot's and Unity's pipeline:
|
|
1599
|
+
the meshes draw into a linear buffer (`scene.hdrTexture` /
|
|
1600
|
+
`view.hdrTexture`: premultiplied linear light, half float where the
|
|
1601
|
+
device renders it - `limits.halfFloatRenderable`, every GLES 3 device
|
|
1602
|
+
here - else rgba8 linear and clamped; the renderer decides, no knob),
|
|
1603
|
+
and one full-screen pass, the resolve, writes the displayed rgba8
|
|
1604
|
+
`scene.texture`: exposure (`scene.setExposure`, default 1), tone mapping
|
|
1605
|
+
(`scene.setToneMapping("none" | "aces" | "agx" | "neutral")` - ACES the
|
|
1606
|
+
filmic curve every engine ships, AgX Blender's (Three, Godot 4.3+),
|
|
1607
|
+
Neutral the Khronos PBR curve (Three, Unity) that keeps product colors
|
|
1608
|
+
where a filmic curve shifts them; the reactive `toneMapping`/`exposure`
|
|
1609
|
+
props), then the sRGB encode with an ordered dither against banding,
|
|
1610
|
+
premultiplied. So a fragment - stock or custom -
|
|
1611
|
+
writes LINEAR light and never encodes (`sceneOutput` is fog only; a
|
|
1612
|
+
material has no output stage), a `transparent` mesh blends in linear
|
|
1613
|
+
space, the clearColor is tone mapped like a background is, and the
|
|
1614
|
+
resolve is an auto target, so a static scene still costs zero passes.
|
|
1615
|
+
The resolve is the post-effect slot: `resolve` on createScene/createView
|
|
1616
|
+
and the `<Scene>`/`<View3d>` prop - a source, `{ source, textures }`,
|
|
1617
|
+
or a function of the buffer id returning either, so a chain over
|
|
1618
|
+
`hdrTexture` is built before the resolve compiles - and
|
|
1619
|
+
`scene.setResolve` live with the same input. The
|
|
1620
|
+
source gets the shader-target contract (vUV, iResolution, fragColor),
|
|
1621
|
+
`uniform sampler2D uScene` (the buffer) and the RESOLVE set from `/glsl`
|
|
1622
|
+
declared: end with `fragColor = resolveColor(rgb, alpha)`; any
|
|
1623
|
+
`scene.setParams` name is readable; `DEFAULT_RESOLVE` is the stock one
|
|
1624
|
+
sample. BLOOM is stock: `bloom: { threshold?, intensity?, radius? }` on
|
|
1625
|
+
createScene, `scene.setBloom(opts | null)`, the reactive `bloom` prop -
|
|
1626
|
+
radiance above `threshold` (default 1, what a fully lit white surface
|
|
1627
|
+
reaches) blurred over `radius` rounds (default 2) of a separable blur
|
|
1628
|
+
at a quarter of the target's size and added back at `intensity`
|
|
1629
|
+
(default 0.5): Godot's glow, Unity's Bloom, Three's UnrealBloomPass. A
|
|
1630
|
+
chain of small auto passes per resolving target, re-rendered when the
|
|
1631
|
+
buffer is; views follow the scene's bloom unless they carry a `bloom`
|
|
1632
|
+
of their own (null = off, a clean minimap), the fog model. The glow is
|
|
1633
|
+
light: over a transparent backdrop (a view with no clearColor over UI)
|
|
1634
|
+
it still shows, its alpha its own brightness, and an opaque pixel is the
|
|
1635
|
+
plain sum. The default resolve composes it; a custom resolve does by declaring `uniform
|
|
1636
|
+
sampler2D uBloom; uniform float uBloomIntensity;` and adding the term
|
|
1637
|
+
(start from `BLOOM_RESOLVE` in `/glsl`; `examples/bloom.tsx` adds a
|
|
1638
|
+
vignette that way). `output` composes the DISPLAYED texture after the
|
|
1639
|
+
resolve, the place for an LDR effect (`examples/scene-post-effect.tsx`). Cost: one full-screen pass and
|
|
1640
|
+
double the color bandwidth per target, about 0.2 ms per 1080p target on
|
|
1641
|
+
an Intel iGPU (21 full-HD targets at once still ran at 36 fps); a
|
|
1642
|
+
device that cannot afford it renders a smaller buffer and the leaf
|
|
1643
|
+
scales it up. What changed for a scene tuned before linear light:
|
|
1644
|
+
terminators soften, mid-tones brighten, highlights widen - drop ambient
|
|
1645
|
+
rather than lights. `emissiveIntensity` scales the emissive in linear
|
|
1646
|
+
light.
|
|
1647
|
+
|
|
1648
|
+
### Lighting GLSL
|
|
1057
1649
|
|
|
1058
1650
|
Lighting GLSL (`@solidrt/3d/glsl`): exported string constants composed
|
|
1059
1651
|
into shaderMaterial sources with plain template literals - `LIT_VERTEX`
|
|
@@ -1084,27 +1676,34 @@ three) and `SHADOW_LOOKUP`
|
|
|
1084
1676
|
cast; it walks the light's slots and samples the first map that covers
|
|
1085
1677
|
the point, which is the cascade select, blended into the next map over
|
|
1086
1678
|
the outer `SHADOW_BLEND` of the map). A receiving fragment
|
|
1087
|
-
multiplies light i's term by `lightShadow(i, ...)`, exactly what `
|
|
1679
|
+
multiplies light i's term by `lightShadow(i, ...)`, exactly what `phong`
|
|
1088
1680
|
composes; a non-receiving one composes none of the three and declares no
|
|
1089
1681
|
samplers. Lights, colors and exponents are arguments, so
|
|
1090
|
-
nothing is pinned but the function names; `
|
|
1682
|
+
nothing is pinned but the function names; `phong` is composed from these
|
|
1091
1683
|
same constants - customizing never means leaving the system.
|
|
1092
1684
|
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1685
|
+
### Custom looks: three tiers
|
|
1686
|
+
|
|
1687
|
+
A custom look is a citizen of the scene - lit by its lights, shadowed
|
|
1688
|
+
and fogged like the stock materials, exposed and tone mapped by the same
|
|
1689
|
+
resolve - at one of three tiers, top first:
|
|
1096
1690
|
|
|
1097
1691
|
1. STANDARD FRAGMENT, CUSTOM VERTEX. Any vertex stage that writes the lit
|
|
1098
1692
|
varyings (vWorldPos, vNormal, vUv, plus vColor with `vertexColors`,
|
|
1099
|
-
vUv2 with `lightMap`) pairs with `
|
|
1100
|
-
`standardFragment(options)` (`/glsl`): the exact fragment `
|
|
1693
|
+
vUv2 with `lightMap`) pairs with `phongFragment(options)` /
|
|
1694
|
+
`standardFragment(options)` (`/glsl`): the exact fragment `phong` /
|
|
1101
1695
|
`standard` compile, the same option names and defaults as their
|
|
1102
1696
|
options with the texture options boolean. An instanced or displaced
|
|
1103
1697
|
mesh keeps the stock shading whole (`examples/instanced.tsx`: the
|
|
1104
1698
|
per-instance tint rides vColor). Instance it with the per-entry
|
|
1105
1699
|
uniforms the source declares (uColor, uSpecular/uShininess or
|
|
1106
|
-
uMetalness/uRoughness, the maps opted into)
|
|
1107
|
-
|
|
1700
|
+
uMetalness/uRoughness, the maps opted into) - SEED THEM: a uniform
|
|
1701
|
+
no one wrote is zero, and the program shades `base * uColor`, so an
|
|
1702
|
+
unseeded uColor renders pure black with no error. `instance({
|
|
1703
|
+
params: { uColor: [1, 1, 1, 1], uSpecular: 0.12, uShininess: 24 } })`
|
|
1704
|
+
is the white starting point (the stock materials seed exactly this
|
|
1705
|
+
from their `color` option).
|
|
1706
|
+
2. A SURFACE FUNCTION inside the stock fragment. `phongFragment({ surface,
|
|
1108
1707
|
prelude })`: `prelude` is file scope (uniforms and helpers; a uniform
|
|
1109
1708
|
it declares is an ordinary `instance()` param), `surface` declares
|
|
1110
1709
|
`void surface(inout Surface s)`, called once the program has filled
|
|
@@ -1117,26 +1716,30 @@ three tiers, top first:
|
|
|
1117
1716
|
generated program is; colors are linear light, premultiplied
|
|
1118
1717
|
throughout, `Surface.base` included. The material describes the
|
|
1119
1718
|
surface, the package shades it (Godot's fragment(), Filament's
|
|
1120
|
-
material()).
|
|
1719
|
+
material()). This is the tier for anything whose geometry is not a
|
|
1720
|
+
surface: a point splat discarding outside `gl_PointCoord`'s inscribed
|
|
1721
|
+
circle and flipping a fitted normal towards the viewer keeps the
|
|
1722
|
+
scene's whole light model in about fifteen lines, where hand-rolling
|
|
1723
|
+
the light loop (tier 3) would have to match every light's falloff,
|
|
1724
|
+
cone and shadow by hand.
|
|
1121
1725
|
3. A FRAGMENT OF YOUR OWN over the scene set. Compose `SCENE` (or
|
|
1122
1726
|
`sceneSource({ lights, receiveShadow, env, fog })`, each flag leaving a
|
|
1123
1727
|
declaration out): it declares uCamPos, uHemiSky/uHemiGround, the
|
|
1124
|
-
light list, the shadow set, the environment
|
|
1125
|
-
|
|
1728
|
+
light list, the shadow set, the environment and fog - declare none
|
|
1729
|
+
of them yourself. Build a `Surface` with `surfaceOf(base,
|
|
1126
1730
|
normal)`, set the fields you mean, call `shadeBlinn(s, position)` or
|
|
1127
1731
|
`shadePbr(s, position)` (premultiplied rgb back: hemisphere, every
|
|
1128
1732
|
light with its shadow, the environment term, the emissive), add your
|
|
1129
1733
|
own terms times the alpha, and end with `sceneOutput(rgb, alpha,
|
|
1130
|
-
position)` (fog
|
|
1734
|
+
position)` (fog; the pixel is linear light, the resolve exposes, tone
|
|
1735
|
+
maps and encodes it). A custom LIGHT MODEL
|
|
1131
1736
|
loops `sceneLight(i, position, normal)` to `uLightCount` instead of a
|
|
1132
1737
|
shade function: light i's direction and its color already attenuated,
|
|
1133
1738
|
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.
|
|
1739
|
+
materials are built from this same set, so the tiers cannot drift.
|
|
1136
1740
|
The demo `the-third-dimension.tsx` has tier 2 (the ground) and tier 3
|
|
1137
|
-
(the knot's rim term)
|
|
1138
|
-
|
|
1139
|
-
`standardFragment(options)` is the same for `standard`: lit's options
|
|
1741
|
+
(the knot's rim term).
|
|
1742
|
+
`standardFragment(options)` is the same for `standard`: phong's options
|
|
1140
1743
|
minus `specularMap`/`env` (the environment is always composed) plus
|
|
1141
1744
|
`metalnessMap`/`roughnessMap`, on the same `litVertex(options)`, with
|
|
1142
1745
|
`uMetalness`/`uRoughness` in place of `uSpecular`/`uShininess`.
|
|
@@ -1153,7 +1756,9 @@ a shadow program that never reads `n` (no triplanar, no surface function
|
|
|
1153
1756
|
using it) reflects `uNormal` inactive - set `normalMatrix: false` on that
|
|
1154
1757
|
instance or every caster move warns about the skipped write.
|
|
1155
1758
|
|
|
1156
|
-
Lights
|
|
1759
|
+
### Lights
|
|
1760
|
+
|
|
1761
|
+
Lights and `phong`: lights are graph NODES, like Three. `createDirectionalLight({
|
|
1157
1762
|
direction?, color?, intensity? })` / `<DirectionalLight>` is parallel light
|
|
1158
1763
|
travelling along `direction` in the node's LOCAL space (default `[0, -1,
|
|
1159
1764
|
0]`, a sun overhead; length ignored), so a parent Group's rotation turns it
|
|
@@ -1175,10 +1780,12 @@ only, same falloff, no cone. `createHemisphereLight({ sky?, ground?, intensity?
|
|
|
1175
1780
|
normal's tilt (fixed to world up, the node's transform is ignored); one per
|
|
1176
1781
|
scene, the last attached wins. Placement goes through setTransform, the
|
|
1177
1782
|
light's own fields through `setLight(light, { ... })` (frame-rate-safe,
|
|
1178
|
-
like setMeshParams). At most `MAX_LIGHTS` (8, exported from `/glsl`)
|
|
1783
|
+
like setMeshParams). At most `MAX_LIGHTS` (8, exported from the root and `/glsl`)
|
|
1179
1784
|
lights per scene, directional, spot and point together (the hemisphere
|
|
1180
|
-
is not in the list) -
|
|
1181
|
-
|
|
1785
|
+
is not in the list) - a ninth is reported at the sync that settles the
|
|
1786
|
+
set, not at add() (a declarative swap's transient overlap is fine),
|
|
1787
|
+
the way a shadow-budget overflow is (see there), and the lights past
|
|
1788
|
+
the cap are not lit; it is a shader-source constant, fixed per app. `uLightDir` and `uLightPos` are core-driven:
|
|
1182
1789
|
each light's slots are spatial-core shared-slot sinks following the
|
|
1183
1790
|
node's world rotation (direction, negated so the shader reads the
|
|
1184
1791
|
vector TOWARD the light) and world position, so a MOVING light costs no
|
|
@@ -1189,7 +1796,7 @@ LIGHT_POINT), `uLightDir[N]`/`uLightPos[N]`/`uLightColor[N]` (intensity
|
|
|
1189
1796
|
folded into the color) and `uLightParams[N]` (cosInner, cosOuter,
|
|
1190
1797
|
distance, decay) - so a custom fragment composing `LIGHT_SLOTS` +
|
|
1191
1798
|
`LIGHT_LOOKUP` from `/glsl` reads the same list through `lightVector(i,
|
|
1192
|
-
worldPos, out l)` (returns the attenuation, 0 = skip the light; `
|
|
1799
|
+
worldPos, out l)` (returns the attenuation, 0 = skip the light; `phong`
|
|
1193
1800
|
is the shape), and a light change costs one write however many meshes.
|
|
1194
1801
|
A custom fragment that declares only the old directional subset
|
|
1195
1802
|
(`uLightCount`/`uLightDir`/`uLightColor`) still works - it just shades
|
|
@@ -1198,13 +1805,14 @@ scenes. Everything starts black: a lit scene with no light shows
|
|
|
1198
1805
|
nothing, on purpose, like Three. `examples/lamps.tsx` is the spot/point
|
|
1199
1806
|
shape (soft vs hard cone, casting spots, an orbiting bulb).
|
|
1200
1807
|
|
|
1201
|
-
|
|
1202
|
-
|
|
1808
|
+
### phong
|
|
1809
|
+
|
|
1810
|
+
`phong(opts)` is the Blinn-Phong material (Three's MeshPhongMaterial, Unity
|
|
1811
|
+
URP's Simple Lit) beside `unlit`: hemisphere ambient plus the light list, Lambert diffuse, Blinn-Phong highlight when
|
|
1203
1812
|
`specular` (0..1 strength) is set with `shininess` (default 30), a
|
|
1204
1813
|
mirror of the scene's environment when `reflectivity` (0..1, the
|
|
1205
1814
|
face-on weight; see Environment above) is set, blurred by the same
|
|
1206
|
-
`shininess`, the same `color`/`map`/`transparent` as unlit,
|
|
1207
|
-
multiply by the colored layout's aColor (so the geometry must carry it),
|
|
1815
|
+
`shininess`, the same `color`/`map`/`transparent`/`blend`/`vertexColors` as unlit,
|
|
1208
1816
|
`triplanar: n` to sample `map` by world position at `n` repeats per
|
|
1209
1817
|
world unit, blended across the three axis planes by the normal, and
|
|
1210
1818
|
`alphaTest: t` for a cutout (a fragment whose final alpha is below `t`
|
|
@@ -1223,6 +1831,8 @@ or more beside it, or trilinear smears the far half into the mip its long
|
|
|
1223
1831
|
axis picked (`createModel` uploads its images with both; the device clamps
|
|
1224
1832
|
the level, `limits.maxAnisotropy` reports it).
|
|
1225
1833
|
|
|
1834
|
+
#### Surface maps
|
|
1835
|
+
|
|
1226
1836
|
The surface maps, each an option beside `map` and sampled at its uv:
|
|
1227
1837
|
|
|
1228
1838
|
- `normalMap` (+ `normalScale`, ONE float as in Unity/Godot - Three's
|
|
@@ -1257,6 +1867,8 @@ The surface maps, each an option beside `map` and sampled at its uv:
|
|
|
1257
1867
|
|
|
1258
1868
|
`examples/materials.tsx` shows all five.
|
|
1259
1869
|
|
|
1870
|
+
### standard
|
|
1871
|
+
|
|
1260
1872
|
`standard(opts)` is the metalness/roughness material, the look authored
|
|
1261
1873
|
assets expect (Three's MeshStandardMaterial, Godot's StandardMaterial3D,
|
|
1262
1874
|
Unity's Standard): every lit option but the Blinn-Phong knobs
|
|
@@ -1276,7 +1888,7 @@ split sum, `envRadiance` at the roughness over the cube's mip chain
|
|
|
1276
1888
|
times the analytic `envBrdf` for the specular, `envIrradiance` on the
|
|
1277
1889
|
diffuse beside the hemisphere - with no `reflectivity` switch: the
|
|
1278
1890
|
environment is intrinsic to the model, and a baked environment with no
|
|
1279
|
-
lights at all lights a scene (`examples/environment.tsx`). Light intensities read as
|
|
1891
|
+
lights at all lights a scene (`examples/environment.tsx`). Light intensities read as phong's
|
|
1280
1892
|
(1 lights a white matte surface to 1; Godot's and Unity's convention -
|
|
1281
1893
|
a Three scene's intensities divide by pi). Without an environment a
|
|
1282
1894
|
metal shows only its highlights: no diffuse, nothing to reflect (Three
|
|
@@ -1295,70 +1907,90 @@ classes do not warn about an inactive uniform.
|
|
|
1295
1907
|
|
|
1296
1908
|
## Models
|
|
1297
1909
|
|
|
1910
|
+
### Three layers
|
|
1911
|
+
|
|
1298
1912
|
Authored models come in as glTF 2.0 (.gltf with its .bin and image files
|
|
1299
1913
|
next to it, or single-file .glb) and become a Group carrying the file's
|
|
1300
1914
|
node hierarchy, Three's `gltf.scene`. Three layers, use the lowest that
|
|
1301
1915
|
fits:
|
|
1302
1916
|
|
|
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
|
-
|
|
1917
|
+
#### parseGltf
|
|
1918
|
+
|
|
1919
|
+
`parseGltf(bytes, resolve?)` - the pure parser (no engine, runs under
|
|
1920
|
+
bun and on flux): `ModelData` = `nodes` (the retained hierarchy in
|
|
1921
|
+
pre-order - name, parent index, local TRS; matrix-form nodes are
|
|
1922
|
+
TRS-decomposed, shear dropped; nodes that carry no part, joint or
|
|
1923
|
+
animation target anywhere - cameras, lights, unused empties - are
|
|
1924
|
+
pruned), `parts` (one per mesh primitive, its node's NAME kept, `node`
|
|
1925
|
+
index, vertices in the base layout LOCAL to the node - except
|
|
1926
|
+
skinned parts: "skinned" layout, model-space bind pose, `skin` index),
|
|
1927
|
+
`skins` (joint node indices + inverse binds), `clips` (the animations
|
|
1928
|
+
as baked channel buffers: node/path/interpolation, times, values),
|
|
1929
|
+
`materials` (base color factor, `map` =
|
|
1930
|
+
index into `images`, `doubleSided`, `transparent` = alphaMode BLEND,
|
|
1931
|
+
`alphaMode` as written and `alphaCutoff`, spec default 0.5, the
|
|
1932
|
+
normal and emissive slots, `metalness`/`roughness` factors and the
|
|
1933
|
+
packed `metalnessRoughnessMap` - standard's inputs), `images`
|
|
1934
|
+
(the encoded PNG/JPEG bytes, undecoded) and `bounds` (world-space rest
|
|
1935
|
+
pose: a part's box through its node, a skinned part's per-joint boxes
|
|
1936
|
+
through the joints' rest transforms - where the skin places it, armature
|
|
1937
|
+
scale included; conservative under rotation). External
|
|
1938
|
+
files come through `resolve(uri)` (uri as written, still
|
|
1939
|
+
percent-encoded; `gltfExternalUris(bytes)` lists them so an async
|
|
1940
|
+
caller can read them first) - for a .gltf AND for a .glb, which is
|
|
1941
|
+
usually self-contained but may legally reference external images
|
|
1942
|
+
(real exporters do); data: uris need no resolver. Missing
|
|
1943
|
+
normals produce FLAT shading (the spec's rule): the primitive is
|
|
1944
|
+
un-indexed, one vertex per corner. A mirroring node chain (negative
|
|
1945
|
+
rest-pose world determinant) flips the part's index winding so
|
|
1946
|
+
`cull: "back"` still keeps the outside. Non-triangle primitives are
|
|
1947
|
+
skipped; a required extension the parser does not implement throws
|
|
1948
|
+
naming it, and Draco or meshopt compression throws "re-export without
|
|
1949
|
+
mesh compression" - Blender exports Draco by DEFAULT, so that is the
|
|
1950
|
+
first error a real file hits.
|
|
1951
|
+
|
|
1952
|
+
#### createModel
|
|
1953
|
+
|
|
1954
|
+
`createModel(data, { material?, label?, autoFree? })` - uploads the images (repeat
|
|
1955
|
+
wrap, mipmapped, 4x anisotropic), makes one material per glTF material (default `standard`
|
|
1956
|
+
with the file's color, maps, normal scale, metalness/roughness and
|
|
1957
|
+
packed map, emissive and transparency - the glTF material model, so a
|
|
1958
|
+
scene showing a model wants an `environment` (a glTF metal in a scene
|
|
1959
|
+
with none renders near black); `material: (m, maps, skinned) =>
|
|
1960
|
+
phong({ color: m.color, map: maps.map ?? undefined, skinned })` for the
|
|
1961
|
+
Blinn-Phong look, or any other material; it is called once per material and shared), the node table as nested
|
|
1962
|
+
Groups with the file's local TRS, and one mesh per part under its node,
|
|
1963
|
+
all inside the returned `Model` (a Group): `add(scene.root, model)`,
|
|
1964
|
+
place it with `setTransform`, find parts by name in `model.parts`
|
|
1965
|
+
(`{ name, mesh }`), spin a wheel relative to its axle through
|
|
1966
|
+
`model.nodes` (`{ name, node }` in table order, parents first; names
|
|
1967
|
+
repeat when the file's do - `.find()` yours), `model.bounds` for
|
|
1968
|
+
framing a camera. Skinned parts get the `skinned: true` material
|
|
1969
|
+
variant and hang off the model ROOT (the spec ignores their node's
|
|
1970
|
+
transform; the palette places them - see the mixer below). `dispose()`
|
|
1971
|
+
detaches it and frees the geometry buffers and textures - the model owns
|
|
1972
|
+
them, nothing else frees them.
|
|
1973
|
+
|
|
1974
|
+
#### loadGltf and loadModel
|
|
1975
|
+
|
|
1976
|
+
`loadGltf(path)` / `loadModel(path)` - read from `assets/` with flux:fs
|
|
1977
|
+
and build. Like every resource creator, a model frees with the owning
|
|
1978
|
+
reactive scope (`dispose()` at its cleanup; `autoFree: false` opts out):
|
|
1979
|
+
for the loaders that scope is the caller's at the call, captured before
|
|
1980
|
+
the await, so a model loaded inside a memo goes with the memo's rerun
|
|
1981
|
+
and one loaded in a component with its unmount. A load that settles
|
|
1982
|
+
after its scope is gone (unmounted mid-load) is freed on arrival and
|
|
1983
|
+
resolves already disposed - nothing owns it any more. `loadModel` reads the baked `.srtm` written by `srt tool
|
|
1984
|
+
3d/model <in.gltf|glb> -o assets/<name>.srtm`: the same parse run once
|
|
1985
|
+
under bun, stored in the GPU layout, so loading is views onto the file's
|
|
1986
|
+
bytes plus the image decodes. Numbers from a 32k-vertex, 6-texture model
|
|
1987
|
+
on a release client: `parseGltf` 124 ms on flux (22 ms under bun) against
|
|
1988
|
+
40 ms for the whole baked load - the runtime parse is fine for small
|
|
1989
|
+
models and a binary import (`import bytes from "./x.glb" with { type:
|
|
1990
|
+
"binary" }` then `createModel(parseGltf(bytes))`, see
|
|
1991
|
+
`examples/model.tsx`); bake anything big.
|
|
1992
|
+
|
|
1993
|
+
### Async loading
|
|
1362
1994
|
|
|
1363
1995
|
Loading is async everywhere but the binary import: loadGltf/loadModel
|
|
1364
1996
|
return promises, and the async value must be read the way Solid 2 async
|
|
@@ -1378,6 +2010,8 @@ a source glTF must be parsed at runtime, do the parse in an isolate
|
|
|
1378
2010
|
(parseGltf's result is plain data and copies across) and keep
|
|
1379
2011
|
createModel on main.
|
|
1380
2012
|
|
|
2013
|
+
### Placement and sockets
|
|
2014
|
+
|
|
1381
2015
|
Placement: pieces of one authored set (a body and its fitted cosmetics)
|
|
1382
2016
|
export in one world space, so composing them is `add(group, model)` per
|
|
1383
2017
|
piece and nothing else - no placement math. SOCKETED items (a weapon in
|
|
@@ -1385,8 +2019,8 @@ a hand) are different: they bind to a joint, so they only land once a
|
|
|
1385
2019
|
skeleton exists (a rig-less export cannot place them at all). The joint
|
|
1386
2020
|
is an ordinary Group in `model.nodes` - find it by name and `add()` the
|
|
1387
2021
|
item under it; it then follows the pose, mixer-driven or hand-posed,
|
|
1388
|
-
like any child transform. Two authoring cases
|
|
1389
|
-
|
|
2022
|
+
like any child transform. Two authoring cases: an item authored about
|
|
2023
|
+
its own socket
|
|
1390
2024
|
origin needs the plain `add()` and nothing more; one authored in the
|
|
1391
2025
|
RIG'S model space needs a socket Group between joint and item carrying
|
|
1392
2026
|
the joint's rest-pose inverse (at rest the item then sits exactly where
|
|
@@ -1395,6 +2029,34 @@ transform on top of the authored placement. Skinned PARTS are the one
|
|
|
1395
2029
|
thing that never needs this: they hang off the model root and the
|
|
1396
2030
|
palette places them.
|
|
1397
2031
|
|
|
2032
|
+
### Wardrobe pieces
|
|
2033
|
+
|
|
2034
|
+
Wardrobe pieces (a hood, a cape, cuffs) are the third case: exported
|
|
2035
|
+
WITH a skin over the body's joints and WITHOUT clips, so beside an
|
|
2036
|
+
animated body they hold their bind pose. `bindSkeleton(body, piece)`
|
|
2037
|
+
drives them from the body's skeleton - Three's SkinnedMesh.bind, Unity's
|
|
2038
|
+
`bones =`, Godot's shared Skeleton3D. The piece's palette rows re-bind
|
|
2039
|
+
onto the body's joint nodes (matched by name, case-insensitive) with
|
|
2040
|
+
the piece's own inverse binds, so the flush writes the body's pose into
|
|
2041
|
+
the piece's skin and no per-frame code exists; joints with no body
|
|
2042
|
+
counterpart (a hat's internal bones) and rigid parts hanging off a
|
|
2043
|
+
matched node are grafted under the matched body joint and ride it. The
|
|
2044
|
+
piece then hangs under the body at the body's placement (its skinned
|
|
2045
|
+
vertices are in the body's model space), and its own joint nodes are no
|
|
2046
|
+
longer posed by anything: read poses from and socket items on the
|
|
2047
|
+
BODY's joints. The piece's node tree is never composed - exporters
|
|
2048
|
+
truncate it above the spine or skip an ancestor in the middle, and
|
|
2049
|
+
copying locals through such a tree hangs joints off the wrong place
|
|
2050
|
+
while looking right for every piece whose tree happens to match; the
|
|
2051
|
+
body's world matrices are what a bone matrix needs. A shared joint
|
|
2052
|
+
whose bind pose differs (a piece exported against another rest pose or
|
|
2053
|
+
scale) throws at bind instead of rendering wrong. A piece comes off by
|
|
2054
|
+
disposing it; disposing a body disposes what it wears. Culling follows
|
|
2055
|
+
the wear: a body joint's box is the union of every skin reaching it,
|
|
2056
|
+
its own and the pieces'.
|
|
2057
|
+
|
|
2058
|
+
### Applied glTF material fields
|
|
2059
|
+
|
|
1398
2060
|
Applied: `doubleSided` (the default material draws it with `cull:
|
|
1399
2061
|
"none"`), alphaMode MASK (`alphaTest: alphaCutoff`), `normalTexture`
|
|
1400
2062
|
(+ scale; the derivative frame needs no tangents), `emissiveFactor` x
|
|
@@ -1402,14 +2064,26 @@ Applied: `doubleSided` (the default material draws it with `cull:
|
|
|
1402
2064
|
factor (a zero factor skips the map too - glTF's product rule, emission
|
|
1403
2065
|
off), `pbrMetallicRoughness` factors as `m.metalness`/`m.roughness` and
|
|
1404
2066
|
its packed texture as BOTH `maps.metalnessMap` and `maps.roughnessMap`
|
|
1405
|
-
(standard's channel-select options; the default `
|
|
1406
|
-
The `material(m, maps)` callback receives every
|
|
1407
|
-
by
|
|
1408
|
-
`maps.
|
|
1409
|
-
`data.materials` is in file order, so the calls
|
|
2067
|
+
(standard's channel-select options; the default `phong` ignores them).
|
|
2068
|
+
The `material(m, maps, skinned, vertexColors)` callback receives every
|
|
2069
|
+
uploaded texture by phong()/standard() option name (`maps.map`/
|
|
2070
|
+
`maps.normalMap`/`maps.emissiveMap`/`maps.metalnessMap`/
|
|
2071
|
+
`maps.roughnessMap`); `data.materials` is in file order, so the calls
|
|
2072
|
+
arrive in file order. A primitive's `COLOR_0` lands in its geometry's
|
|
2073
|
+
aColor (the "colored" layout, or the skinned list plus aColor for a
|
|
2074
|
+
rigged one; linear and premultiplied as glTF stores it) and the default
|
|
2075
|
+
material takes `vertexColors: true` for such parts - the callback's
|
|
2076
|
+
fourth argument says so, and a material shared by painted and unpainted
|
|
2077
|
+
parts is made once per variant, like the skinned split.
|
|
1410
2078
|
A `.srtm` baked before the material records carried the PBR fields
|
|
1411
|
-
(file version 3)
|
|
1412
|
-
|
|
2079
|
+
(file version 3), or before the vertex formats took the WebGPU spelling
|
|
2080
|
+
and byte counts (file version 5), is rejected by loadModel - re-bake
|
|
2081
|
+
with `srt tool 3d/model`. A baked part keeps a quantized export's
|
|
2082
|
+
bytes: u8 colors, u8/u16 joints and normalized weights, u16 uvs land in
|
|
2083
|
+
their own formats, positions and normals as floats.
|
|
2084
|
+
|
|
2085
|
+
### Animation
|
|
2086
|
+
|
|
1413
2087
|
Animation: `createMixer(model)` plays `model.clips` by name -
|
|
1414
2088
|
`mixer.play(name, { loop?, speed?, fadeMs? })` (fadeMs crossfades: the
|
|
1415
2089
|
named clip fades in, everything else fades out - Unity's CrossFade,
|
|
@@ -1437,6 +2111,9 @@ so a TRANSPARENT mesh parented under a player-animated joint does not
|
|
|
1437
2111
|
re-trigger the back-to-front re-sort while it animates (opaque meshes,
|
|
1438
2112
|
palettes and picking are unaffected) - nudge the scene with any
|
|
1439
2113
|
setTransform if it shows, until the core-side transparent sort lands.
|
|
2114
|
+
|
|
2115
|
+
### Root motion
|
|
2116
|
+
|
|
1440
2117
|
Root motion: `play(name, { inPlace? })` strips a clip's root travel
|
|
1441
2118
|
(Unity's applyRootMotion off, Godot's root_motion_track): the root's
|
|
1442
2119
|
x/z hold at the clip's first key and its height rebases onto the root's
|
|
@@ -1473,7 +2150,11 @@ horizontal travel (Unity's bake-into-pose Y - for a controller that
|
|
|
1473
2150
|
owns gravity). `inPlace` is ignored while rootMotion is set. Yaw is
|
|
1474
2151
|
the swing-twist about up (exact under any lean); a cubic rotation
|
|
1475
2152
|
track is linearized at 60 keys/s before its yaw is held. Verified on
|
|
1476
|
-
Mixamo's standing turns (external/mixamo-turn).
|
|
2153
|
+
Mixamo's standing turns (external/mixamo-turn).
|
|
2154
|
+
|
|
2155
|
+
### Skins
|
|
2156
|
+
|
|
2157
|
+
Skins need nothing further: each skin's uBones palette
|
|
1477
2158
|
(model-local jointWorld x inverseBind, sized to the RIG - an rgba32f
|
|
1478
2159
|
float texture, 4 texels wide, one row per joint, sampled in the vertex
|
|
1479
2160
|
stage via texelFetch, so there is no joint cap) is composed by the
|
|
@@ -1483,15 +2164,71 @@ computed-once texture. `sampleChannel` (pure, from the root) stays the
|
|
|
1483
2164
|
JS sampling core for checks and custom drivers;
|
|
1484
2165
|
okf/done/animation-core.md records the design.
|
|
1485
2166
|
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
2167
|
+
### Morph targets
|
|
2168
|
+
|
|
2169
|
+
Morph targets (blend shapes): a geometry carries named per-vertex
|
|
2170
|
+
deltas - `withMorphTargets(geometry, [{ name, position, normal? }])`
|
|
2171
|
+
for a hand-built shape, the loader's primitive targets for a glTF (its
|
|
2172
|
+
`extras.targetNames`, else `target<i>`; sparse accessors read) - packed
|
|
2173
|
+
SPARSE BY VERTEX on `geometry.morphs` (a header texel per vertex, then
|
|
2174
|
+
only the entries of the targets that move it; memory follows the
|
|
2175
|
+
authored deltas, never vertices x targets) and uploaded once per
|
|
2176
|
+
geometry as one rgba32f texture. A `morph: true` material (`phong`,
|
|
2177
|
+
`unlit`, `standard`; a custom class splices MORPH_DECLS and MORPH_APPLY
|
|
2178
|
+
from `@solidrt/3d/glsl` before its skin math) walks each vertex's
|
|
2179
|
+
entries in the vertex stage, before any skinning; a mesh under it
|
|
2180
|
+
whose geometry carries no targets is rejected at add(), and a plain
|
|
2181
|
+
material over morphed geometry draws the base shape. WEIGHTS belong to
|
|
2182
|
+
a NODE, glTF's `node.weights` model: a standalone mesh owns its own; a
|
|
2183
|
+
model's parts follow their glTF node (`model.nodes`), so a face split
|
|
2184
|
+
into skin, eyes and teeth parts takes one write. `setMorphWeights(node,
|
|
2185
|
+
{ smile: 0.7 })` writes by name (the others untouched), an array writes
|
|
2186
|
+
every target in order, the `<Mesh morphWeights>` prop is the
|
|
2187
|
+
declarative form; `getMorphWeights(node)` reads them back (from the
|
|
2188
|
+
core while in a scene), `getMorphNames(node)` lists the targets. The
|
|
2189
|
+
write lands in the spatial core's register (a row of a small weights
|
|
2190
|
+
texture, published at the flush like a skin palette - Three's
|
|
2191
|
+
morphTargetInfluences, Godot's set_blend_shape_value); a file's
|
|
2192
|
+
`mesh.weights` seed it. Weights ANIMATE two ways, both native: a glTF
|
|
2193
|
+
`weights` animation channel plays through the mixer like a TRS
|
|
2194
|
+
channel (into the owning node's register, crossfading across players
|
|
2195
|
+
by the same weighted average), and a write is a TARGET like a
|
|
2196
|
+
transform write - `setTransition(node, { weights: { duration: 300 } })`
|
|
2197
|
+
makes every setMorphWeights animate (springs by default; `from`/`exit`
|
|
2198
|
+
endpoints are one number per target, `delay` and `stagger` apply), and
|
|
2199
|
+
`setMorphWeights(node, { smile: 1 }, { duration: 300, bounce: 0.2 })`
|
|
2200
|
+
animates that one write on its own motion, declaration or not; a
|
|
2201
|
+
settle calls `onTransitionEnd` with component "weights". A playing
|
|
2202
|
+
weights track and a JS write are both producers of the register: the
|
|
2203
|
+
players advance before the frame's JS, so a write from onFrame wins
|
|
2204
|
+
that frame. `getMorphWeights` reads the core, so it shows a playing
|
|
2205
|
+
track and a mid-flight transition; the by-name partial write merges
|
|
2206
|
+
into the last JS-WRITTEN set, not the core's - write every target
|
|
2207
|
+
while a clip plays. An INSTANCED mesh morphs per instance: the
|
|
2208
|
+
population owns one weights texture with a row per record slot, each
|
|
2209
|
+
`<Instance>` (or addInstance node) owns a register published into its
|
|
2210
|
+
slot's row, and the shader reads the row by `gl_InstanceID` - so
|
|
2211
|
+
`setMorphWeights(instance, { smile: 1 })`, the `<Instance morphWeights>`
|
|
2212
|
+
prop, a `transition.weights` spring and a clip's weights track all work
|
|
2213
|
+
per copy, the shadow variants included, with no per-record data
|
|
2214
|
+
(Three's InstancedMesh morphs). Skinned plus morphed plus instanced
|
|
2215
|
+
composes. A custom class splicing MORPH_DECLS reads the row as
|
|
2216
|
+
`uMorphRow + gl_InstanceID`, which the stock decls already do. A record
|
|
2217
|
+
mesh (JS-written records, no nodes) cannot morph: rejected at add().
|
|
2218
|
+
|
|
2219
|
+
### Not in the subset
|
|
2220
|
+
|
|
2221
|
+
Not in the subset, dropped: tangents and further UV sets; samplers are
|
|
2222
|
+
ignored (every texture repeats); additive blending draws as base color.
|
|
2223
|
+
The follow-ups are filed in okf/backlog/3d-model-loader.md. The `.srtm`
|
|
2224
|
+
container is VERSION 8 (node table in the header, node-local vertices,
|
|
2225
|
+
skins, clips, packed morph targets); older bakes are rejected - re-bake
|
|
2226
|
+
with `srt tool 3d/model`.
|
|
1492
2227
|
|
|
1493
2228
|
## Traps
|
|
1494
2229
|
|
|
2230
|
+
### Models and skinning
|
|
2231
|
+
|
|
1495
2232
|
- A model's vertices are LOCAL to their node; the file's placement lives
|
|
1496
2233
|
in the node-table TRS, composed by the scene like any Group chain. So
|
|
1497
2234
|
a part's `geometry` alone is at the origin, `model.bounds` (rest-pose
|
|
@@ -1499,6 +2236,9 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
|
|
|
1499
2236
|
subtree. The winding flip for a mirroring node chain is baked into the
|
|
1500
2237
|
index order from the REST pose - re-scaling a node across zero at
|
|
1501
2238
|
runtime shows mesh interiors, so do not do that.
|
|
2239
|
+
- A worn piece's joints are dead after `bindSkeleton`: `piece.nodes`
|
|
2240
|
+
still lists them, but nothing poses them and their palette rows sit
|
|
2241
|
+
on the body's nodes now. Pose, read and socket on the body.
|
|
1502
2242
|
- Skinning is a VERTEX-STAGE effect: everything that runs off the
|
|
1503
2243
|
retained tree sees the bind pose. A skinned mesh picks by its
|
|
1504
2244
|
bind-pose triangles at the model root and its transparent sort key is
|
|
@@ -1506,81 +2246,29 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
|
|
|
1506
2246
|
MODEL moves both. Shadows are the exception: the shadow variants
|
|
1507
2247
|
(depth and cutout) skin by the same uBones palette, so a caster casts
|
|
1508
2248
|
its pose.
|
|
2249
|
+
- Morphing is the same kind of effect: picking and the transparent sort
|
|
2250
|
+
see the base shape, the shadow variants morph. The one thing the
|
|
2251
|
+
retained side knows is the box: `geometryBounds` (and a model's
|
|
2252
|
+
bounds) grow by the targets' extent, so a morphed shape is never
|
|
2253
|
+
culled while its weights stay in 0..1 - a weight past 1 can leave it.
|
|
2254
|
+
Normal deltas blend linearly like positions (Three's, Godot's and
|
|
2255
|
+
glTF's rule): exact at a target, an approximation between two, so a
|
|
2256
|
+
half-blend of EXTREME targets can show a ragged self-shadow
|
|
2257
|
+
terminator where the lit and shadowed halves disagree; a modeller's
|
|
2258
|
+
blend shapes are far too small for it to show.
|
|
2259
|
+
|
|
2260
|
+
### Materials and color
|
|
2261
|
+
|
|
1509
2262
|
- A `standard` metal in a scene with no environment renders near black:
|
|
1510
2263
|
its diffuse is zero and the black placeholder cube is all there is to
|
|
1511
2264
|
reflect (Three and Godot render the same; Unity falls back to an
|
|
1512
2265
|
ambient probe, this does not). glTF's default metallic factor is 1,
|
|
1513
2266
|
so an untextured asset is all metal, and createModel's default is
|
|
1514
2267
|
`standard`: give a model scene an `environment` (loadEnvironment's
|
|
1515
|
-
baked .srte, or the skybox's cube), or pass `
|
|
1516
|
-
-
|
|
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
|
-
- Light intensities are the same numbers for `lit` and `standard`: 1
|
|
2268
|
+
baked .srte, or the skybox's cube), or pass `phong` as the material.
|
|
2269
|
+
- Light intensities are the same numbers for `phong` and `standard`: 1
|
|
1528
2270
|
lights a white matte surface to 1 face-on. A Three scene's intensities
|
|
1529
2271
|
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
2272
|
- Transparency is an EXPLICIT material flag, Three's rule: `unlit({ color:
|
|
1585
2273
|
[r, g, b, 0.5] })` still draws opaque, and opaque means it: the standard
|
|
1586
2274
|
classes write alpha 1 when not `transparent` (the scene target is
|
|
@@ -1592,8 +2280,8 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
|
|
|
1592
2280
|
(or `shaderMaterial({ transparent: true })`) builds the pipeline with
|
|
1593
2281
|
`blend: "alpha"` and `depthWrite: false` (depth test stays on, so it hides
|
|
1594
2282
|
behind opaques without occluding other translucents). The one inference:
|
|
1595
|
-
a `shaderMaterial` with any `blend` but "none" is transparent
|
|
1596
|
-
`transparent: false` - every blended draw belongs after the opaques, and
|
|
2283
|
+
a stock material or `shaderMaterial` with any `blend` but "none" is transparent
|
|
2284
|
+
unless told `transparent: false` - every blended draw belongs after the opaques, and
|
|
1597
2285
|
back-to-front is harmless for add/multiply. The scene owns the
|
|
1598
2286
|
order: background, opaque meshes by `renderOrder` then add order,
|
|
1599
2287
|
transparent meshes by `renderOrder` then back-to-front by the CENTER of
|
|
@@ -1607,6 +2295,56 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
|
|
|
1607
2295
|
translucents can sort wrong (center distance, not per-pixel) - that is the
|
|
1608
2296
|
engine contract, no OIT. A `shaderMaterial({ transparent: true })`
|
|
1609
2297
|
fragment must write PREMULTIPLIED output (`vec4(rgb * a, a)`).
|
|
2298
|
+
- A color map created as plain rgba8 renders WASHED OUT: the fragment
|
|
2299
|
+
reads its encoded bytes as linear light and encodes them again. Create
|
|
2300
|
+
color images (base color, emissive, a sky's faces or panorama) with
|
|
2301
|
+
`format: "rgba8-srgb"`; keep data maps rgba8. A rendered rgba8 texture
|
|
2302
|
+
(a scene view, a shader target, a UI capture) holds encoded pixels and
|
|
2303
|
+
cannot be tagged, so as a `map` it needs `srgbToLinear` from SRGB in a
|
|
2304
|
+
custom fragment (no material option yet); a draw target you create
|
|
2305
|
+
yourself can be `format: "rgba8-srgb"` and then decodes on sample (it
|
|
2306
|
+
is sampler-only: no display, readback or copy).
|
|
2307
|
+
- Reading `scene.texture` back (readTexture, a snapshot) gives ENCODED
|
|
2308
|
+
pixels: an expected linear value v shows as `linearToSrgb(v) * 255` -
|
|
2309
|
+
intensity 0.5 reads 188, not 128. The buffer, `scene.hdrTexture`, is
|
|
2310
|
+
sampler-only (no readback, copy or display): read radiance through a
|
|
2311
|
+
`createShaderTexture` pass over it, scaled into 0..1.
|
|
2312
|
+
|
|
2313
|
+
### Environment and background
|
|
2314
|
+
|
|
2315
|
+
- A reflection probe is a mirrored render (x-flipped projection, winding
|
|
2316
|
+
inverted by the engine): anything built from screen-space derivatives
|
|
2317
|
+
flips with it, so a normal-mapped surface shows its bumps INVERTED in
|
|
2318
|
+
a probe's reflection. Known and shared with every engine's mirrored
|
|
2319
|
+
views; a `uMirror` sign on the derivative frame is the fix when it
|
|
2320
|
+
matters.
|
|
2321
|
+
- A generated cube chain (`mipmap: true` from six faces) is a box
|
|
2322
|
+
filter, not the GGX convolution the roughness-to-level rule assumes:
|
|
2323
|
+
rough reflections read too sharp and the diffuse `envIrradiance` is a
|
|
2324
|
+
4x4 average. Bake with `srt tool 3d/environment` for anything
|
|
2325
|
+
photographed; the JS sky gradients in the examples get away with it.
|
|
2326
|
+
- The background covers the whole target with depth off, drawn first: it
|
|
2327
|
+
REPLACES the clearColor visually (the clear still runs; you just never
|
|
2328
|
+
see it), and a `transparent: true` mesh blends over it in-pass since the
|
|
2329
|
+
background is always entry zero.
|
|
2330
|
+
- The background pipeline/program are SCENE-OWNED (unlike shared
|
|
2331
|
+
material pipelines): setBackground(null), replacement, and dispose()
|
|
2332
|
+
destroy them. Do not hand the background's pipeline to anything else.
|
|
2333
|
+
A skybox is the same slot with the library's fragment; only a
|
|
2334
|
+
skybox-to-skybox replace keeps the entry (params and cube rewritten).
|
|
2335
|
+
- The environment binds through the light rewrite's map set (uEnv
|
|
2336
|
+
beside uShadowAtlas on every receiving target, new views included) and
|
|
2337
|
+
directly on setEnvironment; the placeholder cube is app-lifetime like
|
|
2338
|
+
the shadow placeholder. A `phong` without `reflectivity` declares no
|
|
2339
|
+
environment sampler - the flag is part of the class key.
|
|
2340
|
+
|
|
2341
|
+
### Coordinates and rotation
|
|
2342
|
+
|
|
2343
|
+
- The y-down clip flip is baked into `perspective()`; scene code and
|
|
2344
|
+
geometry are plain y-up right-handed, and CCW-outward winding culls
|
|
2345
|
+
correctly with `cull: "back"`. Do NOT negate y anywhere else, and do not
|
|
2346
|
+
"fix" the negated row of `perspective()` - both would mirror the winding
|
|
2347
|
+
and show mesh interiors.
|
|
1610
2348
|
- Rotation is stored as a QUATERNION (`node.quaternion`, `[x, y, z, w]`,
|
|
1611
2349
|
always unit). There is exactly one rotation field: no `node.rotation`
|
|
1612
2350
|
shadowing it, because a second field is a second thing to go stale (an
|
|
@@ -1678,14 +2416,86 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
|
|
|
1678
2416
|
- Transforms have ONE write path: `setTransform`/`lookAt`/`setVisible` (or
|
|
1679
2417
|
the props that call them). Mutating `node.position` directly does not
|
|
1680
2418
|
sync. Components have no `lookAt` prop - aim through a `ref`.
|
|
1681
|
-
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
2419
|
+
- Vec3/Quat arguments are COPIED IN everywhere (`setTransform`, `lookAt`,
|
|
2420
|
+
`setCamera`, params), so ONE scratch array reused every frame is safe -
|
|
2421
|
+
allocating three arrays per node per frame is pure waste. The node's own
|
|
2422
|
+
`position`/`quaternion`/`scale` are the live arrays: read them, do not
|
|
2423
|
+
hand them out and do not mutate them (that write does not sync).
|
|
2424
|
+
- `setTransform` early-outs on an unchanged value (rotation compared AFTER
|
|
2425
|
+
euler conversion), so driving every node unconditionally from `onFrame`
|
|
2426
|
+
costs only the compare for nodes that did not move. Compares are exact,
|
|
2427
|
+
like `setVisible`.
|
|
2428
|
+
|
|
2429
|
+
### Visibility and instancing
|
|
2430
|
+
|
|
2431
|
+
- `visible: false` keeps the entry, drawn with `instanceCount: 0` (a
|
|
2432
|
+
cheap off switch); unhiding writes 1, or the mesh's own record count
|
|
2433
|
+
when it is instanced - never a bare 1 into an instanced entry. Hidden
|
|
2434
|
+
meshes skip uModel writes; the fresh matrix is
|
|
2435
|
+
written on unhide. A freshly attached entry starts off the same way and
|
|
2436
|
+
the core's flush turns it on when it writes uModel - never add one
|
|
2437
|
+
live: it has no world matrix yet, and drawn before the sync microtask it
|
|
2438
|
+
flashes at the world origin for a frame.
|
|
2439
|
+
- Instancing pairs strictly at add(), like layout: an instanced material
|
|
2440
|
+
needs a createInstancedMesh or createRecordMesh mesh and vice versa,
|
|
2441
|
+
and every instance buffer the material declares must be a layout the
|
|
2442
|
+
mesh carries - the matrix (INSTANCE_MATRIX_ATTRIBUTES, the first
|
|
2443
|
+
buffer of an instanced mesh) or a stream with the same attribute names
|
|
2444
|
+
AND formats (a byte-equal layout in other formats decodes differently)
|
|
2445
|
+
- each mismatch throws there, at creation for the mesh's own material
|
|
2446
|
+
and at add() for a swapped one. The instance buffers are MESH-owned
|
|
2447
|
+
(unlike shared geometry buffers): `disposeInstances` is their one
|
|
2448
|
+
free, and the mesh cannot be re-added afterwards. Capacity grows by
|
|
2449
|
+
REPLACEMENT, never resize: an addInstance or setRecords past capacity
|
|
2450
|
+
doubles into new buffers and swaps them in (an instanced mesh's live
|
|
2451
|
+
matrix records move in one core `retargetRecords`, every stream's
|
|
2452
|
+
mirror republishes) - amortized like a dynamic array, same policy as
|
|
2453
|
+
@solidrt/2d; size `capacity` or the initial records to skip the
|
|
2454
|
+
copies. A mesh's stream layouts come from its material AT CREATION: a
|
|
2455
|
+
later setMaterial to a class with another layout throws at the
|
|
2456
|
+
rebuild.
|
|
2457
|
+
- Record writes are mirrored, not immediate: `setInstanceStyle`,
|
|
2458
|
+
`setRecords` and an `instanceAttribute` write land in the stream's
|
|
2459
|
+
`data` and the scene's sync publishes the `dirty` range (`updateRecords`
|
|
2460
|
+
marks it for accessor writes) - so a write on a mesh outside any scene
|
|
2461
|
+
shows once it is added (the dirty range waits), and reading the GPU
|
|
2462
|
+
buffer back mid-frame can lag the mirror by one sync. Growth replaces
|
|
2463
|
+
`data`: hold the accessor, not the view.
|
|
2464
|
+
- The sugar is for FEW records, the mirror is for MANY. `setInstanceStyle`
|
|
2465
|
+
over an all-float layout is indexed stores; over a packed layout (the
|
|
2466
|
+
stock `float16x4` color included) it is a codec call per component,
|
|
2467
|
+
and so is every accessor write. Measured at ten thousand instances
|
|
2468
|
+
restyled every frame on a laptop under QuickJS: about 11 ms of JS per
|
|
2469
|
+
frame through setInstanceStyle on a float layout, 19 ms on the half
|
|
2470
|
+
color, 9 ms through the accessor, 5 ms through the mirror - and that
|
|
2471
|
+
5 ms is the app's own per-instance tint math, the write itself is
|
|
2472
|
+
free. A population restyled per frame writes the mirror in bulk:
|
|
2473
|
+
a typed view over `stream.data` (a Float32Array as handed out, a
|
|
2474
|
+
`Float16Array` over the stock color, indexed by slot times components)
|
|
2475
|
+
filled in a plain loop, then ONE `updateRecords(mesh, { first, count
|
|
2476
|
+
})` - zero per-component calls, the same shape as `setRecords`.
|
|
2477
|
+
- Instanced casters: `castShadow` on a populated mesh needs a depth pass
|
|
2478
|
+
with the instance placement in it - the stock materials' `instanced`
|
|
2479
|
+
carries one, a custom class needs `shadowVertex` (see shadows below);
|
|
2480
|
+
a class without one is skipped by shadow views, silently.
|
|
2481
|
+
- A record mesh without explicit `bounds` has no BVH leaf: it never
|
|
2482
|
+
picks, pointer events never target it, and its transparent sort key
|
|
2483
|
+
falls back to the node's world position. That is deliberate - records
|
|
2484
|
+
are opaque to the library, so any inferred box would be a guess. Supply
|
|
2485
|
+
`bounds` for anything pickable or transparent. An instanced mesh picks
|
|
2486
|
+
per instance regardless and culls by its instances' union; its
|
|
2487
|
+
`bounds` replace that union and give the transparent sort a center.
|
|
2488
|
+
- An instance node's own fields hold its LOCAL pose (relative to its
|
|
2489
|
+
parent, as for any node); the record the core writes is its pose
|
|
2490
|
+
relative to the MESH, composed through any group between them. A
|
|
2491
|
+
destroyed instance takes its children with it, like any destroyed
|
|
2492
|
+
node. `disposeInstances` on an instanced mesh flushes the
|
|
2493
|
+
core before freeing the buffer, so the destroyed instances' hiding
|
|
2494
|
+
writes land in a live buffer; keep that order if you ever free a
|
|
2495
|
+
record buffer by hand.
|
|
2496
|
+
|
|
2497
|
+
### Shadows
|
|
2498
|
+
|
|
1689
2499
|
- A CASTING light's position matters (nothing else about a directional
|
|
1690
2500
|
light's position does): the shadow camera is placed AT the light node's
|
|
1691
2501
|
world position, Three's rule, so a `castShadow` sun at the origin
|
|
@@ -1715,12 +2525,26 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
|
|
|
1715
2525
|
is a full extra pass over the casters plus a sampler unit on every
|
|
1716
2526
|
receiving program (MAX_LIGHTS of those are always bound, placeholders
|
|
1717
2527
|
included), so cast from the lights that matter, not all of them.
|
|
2528
|
+
|
|
2529
|
+
### Views and scene params
|
|
2530
|
+
|
|
2531
|
+
- A camera change is ONE `setTargetParams` write (uViewProj + uCamPos are
|
|
2532
|
+
target state), independent of mesh count - never reintroduce per-mesh
|
|
2533
|
+
camera writes (uEye-style per-mesh params are exactly the O(scene) cost
|
|
2534
|
+
the shared channel removed). Scene scale honestly: hundreds to a
|
|
2535
|
+
few thousand objects, bounded by the interpreter, not the GPU. A view
|
|
2536
|
+
is one more such write per camera change and one more entry per mesh
|
|
2537
|
+
at attach; a view's per-frame cost is the core's (one params write per
|
|
2538
|
+
sink per moved node), never JS.
|
|
1718
2539
|
- A mesh's entries are mirrored into every view at attach and dropped at
|
|
1719
2540
|
detach; `setGeometry`/`setMaterial` rebuild them everywhere. An
|
|
1720
2541
|
`overrideMaterial` is validated against every mesh's layout (at
|
|
1721
2542
|
createView for the meshes present, at add() for later ones) exactly like
|
|
1722
2543
|
a mesh's own material, so an override reading `aColor` throws for a
|
|
1723
|
-
|
|
2544
|
+
base-layout mesh. An instanced mesh the override cannot place (it
|
|
2545
|
+
declares no `instanceBuffers`) is skipped, which the view's output
|
|
2546
|
+
cannot show, so the scene warns once per view naming it and the count
|
|
2547
|
+
(over the settled set, at the flush after the attach). Views are disposed by the scene; `view.dispose()`
|
|
1724
2548
|
only for dropping one early.
|
|
1725
2549
|
- SCENE-WIDE uniforms go through that same shared channel via
|
|
1726
2550
|
`scene.setParams({ uTime })`, and this is the single highest-leverage
|
|
@@ -1732,34 +2556,12 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
|
|
|
1732
2556
|
however many meshes read it, with the motion itself in vertex shaders
|
|
1733
2557
|
off that one clock. `params`/`setMeshParams` is the PER-MESH answer and
|
|
1734
2558
|
is O(meshes) per frame; reach for it only when the value genuinely
|
|
1735
|
-
differs per mesh. (`scene.texture`
|
|
1736
|
-
`setTargetParams(scene.texture, ...)`
|
|
1737
|
-
the
|
|
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).
|
|
2559
|
+
differs per mesh. (`scene.texture` is the RESOLVE's id, not the buffer the meshes
|
|
2560
|
+
draw into, so `setTargetParams(scene.texture, ...)` reaches the
|
|
2561
|
+
resolve alone - setParams is the only spelling.)
|
|
2562
|
+
|
|
2563
|
+
### Shader materials
|
|
2564
|
+
|
|
1763
2565
|
- A `shaderMaterial` INSTANCE is the pipeline handle: identical sources
|
|
1764
2566
|
compile twice - no dedupe by source value (deliberate; hidden
|
|
1765
2567
|
content-keyed caches are the anti-pattern the GPU layer avoids). Create
|
|
@@ -1786,10 +2588,25 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
|
|
|
1786
2588
|
vertex source - a comment counts - selects the "colored" layout, and
|
|
1787
2589
|
the material then rejects standard geometry at add(). Do not mention
|
|
1788
2590
|
aColor you do not read.
|
|
2591
|
+
- A custom fragment that ends in `fragColor = vec4(...)` bypasses the
|
|
2592
|
+
scene: no fog (exposure, tone mapping and the encode still happen, in
|
|
2593
|
+
the resolve, so it must write LINEAR premultiplied light) - and a
|
|
2594
|
+
hand-rolled loop over `uLightDir` renders a spot light as a
|
|
2595
|
+
directional one (a lit rectangle on the floor, no cone). What you
|
|
2596
|
+
declare is what runs, and the engine injects nothing, so pick a tier
|
|
2597
|
+
(the three after the GLSL exports under "The model"): a stock fragment
|
|
2598
|
+
on your vertex stage,
|
|
2599
|
+
a `surface` function, or `SCENE` with `shadeBlinn`/`shadePbr` or
|
|
2600
|
+
`sceneLight` and `sceneOutput` at the end. Composing `FOG` and the
|
|
2601
|
+
`SHADOW_*` trio by hand still works and is no longer the shape.
|
|
2602
|
+
|
|
2603
|
+
### Picking, pointer and collision
|
|
2604
|
+
|
|
1789
2605
|
- Picking is triangle-accurate for ordinary meshes (`point` is a surface
|
|
1790
2606
|
point, hits carry `face`/`uv`/`normal`) but box-only for instanced
|
|
1791
|
-
meshes: there `point` is where the ray
|
|
1792
|
-
|
|
2607
|
+
meshes and for lines/points geometry: there `point` is where the ray
|
|
2608
|
+
meets the population `bounds` box (the geometry's box for lines),
|
|
2609
|
+
`normal` that face's, and `face`/`uv` are absent. Never present an
|
|
1793
2610
|
instanced hit as a surface hit. Both tiers run in the spatial core (Rust); never
|
|
1794
2611
|
add a per-triangle path in JS - rays at mesh scale are
|
|
1795
2612
|
interpreter-hostile, and the core already does it.
|
|
@@ -1801,50 +2618,10 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
|
|
|
1801
2618
|
leaf under a design size. Only a leaf whose layout size deliberately
|
|
1802
2619
|
differs from the target (supersampling) needs `handlersFor`, fed the
|
|
1803
2620
|
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
2621
|
- Hover (enter/leave) reacts to pointer MOTION only: a mesh animating
|
|
1815
2622
|
under a still pointer fires nothing until the next move - the same
|
|
1816
2623
|
limit the element hit test has (hit-test-per-frame is an open platform
|
|
1817
2624
|
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
2625
|
- overlap()/sweep() test SURFACES, the trimesh contract everywhere: a
|
|
1849
2626
|
volume wholly inside a closed mesh with no triangle in reach touches
|
|
1850
2627
|
nothing, and a body whose center has passed through a wall reports
|
|
@@ -1856,3 +2633,27 @@ older bakes are rejected - re-bake with `srt tool 3d/model`.
|
|
|
1856
2633
|
floor absorbs the vertical part (a body never creeps down a slope it
|
|
1857
2634
|
can stand on), and with `floorSnap: 0` a standing body reports no
|
|
1858
2635
|
floor unless its motion presses into one.
|
|
2636
|
+
|
|
2637
|
+
### Geometry and components
|
|
2638
|
+
|
|
2639
|
+
- Per-generator conventions - orientation, UV mapping, which axis a solid
|
|
2640
|
+
stands on, what a cap looks like - live on each generator's doc comment,
|
|
2641
|
+
not here. They are consistent (`plane`/`circle`/`ring` face +z, `torus`
|
|
2642
|
+
lies flat with the hole on y, discs and cylinder caps get a PLANAR disc
|
|
2643
|
+
map inscribed in the unit square) but the doc comment is the source.
|
|
2644
|
+
- Entry rebuild order: `setGeometry`/`setMaterial` re-add the entry at the
|
|
2645
|
+
list END and dirty the order, so the next sync() re-sorts and the mesh
|
|
2646
|
+
keeps its place. `_transparent` on the mesh is the flag AS ATTACHED
|
|
2647
|
+
(setMaterial swaps `mesh.material` before the rebuild, so _detach must
|
|
2648
|
+
not read the new material's flag).
|
|
2649
|
+
- `lathe` takes a CLOSED profile (a cross-section with thickness, or run
|
|
2650
|
+
to the axis at x = 0) - it is a solid of revolution, NOT Three's open
|
|
2651
|
+
polyline shell. An "open" outline must be closed by the author;
|
|
2652
|
+
otherwise the shape is simply wrong, there is no open-profile mode.
|
|
2653
|
+
- `useScene()`/`Group`/`Mesh` throw outside `<Scene>` (default-less
|
|
2654
|
+
context).
|
|
2655
|
+
- Geometry local bounds cache on the Geometry (like its GPU buffers and
|
|
2656
|
+
the core's picking copy): mutating `vertices` after a mesh used them
|
|
2657
|
+
leaves stale bounds, a stale GPU buffer AND a stale picking shape
|
|
2658
|
+
until `updateVertices` publishes the range - write through the
|
|
2659
|
+
accessor and call it, or make a new Geometry.
|