@solidrt/3d 0.0.54 → 0.0.56
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 +1121 -177
- package/README.md +15 -7
- package/examples/README.md +86 -5
- package/examples/aim.tsx +2 -3
- package/examples/cascades.tsx +20 -5
- package/examples/collision.tsx +250 -0
- package/examples/first-person.tsx +119 -0
- package/examples/fleet.tsx +194 -0
- package/examples/fog.tsx +173 -0
- package/examples/instanced.tsx +72 -32
- package/examples/lamps.tsx +92 -0
- package/examples/lit.tsx +3 -5
- package/examples/materials.tsx +150 -0
- package/examples/model-load.tsx +95 -0
- package/examples/model.glb +0 -0
- package/examples/model.tsx +32 -6
- package/examples/pick.tsx +2 -4
- package/examples/probe.tsx +101 -0
- package/examples/scene-background.tsx +2 -4
- package/examples/scene-basic.tsx +2 -4
- package/examples/scene-views.tsx +32 -3
- package/examples/shadows.tsx +2 -4
- package/examples/sky-lit.tsx +86 -0
- package/examples/skybox.tsx +128 -0
- package/examples/sprites.tsx +4 -6
- package/examples/standard.tsx +132 -0
- package/examples/sweep-paths.tsx +2 -4
- package/package.json +3 -3
- package/src/camera.ts +148 -0
- package/src/clip.ts +96 -0
- package/src/collision.ts +199 -0
- package/src/color.ts +32 -0
- package/src/components.tsx +773 -82
- package/src/environment-bake.ts +492 -0
- package/src/environment.ts +325 -0
- package/src/first-person.ts +306 -0
- package/src/geometry.ts +6 -2
- package/src/glsl.ts +1390 -82
- package/src/gltf.ts +432 -112
- package/src/index.ts +57 -9
- package/src/light.ts +383 -0
- package/src/material.ts +682 -216
- package/src/math.ts +110 -0
- package/src/mesh.ts +728 -0
- package/src/mixer.ts +476 -0
- package/src/model-file.ts +80 -15
- package/src/model.ts +211 -34
- package/src/node.ts +506 -0
- package/src/orbit.ts +97 -48
- package/src/scene-pointer.ts +166 -0
- package/src/scene-shadows.ts +471 -0
- package/src/scene.ts +1304 -1532
- package/tools/environment.ts +69 -0
- package/tools/model.ts +4 -3
package/AGENTS.md
CHANGED
|
@@ -24,6 +24,16 @@ blendMode and pointer events like any element.
|
|
|
24
24
|
in JS and why. The component face (`Scene`/`Group`/`Mesh`/
|
|
25
25
|
`PerspectiveCamera`) syncs props into that core over context and renders
|
|
26
26
|
nothing itself.
|
|
27
|
+
- Node lifecycle: `add(parent, child)` attaches (re-parenting detaches
|
|
28
|
+
first), `remove(child)` DETACHES an intact subtree - children stay
|
|
29
|
+
under the removed node, core nodes free on leave and recreate on
|
|
30
|
+
re-enter, so a removed subtree re-adds cleanly. Nothing is destroyed
|
|
31
|
+
except instance record buffers (`disposeInstances`) and the scene
|
|
32
|
+
itself. (@solidrt/2d's `removeGroup` DESTROYS its subtree instead - a
|
|
33
|
+
sprite cannot exist outside its layer, so there remove means destroy.)
|
|
34
|
+
An instance node (addInstance on an instanced mesh) follows the 2d
|
|
35
|
+
rule: slot-bound to its mesh, `removeInstance` destroys it, and the
|
|
36
|
+
generic add/remove throw on it.
|
|
27
37
|
- Rendering is the runtime's. The target is `render: "auto"`: it
|
|
28
38
|
re-renders when entries change, so a STATIC scene costs zero passes and
|
|
29
39
|
the library registers no frame loop. Continuous animation is the app's
|
|
@@ -37,24 +47,99 @@ blendMode and pointer events like any element.
|
|
|
37
47
|
its CORE node, so the one flush writes every target - the app writes
|
|
38
48
|
nothing per view. Geometry buffers and (without an override) materials
|
|
39
49
|
are shared; the light set and `scene.setParams` names fan out to every
|
|
40
|
-
view, `view.setParams` is the view's own channel
|
|
41
|
-
|
|
50
|
+
view, `view.setParams` is the view's own channel - and names a view
|
|
51
|
+
sets itself (or its `fog` option, below) become VIEW-OWNED: the
|
|
52
|
+
scene's setParams/setFog fan-out skips them from then on, so a view
|
|
53
|
+
override survives scene-wide writes. A view's backdrop is its
|
|
54
|
+
clearColor (the scene background draws on PROBES, not views); a view
|
|
55
|
+
has no picking. LAYERS select what a target
|
|
56
|
+
draws, Three's model exactly: `layers` on a mesh is its membership
|
|
57
|
+
bitmask (default 1, `setLayers`/the `layers` prop, NOT inherited from
|
|
58
|
+
Groups), and each target carries a mask (default 1) - `layers` on
|
|
59
|
+
createScene/createView, live via `setLayers` on the scene handle and
|
|
60
|
+
each view. A mesh draws where mask & layers is non-zero, so a minimap's
|
|
61
|
+
marker meshes live on bit 2: invisible in the main render, drawn by
|
|
62
|
+
the map view whose mask admits them. Shadow views follow the SCENE's
|
|
63
|
+
mask (what the scene cannot see must not darken it), and
|
|
64
|
+
pick()/raycast()/overlap()/sweep() skip scene-masked-out meshes like
|
|
65
|
+
invisible ones - unless the query passes its own `{ layers }`, which is
|
|
66
|
+
how a low-poly collision mesh lives undrawn in the scene yet answers
|
|
67
|
+
ground and collision queries (the physics-collider pattern).
|
|
68
|
+
Per-view fog: `fog: FogOptions | null` on createView overrides the
|
|
69
|
+
scene's fog for that view (null = unfogged - the clear minimap over a
|
|
70
|
+
fogged scene); absent follows the scene. `overrideMaterial` (Three's
|
|
42
71
|
`scene.overrideMaterial`, scoped to the view) draws every mesh with one
|
|
43
72
|
material - a depth pass, a normal/id visualizer - skips instanced
|
|
44
|
-
meshes (the override
|
|
45
|
-
order. `depth: "texture"` exposes `view.depthTexture`, the shadow-map
|
|
46
|
-
input
|
|
73
|
+
meshes (unless the override itself declares their exact
|
|
74
|
+
`instanceAttributes` record layout) and draws in add order. `depth: "texture"` exposes `view.depthTexture`, the shadow-map
|
|
75
|
+
input; the same option on createScene exposes `scene.depthTexture`,
|
|
76
|
+
the input for a depth-reading post effect in `output` (not combinable
|
|
77
|
+
with `samples` - no multisampled sampleable depth). `ortho: { left, right, top, bottom }` on any camera swaps
|
|
47
78
|
perspective for `orthographic()` (`fov` ignored; `ortho: null` returns);
|
|
48
79
|
the scene's own camera takes it too, and pick() follows.
|
|
49
80
|
`examples/scene-views.tsx` is the shape.
|
|
81
|
+
- CULLING is the core's, per target, on by default: every camera write
|
|
82
|
+
(the scene's, a view's, a shadow tile's) also sets that target's
|
|
83
|
+
frustum in the spatial core, and the flush switches an entry whose
|
|
84
|
+
world box falls wholly outside it to instance count 0 - the same
|
|
85
|
+
switch as `visible`, so a culled mesh costs nothing per frame and a
|
|
86
|
+
still camera re-tests nothing (a camera move re-tests every sink on
|
|
87
|
+
that target, in Rust; a node move re-tests its own). The box is the
|
|
88
|
+
picking box (the local bounds through the world matrix, the
|
|
89
|
+
Godot/Unity AABB test; Three uses spheres); an instanced mesh without
|
|
90
|
+
explicit `bounds` is culled by the union of its live instances' boxes
|
|
91
|
+
(the joint group below over the instance nodes, so the box follows
|
|
92
|
+
them: Godot's MultiMesh AABB, with `bounds` as its custom_aabb; Three
|
|
93
|
+
computes an InstancedMesh sphere once and leaves it stale, Unity wants
|
|
94
|
+
worldBounds), and a record mesh without `bounds` is never culled. Per
|
|
95
|
+
mesh: `frustumCulled: false` (Three's name; `setCulling`) for geometry
|
|
96
|
+
a vertex stage moves beyond its box - a fullscreen quad, a custom
|
|
97
|
+
displacement - and `cullMargin` (world units, Godot's
|
|
98
|
+
`extra_cull_margin`) for bounded displacement such as wind. Sprites
|
|
99
|
+
cull by their quad's reach at any facing. A SKINNED part is culled by
|
|
100
|
+
the union of its joints' boxes (the bake computes each joint's
|
|
101
|
+
influence box in joint space, `ModelSkin.jointBounds`, .srtm VERSION
|
|
102
|
+
5; the joint nodes carry them as culling-only bounds, outside the
|
|
103
|
+
picking index), so the box follows the pose with no per-frame JS -
|
|
104
|
+
Unity's bone bounds, Godot's per-bone AABBs; no `updateWhenOffscreen`
|
|
105
|
+
knob is needed. Probe faces set no frustum (six cameras, one target).
|
|
106
|
+
Shadow tiles cull casters against their light frustum, which is what
|
|
107
|
+
makes `shadow.distance` and cascades cheaper.
|
|
50
108
|
- SHADOWS are a view: `<DirectionalLight castShadow shadow={{ mapSize?,
|
|
51
|
-
bias?, normalBias?, camera? }}>` (`createDirectionalLight({ castShadow,
|
|
109
|
+
bias?, normalBias?, radius?, camera? }}>` (`createDirectionalLight({ castShadow,
|
|
52
110
|
shadow })`, `setLight`) makes the scene own an internal
|
|
53
111
|
`createView({ depth: "texture", overrideMaterial: depth pass })` drawing
|
|
54
112
|
the `castShadow` meshes (`<Mesh castShadow>`, `setCastShadow`) from an
|
|
55
113
|
orthographic camera at the light's WORLD position along its world
|
|
56
114
|
direction, `shadow.camera` (+-5, 0.5..500 by default) as the frustum.
|
|
57
|
-
Any
|
|
115
|
+
Any light may cast, bounded by the shadow-slot budget
|
|
116
|
+
(MAX_SHADOW_MAPS = 8, its own constant: a directional light claims
|
|
117
|
+
`shadow.cascades` consecutive slots, a point light six, a spot one,
|
|
118
|
+
and a caster past the budget throws at attach). `<SpotLight castShadow
|
|
119
|
+
shadow={{ mapSize?, bias?, normalBias?, near? }}>` is the same
|
|
120
|
+
machinery with a PERSPECTIVE camera: at the light's world position
|
|
121
|
+
along its world direction, fov = its cone (2 * angle), near from
|
|
122
|
+
`shadow.near` (default 0.5), far from the light's `distance` (or the
|
|
123
|
+
directional default 500 when 0) - one map, one slot, the same atlas
|
|
124
|
+
and lookup. A perspective map's depth is nonlinear, so `normalBias`
|
|
125
|
+
(world units) is the acne knob to reach for; `bias` acts in that
|
|
126
|
+
nonlinear depth. `<PointLight castShadow>` casts in every direction
|
|
127
|
+
with the same option set: six 90-degree face maps (world-axis
|
|
128
|
+
aligned, slot order +X, -X, +Y, -Y, +Z, -Z) as six consecutive tiles
|
|
129
|
+
of the same atlas, far from `distance` like a spot - so give a
|
|
130
|
+
casting bulb a distance. No cube map: a receiver picks the face by
|
|
131
|
+
the dominant axis of the light-to-point vector (SHADOW_LOOKUP), one
|
|
132
|
+
projection, one hardware-compare tap - the Three/Godot/Unity-URP
|
|
133
|
+
atlas route. `shadow.radius` (every casting light, default 1 = that
|
|
134
|
+
one tap, a 2x2 bilinear compare) softens the edge: above 1 a 3x3 grid
|
|
135
|
+
of hardware taps `radius` texels apart, Three's `shadow.radius` /
|
|
136
|
+
Godot's `shadow_blur`, nine taps per receiver fragment. A first-person
|
|
137
|
+
eye over a floor shows the texel stairs at any map size; 2 is the
|
|
138
|
+
usual figure, past ~3 the taps separate into bands. Each face map renders a few degrees wider than its face
|
|
139
|
+
(URP's fovBias) so a seam fragment's occluder is inside the map it
|
|
140
|
+
samples - without the guard band every seam shows a lit slit - and
|
|
141
|
+
PCF taps clamp at face-tile edges, so a face seam hardens slightly
|
|
142
|
+
instead of bleeding into the neighbour.
|
|
58
143
|
`shadow: { cascades: N }` (1..MAX_CASCADES = 4) replaces the box with
|
|
59
144
|
N maps fitted to slices of the SCENE camera's frustum (near ..
|
|
60
145
|
`shadow.distance`, default the camera far; the practical split; each
|
|
@@ -82,7 +167,8 @@ blendMode and pointer events like any element.
|
|
|
82
167
|
(Godot's and Three's default); `lit({ receiveShadow: false })` opts a
|
|
83
168
|
material out and drops the map from its program - a material option,
|
|
84
169
|
as with vertexColors/triplanar, because the material picks the program
|
|
85
|
-
(Godot's `disable_receive_shadows`). The factor is `SHADOW`'s
|
|
170
|
+
(Godot's `disable_receive_shadows`). The factor is `SHADOW`'s one
|
|
171
|
+
hardware-compare tap (sampler2DShadow, LEQUAL, the driver's 2x2 PCF)
|
|
86
172
|
on each casting light's own term. `examples/shadows.tsx` (three
|
|
87
173
|
casting lights) is the shape; `examples/cascades.tsx` the cascaded sun.
|
|
88
174
|
- RETARGETED motion is native: `setTransition(node, { position:
|
|
@@ -146,18 +232,31 @@ blendMode and pointer events like any element.
|
|
|
146
232
|
`gltf-check`) run on
|
|
147
233
|
flux from the repo root: `bunx srt bundle -f --stdout
|
|
148
234
|
packages/3d/checks/<name>.ts | target/release/flux -`. Run the ones
|
|
149
|
-
touching what you changed.
|
|
235
|
+
touching what you changed. `raycast-check.tsx` and
|
|
236
|
+
`collision-check.tsx` are the exceptions: they assert the documented
|
|
237
|
+
picking contract (triangle accuracy, the box tier, pick/raycast
|
|
238
|
+
parity, layer masks, the `{ meshes }` filter) and collision contract
|
|
239
|
+
(exact sweep times, the surface rule, the slide filter, layers and
|
|
240
|
+
meshes on overlap/sweep, moveAndSlide's landing) against a real scene,
|
|
241
|
+
so they run on the playback client instead:
|
|
242
|
+
`bunx srt render packages/3d/checks/<name>.tsx --project --duration 3
|
|
243
|
+
--size 128x128`. Run them whenever a doc edit touches picking or
|
|
244
|
+
collision claims - two copies of this contract have drifted before.
|
|
150
245
|
|
|
151
246
|
## Components
|
|
152
247
|
|
|
153
248
|
| Component | Props |
|
|
154
249
|
| --- | --- |
|
|
155
|
-
| `Scene` | `width
|
|
250
|
+
| `Scene` | `width?`, `height?` (target pixels - both, or neither = FILL, below), `clearColor?`, `camera?` (partial CameraUpdate, `ortho` included - the declarative scene.setCamera; same state as `PerspectiveCamera`, use one form), `background?` (fragment GLSL, or a skybox `{ cube, intensity?, rotation? }`), `environment?` (`{ cube, intensity?, rotation? }`, the cube reflective materials mirror), `fog?` (`{ color, near, far }`, linear by camera distance), `toneMapping?` (`"none"` default or `"aces"`), `exposure?` (default 1), `layers?` (target mask, default 1), `depth?` (`"texture"` exposes scene.depthTexture; not with samples), `samples?` (1/2/4/8 MSAA), `label?`, `ref?(scene)`, `output?(texture)`, `events?` (mesh pointer events, default on) |
|
|
156
251
|
| `Group` | `position?`, `rotation?` (Euler radians, XYZ order), `quaternion?` (either, not both), `scale?` (number = uniform), `visible?`, pointer events (below), `ref?(node)` |
|
|
157
|
-
| `Mesh` | `geometry`, `material`, transforms as Group, `params?` (per-mesh uniforms, merge semantics - no unset), pointer events (below), `ref?(mesh)` |
|
|
252
|
+
| `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)` |
|
|
158
253
|
| `Sprite` | as Mesh minus `geometry`: a camera-facing unit quad, `scale` is its world size, rotation is ignored; pair with a `sprite()` material |
|
|
159
|
-
| `InstancedMesh` | as Mesh
|
|
160
|
-
| `
|
|
254
|
+
| `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 |
|
|
255
|
+
| `Instance` | one instance of the enclosing `InstancedMesh`: transforms, `transition`, pointer events as Group, plus `style?` (the material's slot-1 floats - `[r, g, b, a]` under `instanceColors`), `ref?(instance)`; a parent too (a `<Mesh>` under an instance rides with it) |
|
|
256
|
+
| `RecordMesh` | as Mesh, plus `records` (interleaved per-instance floats; buffer capacity starts at the first value and grows on larger rewrites), `count?` (records drawn, default all), `bounds?` (local [minX..maxZ] over the population - without it the mesh never picks); the record buffer is component-owned and freed on unmount |
|
|
257
|
+
| `PerspectiveCamera` | `fov?` (vertical DEGREES, default 60), `near?`, `far?`, `position?`, `lookAt?`, `up?` - or the Scene `camera` prop, the same state (last write wins) |
|
|
258
|
+
| `SpotLight` | transforms as Group, `direction?` (local aim, default [0, -1, 0]), `color?`, `intensity?`, `distance?` (falloff cutoff, 0 = none), `angle?` (cone half-angle DEGREES, default 60), `penumbra?` (0..1 rim fade, default 0), `decay?` (falloff exponent, default 2), `castShadow?`, `shadow?` (mapSize, bias, normalBias, near), `ref?(light)` |
|
|
259
|
+
| `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)` |
|
|
161
260
|
|
|
162
261
|
Output composition: without `output`, `Scene` emits a minimal
|
|
163
262
|
`<texture width height>` leaf and nothing else is forwarded - anything
|
|
@@ -171,30 +270,33 @@ elsewhere. Called once, untracked, inside the scene context. Scene
|
|
|
171
270
|
layout, so render and display size separate - render at 2x and display
|
|
172
271
|
smaller for supersampling.
|
|
173
272
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
layout differs from the target
|
|
196
|
-
`handlers`; `useScene()` works inside
|
|
197
|
-
scene context.
|
|
273
|
+
Fill (the default): omit `width`/`height` and the built-in leaf is laid
|
|
274
|
+
out at 100% of its parent's box (give it a sized parent, as on the web)
|
|
275
|
+
while the target follows the leaf's on-screen size in DEVICE pixels -
|
|
276
|
+
display scale, `designSize` fits and ancestor transforms included
|
|
277
|
+
(getBoundingBoxViewport x displayScale, applied from onLayout, so no
|
|
278
|
+
frame draws at a stale size). A bare `<Scene>` in a pane renders at
|
|
279
|
+
native density on any display, mesh events and `<OrbitCamera>` input are
|
|
280
|
+
wired automatically (event scaling reads the leaf's untransformed box
|
|
281
|
+
back with `getLayoutBox`), and the camera aspect follows the box. Fill
|
|
282
|
+
or fixed is decided at mount; `output` requires explicit sizes (the
|
|
283
|
+
target cannot follow a leaf it does not own), and giving exactly one of
|
|
284
|
+
width/height throws.
|
|
285
|
+
|
|
286
|
+
Fixed sizes still matter where the target is a measured quantity: probes
|
|
287
|
+
and checks that snapshot exact pixels, supersampling via `output`, or a
|
|
288
|
+
scene composited at a size unrelated to its layout. `width`/`height` are
|
|
289
|
+
DEVICE pixels and the leaf's layout is LOGICAL - a fixed 720-pixel scene
|
|
290
|
+
under a HiDPI `designSize` fit is stretched across ~1100 device pixels
|
|
291
|
+
and looks soft, and nothing warns; that trap is what fill removes (the
|
|
292
|
+
examples fill; scene-views and scene-post-effect keep fixed sizes to
|
|
293
|
+
show multi-view composition and supersampling). A custom `output` leaf
|
|
294
|
+
whose layout differs from the target
|
|
295
|
+
takes `handlersFor` (below), not `handlers`; `useScene()` works inside
|
|
296
|
+
`output` because it runs in the scene context. With an `<OrbitCamera>`
|
|
297
|
+
(or any SceneInput listener) in the scene, also spread
|
|
298
|
+
`{...useScene().input.handlersFor(layout)}` on the leaf - the mesh-event
|
|
299
|
+
and control-input channels are separate spreads with the same layout.
|
|
198
300
|
|
|
199
301
|
Camera control: `createOrbitCamera(scene, { target?, azimuth?, elevation?,
|
|
200
302
|
distance?, min/maxDistance?, min/maxElevation?, orbitSpeed?, rotateSpeed?,
|
|
@@ -214,44 +316,168 @@ a pan may put the pivot. Zoom aims
|
|
|
214
316
|
at the target unless `zoomAnchor(x, y, {eye, target})` maps the pinch focal
|
|
215
317
|
/ wheel cursor to a world point (ground hit, target-depth plane, ...) - then
|
|
216
318
|
that point stays pinned under the pointer and the target slides toward it;
|
|
217
|
-
only the app can build that mapping, since fov
|
|
218
|
-
|
|
319
|
+
only the app can build that mapping, since fov and aspect are app state
|
|
320
|
+
(the point arrives in the input element's own pixels). Pair it with `rotateAnchor({eye, target})`: called at gesture
|
|
219
321
|
start, its point is projected onto the view axis and re-seats the pivot
|
|
220
322
|
without moving the picture, so a drag after an anchored zoom orbits what the
|
|
221
323
|
camera looks at, not wherever the zoom left the target. Spread
|
|
222
324
|
`orbit.handlers` onto the input-owning element, call `orbit.update(dt)`
|
|
223
325
|
from your onFrame (no frame loop of its own), and use its return - true
|
|
224
326
|
when the pose changed - to gate per-frame dependents like reprojecting
|
|
225
|
-
HUD overlays. `orbiting()`
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
327
|
+
HUD overlays. `orbiting()` (the auto-orbit switch) and `active()` (the
|
|
328
|
+
frame-loop gate: orbiting with a non-zero rate - the predicate the 2d
|
|
329
|
+
camera and the first-person camera share) are reactive (HUD-safe); the
|
|
330
|
+
pose is plain state via `pose()`/`set()` (also the debug-command shape). It drives position and
|
|
331
|
+
target only; fov/near/far stay on scene.setCamera (or the Scene `camera`
|
|
332
|
+
prop).
|
|
333
|
+
|
|
334
|
+
In a component tree, skip the wiring: `<OrbitCamera azimuth={1.2}
|
|
335
|
+
distance={7} />` as a Scene child reaches the scene through context,
|
|
336
|
+
receives input from the scene's leaf (the built-in one automatically; a
|
|
337
|
+
custom `output` leaf spreads `{...useScene().input.handlersFor(layout)}`
|
|
338
|
+
beside its scene.handlersFor spread, same `layout`), defaults `viewport`
|
|
339
|
+
to the leaf's laid-out size plus the scene camera's fov, and pushes input
|
|
340
|
+
poses synchronously - no ref plumbing, no onFrame. Auto-orbit runs a
|
|
341
|
+
frame loop only while `active()`, so a paused camera keeps the app
|
|
342
|
+
demand-driven idle. The pose props are
|
|
343
|
+
initial values: runtime pose changes (and the debug-command hookup) go
|
|
344
|
+
through `ref`'s handle, whose set() also pushes the pose. Every other
|
|
345
|
+
prop is live - forwarded to the control as a getter and read where it
|
|
346
|
+
applies, never snapshotted - so clamps, rates, anchors and `viewport`
|
|
347
|
+
follow their props without a remount, and a clamp change re-clamps the
|
|
348
|
+
pose at once.
|
|
349
|
+
|
|
350
|
+
First-person control: `createFirstPersonCamera(scene, { position?, yaw?,
|
|
351
|
+
pitch?, min/maxPitch?, moveSpeed?, lookSpeed?, fly?, viewport?,
|
|
352
|
+
clampPosition? })` - a position plus yaw/pitch (yaw 0 faces -z, positive
|
|
353
|
+
turns left; pitch positive looks up), Unity's FirstPersonController shape
|
|
354
|
+
(look AND move in one control) where Three splits PointerLockControls
|
|
355
|
+
from a hand-written key loop. Look comes from pointer-move
|
|
356
|
+
movementX/movementY while `pointerLocked()`, from a one-finger drag
|
|
357
|
+
(arena-arbitrated through createTransform, viewport-relative with
|
|
358
|
+
`viewport`) while not, and from the right stick; move from WASD/arrows
|
|
359
|
+
(physical codes and logical keys both), the left stick, and Q/E for
|
|
360
|
+
down/up - bound always, inert unless `fly` is on. Walking (the default)
|
|
361
|
+
flattens the heading onto the ground plane at fixed height. Every option
|
|
362
|
+
but the initial pose is read where it applies (`fly` per update,
|
|
363
|
+
`clampPosition` per move, the rates and pitch clamps per input), so a
|
|
364
|
+
field changed on the options object takes effect on the next move: walk
|
|
365
|
+
and fly are one control. The control NEVER calls `lockPointer`
|
|
366
|
+
- click-to-lock and Escape-to-release are the app's window-level
|
|
367
|
+
decisions (see `examples/first-person.tsx`) - and has no collision of
|
|
368
|
+
its own: `clampPosition(next, current)` is the whole hook - bounds, a
|
|
369
|
+
floor height, or `moveAndSlide` over `next - current` against the
|
|
370
|
+
collision layer (see `examples/collision.tsx`). Spread `handlers` (pointer + key +
|
|
371
|
+
onBlur; keys reach only the FOCUSED node, so that element must hold
|
|
372
|
+
focus or be the window), call `update(dt)` from onFrame; `active()` is
|
|
373
|
+
reactive - a key held or a stick deflected - and gates the loop. Keys
|
|
374
|
+
cannot be polled (core has no key-state accessor), so held keys are
|
|
375
|
+
tracked from the down/up pair and `onBlur` drops them.
|
|
376
|
+
|
|
377
|
+
`<FirstPersonCamera>` as a Scene child wires all of it: with a
|
|
378
|
+
key-driven control registered, the built-in leaf becomes `focusable`
|
|
379
|
+
and takes focus on pointer down (the web canvas gesture), which routes
|
|
380
|
+
the keys to the control; a pointer-only scene (`<OrbitCamera>`) never
|
|
381
|
+
steals focus. A custom `output` leaf spreads
|
|
382
|
+
`useScene().input.handlersFor(layout, () => node)` and declares
|
|
383
|
+
`focusable` itself. The frame loop runs only while `active()`. Every prop
|
|
384
|
+
but the initial pose is live: `fly={flying()}` toggles walk/fly on the
|
|
385
|
+
running control (pose and held keys carry over, no remount, and
|
|
386
|
+
`clampPosition` may swap with it), `moveSpeed`/`lookSpeed` follow their
|
|
387
|
+
props, and a pitch clamp change re-clamps at once.
|
|
229
388
|
|
|
230
389
|
Overlay projection: `scene.project(point)` maps a world point to scene
|
|
231
390
|
pixels (top-left origin, y down - the output texture's own space; `w` is
|
|
232
|
-
|
|
233
|
-
at or behind
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
391
|
+
the camera-forward distance in world units under either projection) and
|
|
392
|
+
returns null for a point at or behind a PERSPECTIVE camera's plane; an
|
|
393
|
+
ortho camera places every point (`w` may be <= 0 there - negative near
|
|
394
|
+
is legal ortho). It reflects a pending `setCamera` immediately, so
|
|
395
|
+
set-then-project in one tick is exact. `scene.unproject(x, y, w, out?)`
|
|
396
|
+
is its exact inverse: the world point at that pixel and camera-forward
|
|
397
|
+
distance `w` - project()'s `w` round-trips in both modes (the
|
|
398
|
+
drag-at-depth recipe: project the grabbed point once, keep its `w`,
|
|
399
|
+
unproject each move). `scene.viewProj(out?)` copies the view-projection
|
|
400
|
+
matrix for batch work. Never rebuild the camera matrices by hand for a
|
|
401
|
+
HUD.
|
|
237
402
|
|
|
238
403
|
Picking: `scene.pick(x, y)` is project()'s inverse - the camera ray
|
|
239
404
|
through a scene pixel, returning `Hit[]` (`{ mesh, distance, point }`,
|
|
240
405
|
world units, nearest first; every hit along the ray, not just the front
|
|
241
|
-
one). `scene.raycast(origin, direction)` is the world-space
|
|
242
|
-
under it.
|
|
406
|
+
one). `scene.raycast(origin, direction, opts?)` is the world-space
|
|
407
|
+
primitive under it, and `scene.screenRay(x, y)` the ray itself
|
|
408
|
+
(`{ origin, direction }`; direction's camera-forward component is 1, so
|
|
409
|
+
`origin + w * direction` = unproject) for intersection work pick cannot
|
|
410
|
+
do - drag planes, ground grids, filtered raycasts. All three cast
|
|
411
|
+
exactly the same ray. raycast's `opts` filters the query:
|
|
412
|
+
`{ layers }` (Unity's layerMask) replaces the scene's mask for this ray,
|
|
413
|
+
and `{ meshes }` (Three's intersectObjects) is an include-list; a
|
|
414
|
+
per-frame ground query passes one or the other instead of skipping
|
|
415
|
+
skyboxes and actors by hand.
|
|
243
416
|
The index and the narrowphase live in the spatial core: every attached
|
|
244
417
|
mesh's local box is a leaf in a dynamic AABB tree the flush refits from
|
|
245
418
|
the fresh world matrices (O(moved) per frame, a query O(log meshes)), and
|
|
246
419
|
an ordinary mesh is then tested per triangle against its geometry's
|
|
247
420
|
shape (one CPU copy per distinct geometry, created with its GPU buffers),
|
|
248
421
|
so hits carry `face`, `uv` and a world-space `normal` facing the ray, and
|
|
249
|
-
a ray through a knot's hole misses.
|
|
250
|
-
|
|
251
|
-
|
|
422
|
+
a ray through a knot's hole misses. A large geometry's triangles are
|
|
423
|
+
BVH-indexed too - built by the first ray that reaches the shape, log-cost
|
|
424
|
+
after - so raycasting a merged static scene stays cheap (see the batching
|
|
425
|
+
advice). An instanced mesh is box-only (its
|
|
426
|
+
explicit population bounds; records are opaque): it is tested by the
|
|
427
|
+
box's twelve triangles, so its hits carry the struck face's `normal` and
|
|
428
|
+
no `face`/`uv` - and a ray from inside it meets the far side, the
|
|
429
|
+
surface contract overlap/sweep share. Both methods
|
|
252
430
|
flush pending writes first (the lookAt/project immediacy contract), and
|
|
253
431
|
both skip invisible meshes.
|
|
254
432
|
|
|
433
|
+
Collision: `scene.overlap(volume, opts?)` and `scene.sweep(volume,
|
|
434
|
+
motion, opts?)` are the same index's other two questions - what a volume
|
|
435
|
+
touches, and where a moving one first touches: Unity's
|
|
436
|
+
OverlapSphere/Box/Capsule and SphereCast/CapsuleCast/BoxCast, Godot's
|
|
437
|
+
intersect_shape and cast_motion (Three has the raycaster alone; every
|
|
438
|
+
Three game adds a physics library or three-mesh-bvh for this). A
|
|
439
|
+
`Volume` is a sphere (`{ center, radius }`), a capsule (`{ a, b,
|
|
440
|
+
radius }`, the radius swept along the segment - a character) or an
|
|
441
|
+
oriented box (`{ center, halfExtents, rotation? }`). overlap returns
|
|
442
|
+
`Overlap[]` - `{ mesh, point, normal, depth }`, the deepest contact per
|
|
443
|
+
mesh: the point on the mesh, the unit direction out of it and the depth
|
|
444
|
+
along it that clears the contact (Godot's get_rest_info, Unity's
|
|
445
|
+
ComputePenetration, per hit; unordered) - and sweep `Impact[]` -
|
|
446
|
+
`{ mesh, time, point, normal }`, per mesh its first touch with `time`
|
|
447
|
+
the fraction of the motion, earliest first. Both take raycast's `opts`
|
|
448
|
+
(`{ layers, meshes }`) and test per triangle in WORLD space against the
|
|
449
|
+
same shapes, so any transform holds, an instanced mesh or sprite counts
|
|
450
|
+
by its box, and the physics-collider pattern is one `{ layers }` away.
|
|
451
|
+
Two contracts to know: the tests are SURFACES (a volume wholly inside a
|
|
452
|
+
closed mesh with no triangle in reach touches nothing - the trimesh rule
|
|
453
|
+
in every engine), and a sweep from a volume already in contact reports
|
|
454
|
+
time 0 only while the motion closes in; leaving or sliding along the
|
|
455
|
+
contact is no hit, which is what lets a slide along a wall proceed. A
|
|
456
|
+
zero motion touches nothing.
|
|
457
|
+
`moveAndSlide(scene, volume, motion, opts?)` is the controller over
|
|
458
|
+
them: Godot's CharacterBody3D.move_and_slide and Unity's
|
|
459
|
+
CharacterController.Move as one PURE function - no node, no velocity;
|
|
460
|
+
the first-person camera composes it in `clampPosition`, a node-driven
|
|
461
|
+
body applies `result.motion` with setTransform. It pushes the body out
|
|
462
|
+
of anything it starts inside, sweeps, stops a skin short, slides the
|
|
463
|
+
rest along the contact plane up to `maxSlides` times, then snaps down
|
|
464
|
+
onto a floor within `floorSnap` unless the motion rises - and the
|
|
465
|
+
`floor` it reports is the one it ENDS on (within `floorSnap` below), not
|
|
466
|
+
one it touched on the way. `MoveOptions`
|
|
467
|
+
adds `up`, `floorMaxAngle` (45 degrees: flatter is floor, steeper a wall
|
|
468
|
+
the body slides down), `maxSlides` (6), `skin` (0.01) and `floorSnap`
|
|
469
|
+
(0.1, 0 off) to the query filters; the result is `{ motion, floor, wall,
|
|
470
|
+
ceiling, hits }`. Gravity is the caller's - fold it into `motion` and
|
|
471
|
+
zero it while `floor` is set (the snap keeps reporting the floor while
|
|
472
|
+
the body stands still) - and a walkable floor absorbs the vertical part,
|
|
473
|
+
so a body never creeps down a slope it can stand on.
|
|
474
|
+
`examples/collision.tsx` is the whole pattern: a capsule walker through
|
|
475
|
+
`clampPosition`, gravity as a frame loop that runs only while airborne,
|
|
476
|
+
the level on a collider layer bit beside its drawn one, pickups lit by
|
|
477
|
+
one overlap per move. Deliberately absent and additive when asked: a
|
|
478
|
+
step offset (Unity's stepOffset; Godot has none either) and a cylinder
|
|
479
|
+
volume (Godot only).
|
|
480
|
+
|
|
255
481
|
Mesh pointer events - the element vocabulary one tree deeper:
|
|
256
482
|
`onPointerDown/Move/Up/Enter/Leave` as plain fields on any node (and as
|
|
257
483
|
Mesh/Group props). The nearest hit mesh is the target; down/move/up
|
|
@@ -313,7 +539,10 @@ concatenates parts into one geometry with offset indices (uint32 past 64k
|
|
|
313
539
|
vertices); parts must share one layout, a mixed list throws. Together
|
|
314
540
|
they collapse a static scene to one mesh per material - transform each
|
|
315
541
|
part into place, merge, draw once - so only what actually moves keeps a
|
|
316
|
-
node, a draw entry and a per-frame `uModel` write of its own.
|
|
542
|
+
node, a draw entry and a per-frame `uModel` write of its own. Merging
|
|
543
|
+
does not tax picking: a merged geometry's raycast narrowphase runs
|
|
544
|
+
through its triangle BVH (built on the first ray), so merge for draw
|
|
545
|
+
count without giving up ground queries. Both are
|
|
317
546
|
pure array math (Three's `applyMatrix4` + `mergeGeometries`), no GPU
|
|
318
547
|
call, and the source geometries are untouched. `geometryBounds(geometry)`
|
|
319
548
|
returns the cached local AABB `[minX, minY, minZ, maxX, maxY, maxZ]`, and
|
|
@@ -349,9 +578,11 @@ uint16/uint32 indices by vertex count automatically.
|
|
|
349
578
|
|
|
350
579
|
Materials:
|
|
351
580
|
|
|
352
|
-
- `unlit({ color?, map?, transparent?, cull?, alphaTest? })` -
|
|
353
|
-
`[r, g, b, a?]` 0..1
|
|
354
|
-
|
|
581
|
+
- `unlit({ color?, map?, transparent?, cull?, alphaTest?, fog? })` -
|
|
582
|
+
straight `[r, g, b, a?]` 0..1 sRGB (decoded to linear light, see Color
|
|
583
|
+
below), premultiplied internally; `cull` and
|
|
584
|
+
`alphaTest` as on lit (a mapped cutout casts its cutout); `fog: false`
|
|
585
|
+
opts out of the scene's fog (all four library materials take it).
|
|
355
586
|
- `sprite({ color?, map?, transparent?, billboard? })` - unlit on a quad
|
|
356
587
|
that turns to face the camera IN THE VERTEX STAGE (off the shared
|
|
357
588
|
uCamRight/uCamUp, or uCamPos for `billboard: "fixed-y"`, which yaws
|
|
@@ -376,7 +607,15 @@ Materials:
|
|
|
376
607
|
`normalize(uCamPos - worldPos)`), `uniform vec3 uCamRight` / `uCamUp`
|
|
377
608
|
(the camera's world-space view axes, shared likewise - a billboard is
|
|
378
609
|
`center + uCamRight * x + uCamUp * y`; do NOT rebuild them from
|
|
379
|
-
uViewProj rows, that carries the clip flip)
|
|
610
|
+
uViewProj rows, that carries the clip flip), `uniform mat4
|
|
611
|
+
uInvViewProj` (the camera's inverse view-projection, shared likewise -
|
|
612
|
+
a clip position back to world, the world-space ray through a pixel
|
|
613
|
+
without knowing the projection), the output stage's `uniform float
|
|
614
|
+
uExposure` / `uToneMapping` (compose `OUTPUT` from `/glsl` and end with
|
|
615
|
+
`fragColor = outputColor(rgb, alpha)` to take the scene's exposure and
|
|
616
|
+
tone mapping and encode like the library materials do; a fragment
|
|
617
|
+
writing fragColor directly writes final encoded pixels) and `uniform
|
|
618
|
+
mat4 uNormal` (the world
|
|
380
619
|
inverse-transpose, written beside uModel for this material's meshes;
|
|
381
620
|
take `mat3(uNormal)` - correct under non-uniform scale, where
|
|
382
621
|
mat3(uModel) bends normals off the surface). Attributes come from the
|
|
@@ -400,50 +639,339 @@ Materials:
|
|
|
400
639
|
pipeline with its own values. `dispose()` lives on the class alone.
|
|
401
640
|
`shaderMaterial(opts)` is exactly a class with one instance (its
|
|
402
641
|
`dispose` forwards to the class).
|
|
403
|
-
- `instanceAttributes: [{ name, format }]` on either shader-material
|
|
404
|
-
makes an INSTANCED material: the vertex stage reads them as `in`
|
|
642
|
+
- `instanceAttributes: [{ name, format, slot? }]` on either shader-material
|
|
643
|
+
form makes an INSTANCED material: the vertex stage reads them as `in`
|
|
405
644
|
variables beside the layout's own, and each drawn instance gets one
|
|
406
|
-
record
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
645
|
+
record per slot from the mesh's instance buffers - slot 0 (default)
|
|
646
|
+
is the record buffer, slot 1 an instanced mesh's STYLE buffer (below).
|
|
647
|
+
Its meshes come from `createInstancedMesh` or `createRecordMesh`; a
|
|
648
|
+
`createMesh` mesh is rejected at add(). `instanceStyle: [..]` is the
|
|
649
|
+
slot-1 record a fresh instance starts with. The stock materials do
|
|
650
|
+
the same with one flag: `lit/standard/unlit({ instanced: true })`
|
|
651
|
+
places by the instance matrix (the shadow pass too, so the fleet
|
|
652
|
+
casts), and `{ instanceColors: true }` adds a per-instance
|
|
653
|
+
premultiplied `[r, g, b, a]` style record starting white - Unity's
|
|
654
|
+
per-material instancing switch with Three's setColorAt.
|
|
655
|
+
|
|
656
|
+
Instancing - one draw entry covering a population, in two forms. The
|
|
657
|
+
axis between them is WHERE MOTION IS COMPUTED, the same split as
|
|
658
|
+
@solidrt/2d's sprite and record layers.
|
|
659
|
+
|
|
660
|
+
`createInstancedMesh(geometry, material, { capacity?, bounds?, label? })`
|
|
661
|
+
draws the geometry once per instance NODE: `addInstance(mesh,
|
|
662
|
+
transform?, parent?)` returns a scene node (kind "instance") placed
|
|
663
|
+
inside the mesh - setTransform/setTransition/setVisible, lookAt,
|
|
664
|
+
worldPosition, pointer handlers and children of its own all apply - whose
|
|
665
|
+
matrix RELATIVE to the mesh the spatial core writes into the record
|
|
666
|
+
buffer (`bindMatrixRecord` anchored on the mesh node: one coalesced
|
|
667
|
+
buffer write per flush however many instances moved, so native
|
|
668
|
+
transitions and clip players move instances with zero per-frame JS).
|
|
669
|
+
The material declares `INSTANCE_MATRIX_ATTRIBUTES` in slot 0 (four vec4
|
|
670
|
+
columns, 16 floats; anything else throws at creation) and its vertex
|
|
671
|
+
stage splices `INSTANCE_MATRIX` from `@solidrt/3d/glsl`: `uModel *
|
|
672
|
+
instanceMatrix() * vec4(aPos, 1.0)`, normals through
|
|
673
|
+
`instanceNormalMatrix()` (Three's derivation, exact for any rotation and
|
|
674
|
+
scale at no per-instance data; a sheared hierarchy - a non-uniformly
|
|
675
|
+
scaled group above a rotated instance - takes
|
|
676
|
+
`transpose(inverse(mat3(instanceMatrix())))` instead) - or is a stock
|
|
677
|
+
material with `instanced`. Slot-1 attributes, when the material declares
|
|
678
|
+
any, give every instance a STYLE record beside its matrix
|
|
679
|
+
(`MeshInstances.style`): app-owned floats in a second, mesh-owned
|
|
680
|
+
buffer, written per instance with `setInstanceStyle(instance, floats)`
|
|
681
|
+
into a JS mirror and published as ONE coalesced buffer write per mesh at
|
|
682
|
+
the scene's sync (a frame-rate path, like setTransform); a fresh or
|
|
683
|
+
recycled slot starts from the material's `instanceStyle` (white under
|
|
684
|
+
`instanceColors`, zeros otherwise); under `alphaTest` the cutout shadow
|
|
685
|
+
multiplies by the same color, so an instance faded below the cutoff
|
|
686
|
+
casts nothing, like its pixels (Godot's alpha scissor holds in its
|
|
687
|
+
shadow pass too). The core's pose slot and the app's style slot are
|
|
688
|
+
@solidrt/2d's split one dimension up. Slots are fixed
|
|
689
|
+
for an instance's life and recycle on `removeInstance` (remove means
|
|
690
|
+
destroy: an instance cannot exist outside its mesh, and the generic
|
|
691
|
+
add/remove throw on one), the drawn count is the slot high-water mark,
|
|
692
|
+
and past `capacity` (default 64) both buffers double into replacements
|
|
693
|
+
with the live records retargeted in one core call and the style mirror
|
|
694
|
+
republished. `parent` may be a group inside the mesh's subtree (a squad
|
|
695
|
+
in a fleet): the record stays mesh-relative through it. Picking is per
|
|
696
|
+
instance: every instance node carries the geometry's bounds and
|
|
697
|
+
triangle shape, so `Hit`, `Overlap` and `Impact` name the `instance` and
|
|
698
|
+
pointer events bubble from it (`event.instance`) through the mesh to its
|
|
699
|
+
ancestors. `bounds` on the mesh is optional here: without it the mesh
|
|
700
|
+
culls by the union of its live instances' boxes (following them, hidden
|
|
701
|
+
ones included) and sorts by its node position; with it the explicit
|
|
702
|
+
box does both. Three's InstancedMesh count
|
|
703
|
+
constructor over Unity's one-transform-per-instance model; Godot's
|
|
704
|
+
MultiMesh is the record form. The components: `<InstancedMesh capacity>`
|
|
705
|
+
with `<Instance position style transition onPointerDown>` children
|
|
706
|
+
(`<Group>` children are squads, an `<Instance>` is a parent too);
|
|
707
|
+
`examples/fleet.tsx` is a thousand of them springing between formations
|
|
708
|
+
with tap-to-tint, `probes/3d-instance-probe.tsx` the function face.
|
|
709
|
+
Mount cost is the one difference between the faces: an `<Instance>` row
|
|
710
|
+
costs about eight times an `addInstance` call (the component machinery -
|
|
711
|
+
a Solid row, two effects, the style effect; a node component's context
|
|
712
|
+
provider is built only when it has children, the biggest single saving -
|
|
713
|
+
`probes/3d-instance-mount-bench.tsx` measures it), so a few thousand
|
|
714
|
+
mount in tens of milliseconds either way; populations spawned per frame,
|
|
715
|
+
or in five figures, belong to the function face.
|
|
716
|
+
|
|
717
|
+
`createRecordMesh(geometry, material, records, count?, { bounds?,
|
|
718
|
+
label? })` is the raw form: `records` is the interleaved per-instance
|
|
719
|
+
data (stride = the material's slot-0 instanceAttributes summed, a
|
|
720
|
+
mismatch throws; slot 1 is an instanced mesh's and throws here),
|
|
721
|
+
uploaded to a mesh-owned buffer whose capacity starts at the
|
|
722
|
+
records given, and `count` picks how many draw (default all).
|
|
723
|
+
`setRecords(mesh, records, count?)` rewrites from the start (count
|
|
724
|
+
defaults to the records written; more than capacity GROWS: capacity
|
|
725
|
+
doubles into a replacement buffer, the entry is re-pointed via
|
|
726
|
+
`setDrawBuffers`, the old buffer is freed) and `setRecordCount(mesh, n)`
|
|
727
|
+
is the population dial (clamped to capacity; frame-rate-safe). Records
|
|
728
|
+
are opaque data (position/yaw/tint/whatever your shader reads), so the
|
|
729
|
+
library cannot know where they place instances: a record mesh has NO
|
|
730
|
+
picking leaf unless you pass `bounds` (local, covering the population) -
|
|
731
|
+
then it picks and transparent-sorts conservatively as one box. The
|
|
732
|
+
escape hatch for motion only JS can compute at scale (a particle sim, a
|
|
733
|
+
crowd stepped in a worker).
|
|
734
|
+
|
|
735
|
+
Everything mesh works on both: setTransform moves the whole population
|
|
736
|
+
through one uModel, setVisible zeroes the drawn count and restores it on
|
|
737
|
+
unhide, renderOrder/params/geometry/material swaps apply, and
|
|
738
|
+
`disposeInstances(mesh)` detaches and frees the record buffers - the one
|
|
739
|
+
explicit free, geometry-buffer rule. `examples/fleet.tsx` (instances,
|
|
740
|
+
components), `probes/3d-instance-probe.tsx` (instances, functions) and
|
|
741
|
+
`examples/instanced.tsx` (records) are the live proofs.
|
|
432
742
|
|
|
433
743
|
Background: `scene.setBackground(source | null)`, the `background` option
|
|
434
|
-
on createScene, and the reactive `Scene` prop.
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
744
|
+
on createScene, and the reactive `Scene` prop. Drawn as the FIRST entry
|
|
745
|
+
of the scene's own pass (attributeless fullscreen triangle, depth off) -
|
|
746
|
+
one target instead of a backdrop texture stacked under the scene, with
|
|
747
|
+
no separate resize plumbing. Two forms:
|
|
748
|
+
|
|
749
|
+
- Fragment GLSL. The source gets the shader-target fragment contract
|
|
750
|
+
(vUV 0..1 top-left origin, iResolution, fragColor; no `#version` line
|
|
751
|
+
= the standard preamble), so a `createShaderTexture` backdrop ports
|
|
752
|
+
verbatim, PLUS `in vec3 vRay`: the world-space view ray through the
|
|
753
|
+
pixel, unnormalized (the vertex stage carries its clip position back
|
|
754
|
+
through the shared uInvViewProj at the near and far planes). A
|
|
755
|
+
directional sky - horizon gradient, sun disc, stars - is a few lines
|
|
756
|
+
on `normalize(vRay)`. The background is an ordinary scene entry, so it
|
|
757
|
+
may declare `uniform vec3 uCamPos` (the ray's origin) and any name
|
|
758
|
+
written through `scene.setParams` (an app clock for an animated sky).
|
|
759
|
+
Godot's sky shader and Unity's skybox material are the same idea; the
|
|
760
|
+
radiance bake for environment lighting will consume this same source
|
|
761
|
+
later, so a procedural sky written here lights the scene then. The
|
|
762
|
+
preamble declares the OUTPUT set: end with `fragColor =
|
|
763
|
+
outputColor(rgb, 1.0)` for a sky that takes the scene's exposure and
|
|
764
|
+
tone mapping (the skybox form does); a direct fragColor write is final
|
|
765
|
+
encoded pixels.
|
|
766
|
+
- A skybox `{ cube, intensity?, rotation? }` (SkyboxOptions): a cube
|
|
767
|
+
map from createCubeTexture sampled along the same ray - Three's
|
|
768
|
+
`scene.background = cubeTexture` with `backgroundIntensity` and
|
|
769
|
+
`backgroundRotation`. `rotation` is a turn about world y in radians
|
|
770
|
+
(the sky turns as a node with that rotation would); `intensity` a
|
|
771
|
+
multiplier. Replacing a skybox with a skybox rewrites the entry's
|
|
772
|
+
params and cube in place (no recompile), so the reactive prop can
|
|
773
|
+
animate the rotation. Under an orthographic camera every pixel looks
|
|
774
|
+
the same way, so a skybox is one flat color there. A 2D texture id
|
|
775
|
+
throws at the samplerCube binding. `examples/skybox.tsx`.
|
|
776
|
+
|
|
777
|
+
The cube-map convention: a cube map holds what a GL lookup returns -
|
|
778
|
+
each face as seen from OUTSIDE the cube, GL's own (RenderMan) frame -
|
|
779
|
+
and every library lookup is a plain `texture(cube, dir)` in world space.
|
|
780
|
+
No shader flip, as in Godot and Unity, which convert images at import;
|
|
781
|
+
Three instead flips x in the shader for image cubes (`flipEnvMap`) and
|
|
782
|
+
not for rendered ones, so a ported Three shader drops its flip, and a
|
|
783
|
+
Three-style six-face image set (px, nx, py, ny, pz, nz as seen from
|
|
784
|
+
inside) is mirrored per image at load. Every bake here (the tool,
|
|
785
|
+
`equirectToCube`, the examples' JS skies) writes GL's table directly,
|
|
786
|
+
and a cube the scene renders itself needs nothing. Three's `scene.background = color` is `clearColor` here; a 2D
|
|
787
|
+
image form can widen the signature later (a branded TextureId is a
|
|
788
|
+
number, so the object form keeps it unambiguous). Translucent grounds
|
|
789
|
+
over a background still need blend factors (a separate shader texture
|
|
790
|
+
underneath until then).
|
|
791
|
+
|
|
792
|
+
Environment: `scene.setEnvironment({ cube, intensity?, rotation? } |
|
|
793
|
+
null)`, the `environment` option on createScene and the reactive `Scene`
|
|
794
|
+
prop - the cube map every `standard` material is lit by (always: the
|
|
795
|
+
split sum `envRadiance` at its roughness times PBR's `envBrdf` for the
|
|
796
|
+
specular, `envIrradiance` - the fully rough sample along the normal, as
|
|
797
|
+
Three's getIBLIrradiance and Godot read it - added to the hemisphere
|
|
798
|
+
for the diffuse) and every `lit({ reflectivity })` material mirrors,
|
|
799
|
+
typically the skybox's own cube turned with it. The cube to use is a
|
|
800
|
+
BAKED one: `bunx srt tool 3d/environment sky.hdr -o assets/sky.srte`
|
|
801
|
+
turns an equirectangular Radiance .hdr (Poly Haven's are CC0) into the
|
|
802
|
+
six faces plus the GGX-prefiltered mip chain in linear float, and
|
|
803
|
+
`await loadEnvironment("assets/sky.srte")` uploads it as an explicit
|
|
804
|
+
"rgba16f" chain (createCubeTexture's array-of-levels form: no generated
|
|
805
|
+
mipmaps, so no half-float render support needed - it works on every
|
|
806
|
+
device; created after an await, so not auto-freed). Unity convolves at
|
|
807
|
+
import the same way; Three's PMREMGenerator and Godot's radiance map do
|
|
808
|
+
it at runtime. The roughness-to-level rule is `ENVIRONMENT`'s:
|
|
809
|
+
roughness r samples level `r * (log2(size) - 2)`, so a roughness of 1
|
|
810
|
+
lands on the 4x4 level (ENV_ROUGH_FACE in environment-bake.ts), the
|
|
811
|
+
last one the bake convolves; a `mipmap: true` cube from six faces
|
|
812
|
+
(equirectToCube, a JS-baked sky) merely box-filters that chain, so its
|
|
813
|
+
rough reflections are sharper than they should be and its diffuse term
|
|
814
|
+
is a coarse average - fine for a sky gradient, wrong for a photograph.
|
|
815
|
+
A 128 environment (2 MiB, the default) lights any surface; for a
|
|
816
|
+
mirror-finish showpiece bake at 256, and for a crisp backdrop pair it
|
|
817
|
+
with a separate hi-res LDR skybox (a 2k panorama through equirectToCube)
|
|
818
|
+
as `background` while the .srte stays the `environment`. Scene-level like Three's
|
|
819
|
+
`scene.environment`, Unity's environment reflections and Godot's
|
|
820
|
+
sky-lit reflections: ONE `uEnv` samplerCube bound on every target the
|
|
821
|
+
scene draws into (a 1x1 black placeholder while unset) and one
|
|
822
|
+
shared-params write (`uEnvIntensity`, `uEnvRotation`, `uEnvOn`), however
|
|
823
|
+
many meshes reflect; no per-material envMap (Three's Basic/Phong
|
|
824
|
+
`envMap`) - a custom material composes ENVIRONMENT from
|
|
825
|
+
`@solidrt/3d/glsl`. `reflectivity` 0..1 is the
|
|
826
|
+
face-on weight, rising to 1 at grazing angles (Schlick), mixed in as
|
|
827
|
+
`rgb = mix(rgb, reflection, weight)`: 1 is chrome, ~0.05 a glossy
|
|
828
|
+
dielectric with rim reflections; Three's Phong `reflectivity` under its
|
|
829
|
+
MixOperation with a fresnel weight (Three's default MultiplyOperation
|
|
830
|
+
tints instead; not offered). The reflection blurs with `shininess`:
|
|
831
|
+
roughness `sqrt(2 / (shininess + 2))` picks a mip level of the cube
|
|
832
|
+
(`textureLod`), so the environment cube wants `mipmap: true`; a cube
|
|
833
|
+
without mipmaps stays sharp. `specularMap`'s red scales it like
|
|
834
|
+
`specular`. For `lit` it is not an ambient light source: the hemisphere
|
|
835
|
+
light stays its only ambient term (`standard` adds envIrradiance; SH9
|
|
836
|
+
is a later, additive mode). A declared `reflectivity` with no
|
|
837
|
+
environment set contributes nothing (uEnvOn 0), not a black reflection.
|
|
838
|
+
`examples/skybox.tsx` (a JS-baked sky), `examples/environment.tsx` (a
|
|
839
|
+
baked HDRI lighting the scene alone).
|
|
840
|
+
|
|
841
|
+
Reflection probes: `scene.createReflectionProbe({ position, size?,
|
|
842
|
+
near?, far?, layers?, clearColor?, label? })` renders the scene into a
|
|
843
|
+
cube map from a point - Three's CubeCamera, Unity's and Godot's
|
|
844
|
+
realtime ReflectionProbe - and returns `{ cube, setPosition, update(),
|
|
845
|
+
dispose() }`; `cube` is what `environment={{ cube }}` (a chrome ball
|
|
846
|
+
mirroring its surroundings) or `background` takes. A view under the
|
|
847
|
+
hood: one entry list mirrored from the scene, the light set and scene
|
|
848
|
+
params fanned out, its own layer mask (keep the mirroring object out of
|
|
849
|
+
its own probe with `layers`), the scene's background drawn first on
|
|
850
|
+
every face (the GLSL sky or skybox behind the meshes, through the face
|
|
851
|
+
camera, in linear light - what Three, Unity and Godot probes see), a
|
|
852
|
+
cube draw target (`createCubeDrawTarget` in core, rendered face by face
|
|
853
|
+
with `renderTarget(cube, face)`).
|
|
854
|
+
Nothing renders it but `probe.update()`: six scene passes, from the
|
|
855
|
+
meshes as the last frame's flush placed them, so call it when the
|
|
856
|
+
surroundings moved (every frame for a moving scene, once for a still
|
|
857
|
+
one), then the PREFILTER: the faces convolved on the GPU into a second,
|
|
858
|
+
`mipmap: true` cube target level by level (`renderTarget(chain, face,
|
|
859
|
+
level)`, one small pass each - 48 at 128 - the bake tool's GGX
|
|
860
|
+
importance sampling as a fragment, `createPrefilter` in environment.ts,
|
|
861
|
+
with the same roughness-to-level rule as a .srte chain), so `standard`
|
|
862
|
+
blurs a probe by roughness exactly like a baked environment; `prefilter:
|
|
863
|
+
false` skips it and hands out the sharp faces (Three's CubeCamera: a
|
|
864
|
+
mirror at every roughness). COST: the chain's passes are tiny, but a
|
|
865
|
+
pass that samples mip levels of a cube map carries a fixed GPU cost
|
|
866
|
+
(about 0.3 ms each on an Intel/Mesa laptop, the same at 8 samples or at
|
|
867
|
+
256 - measured 2026-09-05), so a prefiltered probe updated every frame
|
|
868
|
+
costs ~14 ms of GPU there against ~1 ms for the six face passes: fine at
|
|
869
|
+
60 fps on its own, the largest single item in a frame budget. Realtime
|
|
870
|
+
probes are the expensive option in every engine (Unity time-slices
|
|
871
|
+
them): update a prefiltered probe when the surroundings changed, or
|
|
872
|
+
every few frames, and keep `prefilter: false` for a probe that must
|
|
873
|
+
refresh every frame on a tight budget. The faces hold LINEAR light (the probe owns
|
|
874
|
+
`uOutputEncode` 0, `uToneMapping` 0 and `uExposure` 1 on its target,
|
|
875
|
+
names the scene's fan-out then skips), HALF FLOAT where the device
|
|
876
|
+
renders it (`limits.halfFloatRenderable`, every GLES 3 device here: a
|
|
877
|
+
sun's or an emissive's range survives into the reflection, as in every
|
|
878
|
+
engine's HDR probe) and 8-bit clamped elsewhere - the renderer decides
|
|
879
|
+
for all probes (Godot), not a per-probe knob (`probeFormat()` in
|
|
880
|
+
environment.ts) - and the probe never samples its own cube while
|
|
881
|
+
rendering (a black environment stands in: one bounce). The face cameras are plain world-up cameras through an
|
|
882
|
+
x-mirrored projection (`Camera.mirror`), because a GL cube face is seen
|
|
883
|
+
from outside; the engine inverts the front-face rule on cube target
|
|
884
|
+
passes so cull modes keep their meaning. `examples/probe.tsx`.
|
|
885
|
+
|
|
886
|
+
Baked sky: `scene.bakeBackground(size?)` is a reflection probe at the
|
|
887
|
+
origin that sees no mesh (layer mask 0): the scene's background - the
|
|
888
|
+
GLSL sky or the skybox - alone on its six faces, LINEAR (a sky ending in
|
|
889
|
+
`outputColor` bakes its light; one writing fragColor raw bakes those
|
|
890
|
+
bytes as light), prefilters it like a probe and returns the chain's
|
|
891
|
+
TextureId for `environment={{ cube }}`: Godot's sky-to-radiance bake, so
|
|
892
|
+
a procedural sky lights the scene with no light nodes, and the runtime
|
|
893
|
+
way to turn a hi-res LDR skybox into a properly convolved environment
|
|
894
|
+
(the `mipmap: true` box chain above is the sharper, cheaper
|
|
895
|
+
alternative). A snapshot at the probe format (half float where
|
|
896
|
+
renderable; default size 128): bake again when the sky changes, and
|
|
897
|
+
destroy the old cube - it is not auto-freed (an environment normally
|
|
898
|
+
lives as long as the app). `outputColor` leaves linear output UNCLAMPED
|
|
899
|
+
(the clamp sits in the display encode), so a sky's sun disc of 40.0
|
|
900
|
+
bakes as 40.0 and shows as the broad bright highlight on rough metal
|
|
901
|
+
that HDR is for. A sky that reads
|
|
902
|
+
`uCamPos` bakes from the origin; scene params (an app clock) are seen as
|
|
903
|
+
of the call. `examples/sky-lit.tsx`.
|
|
904
|
+
|
|
905
|
+
Panoramas: `equirectToCube(map, size, opts?)` converts an uploaded
|
|
906
|
+
equirectangular 2D texture (createImage, createTexture) into a cube
|
|
907
|
+
TextureId on the GPU, synchronously (six face passes straight into a
|
|
908
|
+
cube draw target of the panorama's format - rgba8, rgba8-srgb or
|
|
909
|
+
rgba16f, no readback; `opts` are createCubeTexture's - `mipmap: true`
|
|
910
|
+
for an environment). The center column faces -Z and the top row is +Y,
|
|
911
|
+
as in Godot's PanoramaSkyMaterial and Unity's Skybox/Panoramic; Three
|
|
912
|
+
centers +X, a quarter turn away. Leave the source texture's wrap at
|
|
913
|
+
clamp (`repeat` also wraps vertically and bleeds the poles). An HDR
|
|
914
|
+
panorama uploaded as rgba16f converts into a half-float cube (sharp:
|
|
915
|
+
a skybox, or `mipmap: true` for the box chain); its PREFILTERED form is
|
|
916
|
+
the bake tool above, whose CPU pipeline (`src/environment-bake.ts`:
|
|
917
|
+
decodeHdr, panoramaToCube, prefilterCube, the .srte encode/decode) is
|
|
918
|
+
pure TypeScript and bun-tested - there is no runtime .hdr decoder.
|
|
919
|
+
|
|
920
|
+
Fog: `scene.setFog(fog | null)`, the `fog` option on createScene and
|
|
921
|
+
the reactive `Scene` prop, in Three's two shapes: linear `{ color, near,
|
|
922
|
+
far }` (`Fog`; fades from near to far, fully fogged past far) or exp2
|
|
923
|
+
`{ color, density }` (`FogExp2`, Unity's default; `1 - exp(-(d *
|
|
924
|
+
density)^2)`, no start band, never quite opaque - 0.01 is ~63% at 100
|
|
925
|
+
units). Either form takes `height` + `heightFalloff` (Godot's fog
|
|
926
|
+
height, Unreal's height falloff): full fog at and below `height` (world
|
|
927
|
+
y, default 0), thinning by `exp(-(y - height) * heightFalloff)` above -
|
|
928
|
+
a valley fills, the hilltops and the sky stay clear; per fragment
|
|
929
|
+
height, not integrated along the ray, the cheap tier every engine ships
|
|
930
|
+
first. A fragment fades toward `color` by its RADIAL distance from
|
|
931
|
+
`uCamPos` (not view depth). It is ONE shared-params write (`uFogColor`,
|
|
932
|
+
`uFogNear`, `uFogInv` = 1/(far-near), `uFogDensity`, `uFogHeight`,
|
|
933
|
+
`uFogHeightFalloff`; the form not in use is 0, "no fog" is every rate
|
|
934
|
+
0, which the scene seeds at creation so there is no enable flag and no
|
|
935
|
+
branch - the shader takes the larger of the two distance factors times
|
|
936
|
+
the height term), fanned out to every view, so fogging costs nothing
|
|
937
|
+
per frame however many meshes. Every standard material (unlit,
|
|
938
|
+
lit, sprite) composes it after its alphaTest discard, mixed at the alpha
|
|
939
|
+
it writes (premultiplied stays premultiplied); `fog: false` on the
|
|
940
|
+
material drops the code from the program (Three's `material.fog`) - a
|
|
941
|
+
sky sphere, a far backdrop. A shaderMaterial opts in by composing `FOG`
|
|
942
|
+
from `/glsl` (declares the set; `fog(rgb, alpha, worldPos, camPos)`, or
|
|
943
|
+
`fogAdditive(rgb, worldPos, camPos)` for a `blend: "add"` look, which
|
|
944
|
+
fades toward black instead of the fog color).
|
|
945
|
+
The BACKGROUND is not fogged: it is entry zero with no depth or
|
|
946
|
+
distance, so match the fog color to `clearColor` or the background's
|
|
947
|
+
horizon, and put `far` at or inside the camera's far plane to hide the
|
|
948
|
+
clip. `examples/fog.tsx` cycles the forms over a valley;
|
|
949
|
+
`examples/cascades.tsx` fogs its field to the sky.
|
|
950
|
+
|
|
951
|
+
Color: the scene shades in LINEAR light and outputs sRGB, like Three
|
|
952
|
+
(ColorManagement), Godot and Unity's linear space - no gamma mode. Every
|
|
953
|
+
`[r, g, b]` color option is sRGB, what a color picker shows: material
|
|
954
|
+
`color` and `emissive`, light `color`, the hemisphere's `sky`/`ground`,
|
|
955
|
+
fog `color`; the library decodes it when it writes the uniform
|
|
956
|
+
(`srgbToLinear`/`linearColor` are exported for values you write straight
|
|
957
|
+
to a uniform yourself). Color MAPS decode through their format: create a
|
|
958
|
+
base color, emissive or sky image with `format: "rgba8-srgb"`
|
|
959
|
+
(createTexture, createCubeTexture; createModel does it for glTF's base
|
|
960
|
+
color and emissive images) - a plain rgba8 map reads as linear data and
|
|
961
|
+
renders washed out; data maps (normal, specular, roughness, light maps)
|
|
962
|
+
stay rgba8, and an HDR image is "rgba16f". Vertex colors are linear, as
|
|
963
|
+
glTF stores them. Every library fragment ends in the OUTPUT set's
|
|
964
|
+
`outputColor(rgb, alpha)`: exposure (`scene.setExposure`, default 1),
|
|
965
|
+
tone mapping (`scene.setToneMapping("none" | "aces")`, the reactive
|
|
966
|
+
`toneMapping`/`exposure` props) and the sRGB encode, premultiplied. The
|
|
967
|
+
scene target therefore holds encoded pixels like every texture the
|
|
968
|
+
runtime displays; `clearColor` is written as given and is NOT tone
|
|
969
|
+
mapped (with a curve on, draw the backdrop as a background), and a
|
|
970
|
+
transparent mesh blends in encoded space (Three's compromise; the
|
|
971
|
+
hardware-encode alternative would display wrong through the runtime's
|
|
972
|
+
raw sampling). What changed for a scene tuned before this: terminators
|
|
973
|
+
soften, mid-tones brighten, highlights widen - drop ambient rather than
|
|
974
|
+
lights. `emissiveIntensity` scales the emissive in linear light.
|
|
447
975
|
|
|
448
976
|
Lighting GLSL (`@solidrt/3d/glsl`): exported string constants composed
|
|
449
977
|
into shaderMaterial sources with plain template literals - `LIT_VERTEX`
|
|
@@ -453,14 +981,22 @@ plus the colored layout's aColor forwarded raw as vColor - using it makes
|
|
|
453
981
|
the material need that channel) and the pure functions `HEMISPHERE`
|
|
454
982
|
(`hemisphere(n, sky, ground)`), `LAMBERT` (`lambert(n, l)`),
|
|
455
983
|
`BLINN_SPECULAR` (`blinnSpecular(n, v, l, shininess)`), `FRESNEL`
|
|
456
|
-
(`fresnel(n, v, power)`),
|
|
984
|
+
(`fresnel(n, v, power)`), `PBR` (the GGX metalness/roughness model
|
|
985
|
+
`standard` shades with: `ggxSpecular(n, v, l, f0, roughness)` - one
|
|
986
|
+
light's lobe, to weight by `lambert(n, l)` and the light color like the
|
|
987
|
+
diffuse - `envBrdf(nv, roughness)` for the split sum's scale and bias on
|
|
988
|
+
f0, `DIELECTRIC_F0`, and the D/V/F pieces; it defines `PBR_PI`, not
|
|
989
|
+
`PI`), and the shadow trio composed IN ORDER:
|
|
457
990
|
`SHADOW_SLOTS` (the scene's shadow set: `uShadowAtlas`, per map slot
|
|
458
|
-
`uShadowRect[M]`/`uShadowMatrix[M]`, per
|
|
991
|
+
`uShadowRect[M]`/`uShadowMatrix[M]`, per light index
|
|
459
992
|
`uShadowFirst[N]`/`uShadowCount[N]` (its slots; a cascaded light has
|
|
460
993
|
several, tightest first), `uShadowBias[N]`, `uShadowNormalBias[N]`),
|
|
461
994
|
`SHADOW` (`shadowPoint(coord)` - clip to map point, `shadowInside(p)` -
|
|
462
995
|
does the map have it, `shadowSample(map, rect, p, bias)` - one tile's
|
|
463
|
-
|
|
996
|
+
factor as ONE hardware comparison tap (`uShadowAtlas` is a
|
|
997
|
+
sampler2DShadow; the engine binds the comparison sampler for that
|
|
998
|
+
declaration, so the 2x2 PCF weighting is the driver's), and
|
|
999
|
+
`shadow(map, rect, coord, bias)` composing the
|
|
464
1000
|
three) and `SHADOW_LOOKUP`
|
|
465
1001
|
(`lightShadow(i, worldPos, n)` - light i's factor, 1 when it does not
|
|
466
1002
|
cast; it walks the light's slots and samples the first map that covers
|
|
@@ -472,82 +1008,263 @@ samplers. Lights, colors and exponents are arguments, so
|
|
|
472
1008
|
nothing is pinned but the function names; `lit` is composed from these
|
|
473
1009
|
same constants - customizing never means leaving the system.
|
|
474
1010
|
|
|
1011
|
+
A custom look is a citizen of the scene - lit by its lights, shadowed,
|
|
1012
|
+
fogged, exposed and tone mapped like the stock materials - at one of
|
|
1013
|
+
three tiers, top first:
|
|
1014
|
+
|
|
1015
|
+
1. STANDARD FRAGMENT, CUSTOM VERTEX. Any vertex stage that writes the lit
|
|
1016
|
+
varyings (vWorldPos, vNormal, vUv, plus vColor with `vertexColors`,
|
|
1017
|
+
vUv2 with `lightMap`) pairs with `litFragment(options)` /
|
|
1018
|
+
`standardFragment(options)` (`/glsl`): the exact fragment `lit` /
|
|
1019
|
+
`standard` compile, the same option names and defaults as their
|
|
1020
|
+
options with the texture options boolean. An instanced or displaced
|
|
1021
|
+
mesh keeps the stock shading whole (`examples/instanced.tsx`: the
|
|
1022
|
+
per-instance tint rides vColor). Instance it with the per-entry
|
|
1023
|
+
uniforms the source declares (uColor, uSpecular/uShininess or
|
|
1024
|
+
uMetalness/uRoughness, the maps opted into).
|
|
1025
|
+
2. A SURFACE FUNCTION inside the stock fragment. `litFragment({ surface,
|
|
1026
|
+
prelude })`: `prelude` is file scope (uniforms and helpers; a uniform
|
|
1027
|
+
it declares is an ordinary `instance()` param), `surface` declares
|
|
1028
|
+
`void surface(inout Surface s)`, called once the program has filled
|
|
1029
|
+
the Surface struct from its options (base from uColor, the map and
|
|
1030
|
+
the vertex color; the normal, bent by the normal map; emissive,
|
|
1031
|
+
ambient, the light model's fields) and before it shades. Rewrite any
|
|
1032
|
+
field or `discard`; it reads the varyings, the declared uniforms and
|
|
1033
|
+
prelude's names, and runs in the shadow twin too, so what it discards
|
|
1034
|
+
casts no shadow. The struct is the contract, no local of the
|
|
1035
|
+
generated program is; colors are linear light, premultiplied
|
|
1036
|
+
throughout, `Surface.base` included. The material describes the
|
|
1037
|
+
surface, the package shades it (Godot's fragment(), Filament's
|
|
1038
|
+
material()).
|
|
1039
|
+
3. A FRAGMENT OF YOUR OWN over the scene set. Compose `SCENE` (or
|
|
1040
|
+
`sceneSource({ lights, receiveShadow, env, fog })`, each flag leaving a
|
|
1041
|
+
declaration out): it declares uCamPos, uHemiSky/uHemiGround, the
|
|
1042
|
+
light list, the shadow set, the environment, fog and OUTPUT - declare
|
|
1043
|
+
none of them yourself. Build a `Surface` with `surfaceOf(base,
|
|
1044
|
+
normal)`, set the fields you mean, call `shadeBlinn(s, position)` or
|
|
1045
|
+
`shadePbr(s, position)` (premultiplied rgb back: hemisphere, every
|
|
1046
|
+
light with its shadow, the environment term, the emissive), add your
|
|
1047
|
+
own terms times the alpha, and end with `sceneOutput(rgb, alpha,
|
|
1048
|
+
position)` (fog, exposure, tone mapping, encode). A custom LIGHT MODEL
|
|
1049
|
+
loops `sceneLight(i, position, normal)` to `uLightCount` instead of a
|
|
1050
|
+
shade function: light i's direction and its color already attenuated,
|
|
1051
|
+
cone-faded and shadowed (zero when it cannot reach). The stock
|
|
1052
|
+
materials are built from this same set, so the tiers cannot drift;
|
|
1053
|
+
`probes/scene-set-probe.tsx` is the byte-identity rig that checks it.
|
|
1054
|
+
The demo `the-third-dimension.tsx` has tier 2 (the ground) and tier 3
|
|
1055
|
+
(the knot's rim term); `probes/spot-custom-material-probe.tsx` a
|
|
1056
|
+
sceneLight loop beside a lit() floor.
|
|
1057
|
+
`standardFragment(options)` is the same for `standard`: lit's options
|
|
1058
|
+
minus `specularMap`/`env` (the environment is always composed) plus
|
|
1059
|
+
`metalnessMap`/`roughnessMap`, on the same `litVertex(options)`, with
|
|
1060
|
+
`uMetalness`/`uRoughness` in place of `uSpecular`/`uShininess`.
|
|
1061
|
+
`litShadowFragment(options)` is the depth-pass twin (same base, cutout
|
|
1062
|
+
and surface function, nothing after them), so a discarding material
|
|
1063
|
+
casts what it draws: build it on `litVertex(options)` with the OPPOSITE cull, instance
|
|
1064
|
+
it with only the uniform values its source declares (per-entry params
|
|
1065
|
+
reject unknown names), and pass it as the main instance's `shadow`.
|
|
1066
|
+
It returns undefined when the options cannot discard - the scene's
|
|
1067
|
+
default depth override is then already right, carry no `shadow`.
|
|
1068
|
+
`UNLIT_VERTEX` / `unlitFragment` / `unlitShadowFragment` are the unlit
|
|
1069
|
+
twins (no lighting flags, no cull; varyings vUv/vWorldPos only). TRAP:
|
|
1070
|
+
a shadow program that never reads `n` (no triplanar, no surface function
|
|
1071
|
+
using it) reflects `uNormal` inactive - set `normalMatrix: false` on that
|
|
1072
|
+
instance or every caster move warns about the skipped write.
|
|
1073
|
+
|
|
475
1074
|
Lights and `lit`: lights are graph NODES, like Three. `createDirectionalLight({
|
|
476
1075
|
direction?, color?, intensity? })` / `<DirectionalLight>` is parallel light
|
|
477
1076
|
travelling along `direction` in the node's LOCAL space (default `[0, -1,
|
|
478
1077
|
0]`, a sun overhead; length ignored), so a parent Group's rotation turns it
|
|
479
1078
|
and position/scale do not matter - deliberately a direction, not Three's
|
|
480
|
-
position-minus-target. `
|
|
1079
|
+
position-minus-target. `createSpotLight({ direction?, color?, intensity?,
|
|
1080
|
+
distance?, angle?, penumbra?, decay? })` / `<SpotLight>` is a cone from
|
|
1081
|
+
the node's WORLD position along that same LOCAL `direction` (aim by
|
|
1082
|
+
`direction` or a parent's rotation; place by setTransform): `angle` is
|
|
1083
|
+
the cone half-angle in DEGREES ((0, 90], default 60 - degrees like
|
|
1084
|
+
camera fov and like Unity/Godot; Three's radians convert as
|
|
1085
|
+
`angle * 180 / PI`), `penumbra` the
|
|
1086
|
+
0..1 fraction of it fading to the rim (default 0, a hard edge), and the
|
|
1087
|
+
strength falls off as `1 / d^decay` (default 2) windowed to zero at
|
|
1088
|
+
`distance` (0 = no cutoff) - Three's SpotLight semantics minus the
|
|
1089
|
+
target object. `createPointLight({ color?, intensity?, distance?,
|
|
1090
|
+
decay? })` / `<PointLight>` is the omnidirectional version: position
|
|
1091
|
+
only, same falloff, no cone. `createHemisphereLight({ sky?, ground?, intensity?
|
|
481
1092
|
})` / `<HemisphereLight>` is the ambient term, a gradient by the WORLD
|
|
482
1093
|
normal's tilt (fixed to world up, the node's transform is ignored); one per
|
|
483
1094
|
scene, the last attached wins. Placement goes through setTransform, the
|
|
484
1095
|
light's own fields through `setLight(light, { ... })` (frame-rate-safe,
|
|
485
|
-
like setMeshParams). At most `MAX_LIGHTS` (
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
`
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
1096
|
+
like setMeshParams). At most `MAX_LIGHTS` (8, exported from `/glsl`)
|
|
1097
|
+
lights per scene, directional, spot and point together (the hemisphere
|
|
1098
|
+
is not in the list) - the ninth throws at add(); it is a shader-source
|
|
1099
|
+
constant, fixed per app. `uLightDir` and `uLightPos` are core-driven:
|
|
1100
|
+
each light's slots are spatial-core shared-slot sinks following the
|
|
1101
|
+
node's world rotation (direction, negated so the shader reads the
|
|
1102
|
+
vector TOWARD the light) and world position, so a MOVING light costs no
|
|
1103
|
+
JS. The sync rewrites the rest whenever a light attaches, detaches or
|
|
1104
|
+
changes a field - `uHemiSky`/`uHemiGround` (vec3, intensity folded in),
|
|
1105
|
+
`uLightCount` (int), `uLightType[N]` (LIGHT_DIRECTIONAL | LIGHT_SPOT |
|
|
1106
|
+
LIGHT_POINT), `uLightDir[N]`/`uLightPos[N]`/`uLightColor[N]` (intensity
|
|
1107
|
+
folded into the color) and `uLightParams[N]` (cosInner, cosOuter,
|
|
1108
|
+
distance, decay) - so a custom fragment composing `LIGHT_SLOTS` +
|
|
1109
|
+
`LIGHT_LOOKUP` from `/glsl` reads the same list through `lightVector(i,
|
|
1110
|
+
worldPos, out l)` (returns the attenuation, 0 = skip the light; `lit`
|
|
1111
|
+
is the shape), and a light change costs one write however many meshes.
|
|
1112
|
+
A custom fragment that declares only the old directional subset
|
|
1113
|
+
(`uLightCount`/`uLightDir`/`uLightColor`) still works - it just shades
|
|
1114
|
+
every light as directional, so keep such materials to directional-only
|
|
1115
|
+
scenes. Everything starts black: a lit scene with no light shows
|
|
1116
|
+
nothing, on purpose, like Three. `examples/lamps.tsx` is the spot/point
|
|
1117
|
+
shape (soft vs hard cone, casting spots, an orbiting bulb).
|
|
498
1118
|
|
|
499
1119
|
`lit(opts)` is the standard look beside `unlit`: hemisphere ambient plus
|
|
500
1120
|
the directional list, Lambert diffuse, Blinn-Phong highlight when
|
|
501
|
-
`specular` (0..1 strength) is set with `shininess` (default 30),
|
|
502
|
-
|
|
1121
|
+
`specular` (0..1 strength) is set with `shininess` (default 30), a
|
|
1122
|
+
mirror of the scene's environment when `reflectivity` (0..1, the
|
|
1123
|
+
face-on weight; see Environment above) is set, blurred by the same
|
|
1124
|
+
`shininess`, the same `color`/`map`/`transparent` as unlit, `vertexColors: true` to
|
|
503
1125
|
multiply by the colored layout's aColor (so the geometry must carry it),
|
|
504
1126
|
`triplanar: n` to sample `map` by world position at `n` repeats per
|
|
505
1127
|
world unit, blended across the three axis planes by the normal, and
|
|
506
1128
|
`alphaTest: t` for a cutout (a fragment whose final alpha is below `t`
|
|
507
1129
|
is discarded; Three's alphaTest, glTF MASK): opaque, depth-written, no
|
|
508
|
-
sorting, usually with `cull: "none"` for cards
|
|
1130
|
+
sorting, usually with `cull: "none"` for cards, and `instanced: true` /
|
|
1131
|
+
`instanceColors: true` for an instanced mesh's population (see
|
|
1132
|
+
Instancing: the placement, the depth pass and a per-instance tint with
|
|
1133
|
+
no GLSL; the material then draws populated meshes only). Triplanar
|
|
509
1134
|
is an OPTION, not the default: generators emit 0..1 UVs per face, so a
|
|
510
1135
|
map on a plane is a decal (UV) while a map on generated scenery wants one
|
|
511
1136
|
density across parts of any size (triplanar); the map must be created
|
|
512
|
-
with `wrap: "repeat"`.
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
1137
|
+
with `wrap: "repeat"`. Any `map` on a surface seen at distance also wants
|
|
1138
|
+
`mipmap: true` at creation, or it aliases as it recedes, and a tiled
|
|
1139
|
+
surface seen at a grazing angle (a floor, a road) wants `anisotropy: 4`
|
|
1140
|
+
or more beside it, or trilinear smears the far half into the mip its long
|
|
1141
|
+
axis picked (`createModel` uploads its images with both; the device clamps
|
|
1142
|
+
the level, `limits.maxAnisotropy` reports it).
|
|
1143
|
+
|
|
1144
|
+
The surface maps, each an option beside `map` and sampled at its uv:
|
|
1145
|
+
|
|
1146
|
+
- `normalMap` (+ `normalScale`, ONE float as in Unity/Godot - Three's
|
|
1147
|
+
Vector2 exists to flip DirectX-style green channels, and glTF mandates
|
|
1148
|
+
OpenGL-style +Y) bends the lit normal per texel. The tangent frame is
|
|
1149
|
+
built per fragment from screen-space derivatives (`NORMAL_MAP` in
|
|
1150
|
+
`/glsl`, Three's untangented path), so ANY UV-mapped geometry works
|
|
1151
|
+
with no tangent channel; the trade is mild seams on mirrored UVs. Not
|
|
1152
|
+
with `triplanar` (throws - triplanar samples by world position).
|
|
1153
|
+
- `emissive: [r, g, b]` (sRGB like `color`) times `emissiveIntensity`
|
|
1154
|
+
(linear, default 1; glTF's emissive strength) and `emissiveMap` add
|
|
1155
|
+
light the lights do not provide, after the lighting terms,
|
|
1156
|
+
shadow-proof, fogged. `emissive` defaults to WHITE when `emissiveMap`
|
|
1157
|
+
is given - the map is the emission - fixing Three's gotcha where an
|
|
1158
|
+
emissiveMap alone shows nothing against the black default.
|
|
1159
|
+
- `specularMap`: its RED channel scales `specular` per fragment (chrome
|
|
1160
|
+
and rubber on one mesh); with it `specular` defaults to 1.
|
|
1161
|
+
- `lightMap` (+ `lightMapIntensity`) adds a baked-light texture by the
|
|
1162
|
+
geometry's aUV2 channel (`withAttribute(g, { name: "aUV2", format:
|
|
1163
|
+
"vec2" }, fill)`) - ADDED to the light sum like the hemisphere term, so
|
|
1164
|
+
a fully baked scene runs with no lights at all. Three's material-slot
|
|
1165
|
+
form; Unity and Godot bake at scene level, but here the material picks
|
|
1166
|
+
the program.
|
|
1167
|
+
- `mapTransform: { offset?, repeat? }` samples every uv map of the
|
|
1168
|
+
material at `uv * repeat + offset` - ONE transform per MATERIAL
|
|
1169
|
+
(Godot's uv1_offset/uv1_scale, Unity's Tiling/Offset; deliberately not
|
|
1170
|
+
Three's per-texture transform, since a TextureId is a shared value
|
|
1171
|
+
whose sampling is creation-time state). aUV2 is exempt. Scroll it per
|
|
1172
|
+
frame with `setMeshParams(mesh, { uMapTransform: [ru, rv, ou, ov] })`.
|
|
1173
|
+
Also on `unlit`. Not with `triplanar` (throws - its repeat is the
|
|
1174
|
+
triplanar value). A cutout's shadow transforms the same way.
|
|
1175
|
+
|
|
1176
|
+
`examples/materials.tsx` shows all five.
|
|
1177
|
+
|
|
1178
|
+
`standard(opts)` is the metalness/roughness material, the look authored
|
|
1179
|
+
assets expect (Three's MeshStandardMaterial, Godot's StandardMaterial3D,
|
|
1180
|
+
Unity's Standard): every lit option but the Blinn-Phong knobs
|
|
1181
|
+
(`specular`, `shininess`, `specularMap`, `reflectivity`), plus
|
|
1182
|
+
`metalness` (0..1, default 0: a metal has no diffuse and reflects tinted
|
|
1183
|
+
by `color`, a dielectric reflects 4% face-on), `roughness` (0..1
|
|
1184
|
+
perceptual, default 1: 0 a mirror, 1 matte; one value widens the
|
|
1185
|
+
highlight and blurs the environment alike; Unity's smoothness is its
|
|
1186
|
+
inverse) and the packed data maps `metalnessMap` (its BLUE channel) and
|
|
1187
|
+
`roughnessMap` (GREEN) - Three's two channel-select options over glTF's
|
|
1188
|
+
ONE metallicRoughnessTexture, so pass the same texture to both; with a
|
|
1189
|
+
map the factor defaults to 1 (the map is the value). Shading is GGX
|
|
1190
|
+
(`PBR` in `/glsl`: distribution, height-correlated Smith visibility,
|
|
1191
|
+
Schlick fresnel) per light in the same light and shadow loop, the
|
|
1192
|
+
hemisphere on the diffuse, and the scene's environment ALWAYS - the
|
|
1193
|
+
split sum, `envRadiance` at the roughness over the cube's mip chain
|
|
1194
|
+
times the analytic `envBrdf` for the specular, `envIrradiance` on the
|
|
1195
|
+
diffuse beside the hemisphere - with no `reflectivity` switch: the
|
|
1196
|
+
environment is intrinsic to the model, and a baked environment with no
|
|
1197
|
+
lights at all lights a scene (`examples/environment.tsx`). Light intensities read as lit's
|
|
1198
|
+
(1 lights a white matte surface to 1; Godot's and Unity's convention -
|
|
1199
|
+
a Three scene's intensities divide by pi). Without an environment a
|
|
1200
|
+
metal shows only its highlights: no diffuse, nothing to reflect (Three
|
|
1201
|
+
and Godot do the same), so give the scene one. `examples/standard.tsx`
|
|
1202
|
+
is the sphere grid. Internally one
|
|
1203
|
+
`shaderMaterialClass` per option combination (map x vertexColors x
|
|
1204
|
+
triplanar x transparent x cull x alphaTest x fog x the surface maps),
|
|
1205
|
+
cached for the app's lifetime, one pipeline per vertex layout - a
|
|
1206
|
+
thousand lit meshes share one program, and the key's width costs nothing
|
|
1207
|
+
by itself: classes are created lazily per combination USED, so the
|
|
1208
|
+
program count is the app's distinct material configurations. The view
|
|
1209
|
+
vector comes from the shared uCamPos; `uTriplanar` and `uAlphaTest` are
|
|
1210
|
+
declared only by the classes that use them (the cutoff is a per-entry
|
|
1211
|
+
value, so every alphaTest material shares one class) so the other
|
|
1212
|
+
classes do not warn about an inactive uniform.
|
|
520
1213
|
|
|
521
1214
|
## Models
|
|
522
1215
|
|
|
523
1216
|
Authored models come in as glTF 2.0 (.gltf with its .bin and image files
|
|
524
|
-
next to it, or single-file .glb) and become a Group
|
|
525
|
-
`gltf.scene`. Three layers, use the lowest that
|
|
1217
|
+
next to it, or single-file .glb) and become a Group carrying the file's
|
|
1218
|
+
node hierarchy, Three's `gltf.scene`. Three layers, use the lowest that
|
|
1219
|
+
fits:
|
|
526
1220
|
|
|
527
1221
|
- `parseGltf(bytes, resolve?)` - the pure parser (no engine, runs under
|
|
528
|
-
bun and on flux): `ModelData` = `
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
`
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
1222
|
+
bun and on flux): `ModelData` = `nodes` (the retained hierarchy in
|
|
1223
|
+
pre-order - name, parent index, local TRS; matrix-form nodes are
|
|
1224
|
+
TRS-decomposed, shear dropped; nodes that carry no part, joint or
|
|
1225
|
+
animation target anywhere - cameras, lights, unused empties - are
|
|
1226
|
+
pruned), `parts` (one per mesh primitive, its node's NAME kept, `node`
|
|
1227
|
+
index, vertices in the standard layout LOCAL to the node - except
|
|
1228
|
+
skinned parts: "skinned" layout, model-space bind pose, `skin` index),
|
|
1229
|
+
`skins` (joint node indices + inverse binds), `clips` (the animations
|
|
1230
|
+
as baked channel buffers: node/path/interpolation, times, values),
|
|
1231
|
+
`materials` (base color factor, `map` =
|
|
1232
|
+
index into `images`, `doubleSided`, `transparent` = alphaMode BLEND,
|
|
1233
|
+
`alphaMode` as written and `alphaCutoff`, spec default 0.5, the
|
|
1234
|
+
normal and emissive slots, `metalness`/`roughness` factors and the
|
|
1235
|
+
packed `metalnessRoughnessMap` - standard's inputs), `images`
|
|
1236
|
+
(the encoded PNG/JPEG bytes, undecoded) and `bounds` (world-space rest
|
|
1237
|
+
pose, conservative for parts under rotated nodes). External
|
|
1238
|
+
files come through `resolve(uri)` (uri as written, still
|
|
1239
|
+
percent-encoded; `gltfExternalUris(bytes)` lists them so an async
|
|
1240
|
+
caller can read them first) - for a .gltf AND for a .glb, which is
|
|
1241
|
+
usually self-contained but may legally reference external images
|
|
1242
|
+
(real exporters do); data: uris need no resolver. Missing
|
|
1243
|
+
normals produce FLAT shading (the spec's rule): the primitive is
|
|
1244
|
+
un-indexed, one vertex per corner. A mirroring node chain (negative
|
|
1245
|
+
rest-pose world determinant) flips the part's index winding so
|
|
1246
|
+
`cull: "back"` still keeps the outside. Non-triangle primitives are
|
|
1247
|
+
skipped; a required extension the parser does not implement throws
|
|
1248
|
+
naming it, and Draco or meshopt compression throws "re-export without
|
|
1249
|
+
mesh compression" - Blender exports Draco by DEFAULT, so that is the
|
|
1250
|
+
first error a real file hits.
|
|
544
1251
|
- `createModel(data, { material?, label? })` - uploads the images (repeat
|
|
545
|
-
wrap, mipmapped), makes one material per glTF material (default `
|
|
546
|
-
color,
|
|
547
|
-
|
|
548
|
-
|
|
1252
|
+
wrap, mipmapped, 4x anisotropic), makes one material per glTF material (default `standard`
|
|
1253
|
+
with the file's color, maps, normal scale, metalness/roughness and
|
|
1254
|
+
packed map, emissive and transparency - the glTF material model, so a
|
|
1255
|
+
scene showing a model wants an `environment` (a glTF metal in a scene
|
|
1256
|
+
with none renders near black); `material: (m, maps, skinned) =>
|
|
1257
|
+
lit({ color: m.color, map: maps.map ?? undefined, skinned })` for the
|
|
1258
|
+
Blinn-Phong look, or any other material; it is called once per material and shared), the node table as nested
|
|
1259
|
+
Groups with the file's local TRS, and one mesh per part under its node,
|
|
1260
|
+
all inside the returned `Model` (a Group): `add(scene.root, model)`,
|
|
549
1261
|
place it with `setTransform`, find parts by name in `model.parts`
|
|
550
|
-
(`{ name, mesh }`),
|
|
1262
|
+
(`{ name, mesh }`), spin a wheel relative to its axle through
|
|
1263
|
+
`model.nodes` (`{ name, node }` in table order, parents first; names
|
|
1264
|
+
repeat when the file's do - `.find()` yours), `model.bounds` for
|
|
1265
|
+
framing a camera. Skinned parts get the `skinned: true` material
|
|
1266
|
+
variant and hang off the model ROOT (the spec ignores their node's
|
|
1267
|
+
transform; the palette places them - see the mixer below). `dispose()`
|
|
551
1268
|
detaches it and frees the geometry buffers and textures - the model owns
|
|
552
1269
|
them, nothing else frees them.
|
|
553
1270
|
- `loadGltf(path)` / `loadModel(path)` - read from `assets/` with flux:fs
|
|
@@ -561,20 +1278,173 @@ next to it, or single-file .glb) and become a Group of meshes, Three's
|
|
|
561
1278
|
"binary" }` then `createModel(parseGltf(bytes))`, see
|
|
562
1279
|
`examples/model.tsx`); bake anything big.
|
|
563
1280
|
|
|
1281
|
+
Loading is async everywhere but the binary import: loadGltf/loadModel
|
|
1282
|
+
return promises, and the async value must be read the way Solid 2 async
|
|
1283
|
+
works - inside a tracking scope whose result the JSX reads back, under a
|
|
1284
|
+
`<Loading>` boundary. The worked shape is `examples/model-load.tsx`: the
|
|
1285
|
+
component keeps the async read in a memo (`let loaded = createMemo(() =>
|
|
1286
|
+
loadModel(path))`), derives everything - framing, mounting - in a second
|
|
1287
|
+
memo that reads `loaded()` FIRST and returns the scene JSX, and returns
|
|
1288
|
+
only that memo read; the window/view shell lives in the parent, above the
|
|
1289
|
+
boundary. Reading the value in the component body instead throws
|
|
1290
|
+
PENDING_ASYNC_UNTRACKED_READ, and any element the component builds before
|
|
1291
|
+
the suspending read is orphaned on the boundary's retry and never freed
|
|
1292
|
+
(the dev leak sentinel reports it) - so the suspending component creates
|
|
1293
|
+
no elements of its own. Async here means the file read: the parse and
|
|
1294
|
+
createModel run synchronously on main. Bake anything big to .srtm; when
|
|
1295
|
+
a source glTF must be parsed at runtime, do the parse in an isolate
|
|
1296
|
+
(parseGltf's result is plain data and copies across) and keep
|
|
1297
|
+
createModel on main.
|
|
1298
|
+
|
|
1299
|
+
Placement: pieces of one authored set (a body and its fitted cosmetics)
|
|
1300
|
+
export in one world space, so composing them is `add(group, model)` per
|
|
1301
|
+
piece and nothing else - no placement math. SOCKETED items (a weapon in
|
|
1302
|
+
a hand) are different: they bind to a joint, so they only land once a
|
|
1303
|
+
skeleton exists (a rig-less export cannot place them at all). The joint
|
|
1304
|
+
is an ordinary Group in `model.nodes` - find it by name and `add()` the
|
|
1305
|
+
item under it; it then follows the pose, mixer-driven or hand-posed,
|
|
1306
|
+
like any child transform. Two authoring cases (verified in
|
|
1307
|
+
`probes/joint-cap-probe.tsx`): an item authored about its own socket
|
|
1308
|
+
origin needs the plain `add()` and nothing more; one authored in the
|
|
1309
|
+
RIG'S model space needs a socket Group between joint and item carrying
|
|
1310
|
+
the joint's rest-pose inverse (at rest the item then sits exactly where
|
|
1311
|
+
authored, posed it follows), since parenting stacks the joint's
|
|
1312
|
+
transform on top of the authored placement. Skinned PARTS are the one
|
|
1313
|
+
thing that never needs this: they hang off the model root and the
|
|
1314
|
+
palette places them.
|
|
1315
|
+
|
|
564
1316
|
Applied: `doubleSided` (the default material draws it with `cull:
|
|
565
|
-
"none"`)
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
1317
|
+
"none"`), alphaMode MASK (`alphaTest: alphaCutoff`), `normalTexture`
|
|
1318
|
+
(+ scale; the derivative frame needs no tangents), `emissiveFactor` x
|
|
1319
|
+
`emissiveTexture` with KHR_materials_emissive_strength folded into the
|
|
1320
|
+
factor (a zero factor skips the map too - glTF's product rule, emission
|
|
1321
|
+
off), `pbrMetallicRoughness` factors as `m.metalness`/`m.roughness` and
|
|
1322
|
+
its packed texture as BOTH `maps.metalnessMap` and `maps.roughnessMap`
|
|
1323
|
+
(standard's channel-select options; the default `lit` ignores them).
|
|
1324
|
+
The `material(m, maps)` callback receives every uploaded texture
|
|
1325
|
+
by lit()/standard() option name (`maps.map`/`maps.normalMap`/
|
|
1326
|
+
`maps.emissiveMap`/`maps.metalnessMap`/`maps.roughnessMap`);
|
|
1327
|
+
`data.materials` is in file order, so the calls arrive in file order.
|
|
1328
|
+
A `.srtm` baked before the material records carried the PBR fields
|
|
1329
|
+
(file version 3) is rejected by loadModel - re-bake with `srt tool
|
|
1330
|
+
3d/model`.
|
|
1331
|
+
Animation: `createMixer(model)` plays `model.clips` by name -
|
|
1332
|
+
`mixer.play(name, { loop?, speed?, fadeMs? })` (fadeMs crossfades: the
|
|
1333
|
+
named clip fades in, everything else fades out - Unity's CrossFade,
|
|
1334
|
+
Godot's play-with-blend), `mixer.stop({ fadeMs? })`, `mixer.playing()`,
|
|
1335
|
+
`mixer.onFinish` for `loop: false` clips (the pose holds at the end).
|
|
1336
|
+
Playback is CORE-DRIVEN: there is no update() and no frame loop to
|
|
1337
|
+
register - clips are registered with the spatial core once and players
|
|
1338
|
+
sample, weight-blend and write joint TRS natively each frame, so a
|
|
1339
|
+
playing character costs zero JS per frame (gate other work on
|
|
1340
|
+
`mixer.playing()`). play() requires the model to be IN a scene (players
|
|
1341
|
+
bind live arena nodes; removing the model drops them - a re-added model
|
|
1342
|
+
plays again from play()). Three traps that follow from core ownership:
|
|
1343
|
+
(1) players advance BEFORE your onFrame, which is therefore the
|
|
1344
|
+
post-animation hook - read a freshly posed joint and overwrite it
|
|
1345
|
+
(root-motion strips, skeleton copies) in plain setTransform, last write
|
|
1346
|
+
wins, all published by the same flush; (2) the JS
|
|
1347
|
+
`position`/`quaternion` fields of player-animated joints (and of the
|
|
1348
|
+
model node under root motion "apply") go STALE (they hold the last JS
|
|
1349
|
+
write) - read poses with `getTransform(node)`, which reads the core;
|
|
1350
|
+
writes to such a node always go through (setTransform skips its
|
|
1351
|
+
usual equal-value short-circuit there, so a teleport back to the last
|
|
1352
|
+
JS-written spot is not lost); (3) a channel nothing plays leaves the node's pose
|
|
1353
|
+
alone; (4) known gap: native pose writes bypass the scene's moved list,
|
|
1354
|
+
so a TRANSPARENT mesh parented under a player-animated joint does not
|
|
1355
|
+
re-trigger the back-to-front re-sort while it animates (opaque meshes,
|
|
1356
|
+
palettes and picking are unaffected) - nudge the scene with any
|
|
1357
|
+
setTransform if it shows, until the core-side transparent sort lands.
|
|
1358
|
+
Root motion: `play(name, { inPlace? })` strips a clip's root travel
|
|
1359
|
+
(Unity's applyRootMotion off, Godot's root_motion_track): the root's
|
|
1360
|
+
x/z hold at the clip's first key and its height rebases onto the root's
|
|
1361
|
+
rest position, the vertical bob intact. `createMixer(model, {
|
|
1362
|
+
rootHeight })` rebases EVERY clip onto that height, pinned or not, for
|
|
1363
|
+
an export whose clips ride above its rest pose. Unset, it is decided per clip by NET DRIFT of the
|
|
1364
|
+
root's position track - last key minus first, past a fraction of the
|
|
1365
|
+
model's height - so run cycles play in place and a taunt that roams and
|
|
1366
|
+
returns does not (pinning THOSE pushes the slide into the feet);
|
|
1367
|
+
`mixer.travels(name)` reads the verdict. The root is the topmost node
|
|
1368
|
+
any position channel of the clip targets. The strip is baked into a
|
|
1369
|
+
second core clip on first in-place play, so it costs no per-frame JS and
|
|
1370
|
+
crossfades like any other clip. A GAME wants the travel kept and moved
|
|
1371
|
+
onto the character: `createMixer(model, { rootMotion: "apply" |
|
|
1372
|
+
"report" })` plays every clip fully pinned (all three root axes held)
|
|
1373
|
+
and its yaw held too (the turn about +y stripped key by key, so the
|
|
1374
|
+
lean and pitch of the pose survive), while the core samples the
|
|
1375
|
+
authored root tracks at each player's time and hands the per-frame
|
|
1376
|
+
delta on - a translation in the model's local frame and a yaw in
|
|
1377
|
+
radians. "apply" adds both to the model node itself (Unity's
|
|
1378
|
+
applyRootMotion: the character walks and turns where its clip says),
|
|
1379
|
+
"report" accumulates them until `mixer.rootDelta()` takes them
|
|
1380
|
+
(`{ position, yaw }`), for a controller to spend through its own
|
|
1381
|
+
movement (Godot's get_root_motion_position). Zero per-frame JS in
|
|
1382
|
+
"apply"; one read per frame in "report". Loop wraps are continuous
|
|
1383
|
+
(the clip's net drift is added across the wrap), crossfades weight the
|
|
1384
|
+
deltas like the poses, and a clip's travel is given in the root's
|
|
1385
|
+
CURRENT facing (its own turn so far undone), so a turn that wanders
|
|
1386
|
+
out and back ends where the clip says and two blending clips agree on
|
|
1387
|
+
the frame. The object form `{ mode, up?, vertical? }` names the root's
|
|
1388
|
+
parent-space up axis (default +y; the turn and height axis) and
|
|
1389
|
+
`vertical: "pose"` keeps the height in the pose, delivering only the
|
|
1390
|
+
horizontal travel (Unity's bake-into-pose Y - for a controller that
|
|
1391
|
+
owns gravity). `inPlace` is ignored while rootMotion is set. Yaw is
|
|
1392
|
+
the swing-twist about up (exact under any lean); a cubic rotation
|
|
1393
|
+
track is linearized at 60 keys/s before its yaw is held. Verified on
|
|
1394
|
+
Mixamo's standing turns (external/mixamo-turn). Skins need nothing further: each skin's uBones palette
|
|
1395
|
+
(model-local jointWorld x inverseBind, sized to the RIG - an rgba32f
|
|
1396
|
+
float texture, 4 texels wide, one row per joint, sampled in the vertex
|
|
1397
|
+
stage via texelFetch, so there is no joint cap) is composed by the
|
|
1398
|
+
spatial core at the frame's flush from the joint nodes themselves, in
|
|
1399
|
+
any write order, and identical skins (a body/legs split, LODs) share one
|
|
1400
|
+
computed-once texture. `sampleChannel` (pure, from the root) stays the
|
|
1401
|
+
JS sampling core for checks and custom drivers;
|
|
1402
|
+
okf/done/animation-core.md records the design.
|
|
1403
|
+
|
|
1404
|
+
Not in the subset, dropped: vertex colors, tangents and further UV sets;
|
|
1405
|
+
morph targets (the "weights" channel path); samplers are ignored (every
|
|
1406
|
+
texture repeats); additive blending draws as base color. The follow-ups
|
|
1407
|
+
are filed in okf/backlog/3d-model-loader.md. The `.srtm` container is
|
|
1408
|
+
VERSION 3 (node table in the header, node-local vertices, skins, clips);
|
|
1409
|
+
older bakes are rejected - re-bake with `srt tool 3d/model`.
|
|
570
1410
|
|
|
571
1411
|
## Traps
|
|
572
1412
|
|
|
573
|
-
- A model's vertices are
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
1413
|
+
- A model's vertices are LOCAL to their node; the file's placement lives
|
|
1414
|
+
in the node-table TRS, composed by the scene like any Group chain. So
|
|
1415
|
+
a part's `geometry` alone is at the origin, `model.bounds` (rest-pose
|
|
1416
|
+
world) is what frames a camera, and moving a named node moves its
|
|
1417
|
+
subtree. The winding flip for a mirroring node chain is baked into the
|
|
1418
|
+
index order from the REST pose - re-scaling a node across zero at
|
|
1419
|
+
runtime shows mesh interiors, so do not do that.
|
|
1420
|
+
- Skinning is a VERTEX-STAGE effect: everything that runs off the
|
|
1421
|
+
retained tree sees the bind pose. A skinned mesh picks by its
|
|
1422
|
+
bind-pose triangles at the model root and its transparent sort key is
|
|
1423
|
+
the bind-pose box; moving the JOINTS never moves either, moving the
|
|
1424
|
+
MODEL moves both. Shadows are the exception: the shadow variants
|
|
1425
|
+
(depth and cutout) skin by the same uBones palette, so a caster casts
|
|
1426
|
+
its pose.
|
|
1427
|
+
- A `standard` metal in a scene with no environment renders near black:
|
|
1428
|
+
its diffuse is zero and the black placeholder cube is all there is to
|
|
1429
|
+
reflect (Three and Godot render the same; Unity falls back to an
|
|
1430
|
+
ambient probe, this does not). glTF's default metallic factor is 1,
|
|
1431
|
+
so an untextured asset is all metal, and createModel's default is
|
|
1432
|
+
`standard`: give a model scene an `environment` (loadEnvironment's
|
|
1433
|
+
baked .srte, or the skybox's cube), or pass `lit` as the material.
|
|
1434
|
+
- A reflection probe is a mirrored render (x-flipped projection, winding
|
|
1435
|
+
inverted by the engine): anything built from screen-space derivatives
|
|
1436
|
+
flips with it, so a normal-mapped surface shows its bumps INVERTED in
|
|
1437
|
+
a probe's reflection. Known and shared with every engine's mirrored
|
|
1438
|
+
views; a `uMirror` sign on the derivative frame is the fix when it
|
|
1439
|
+
matters.
|
|
1440
|
+
- A generated cube chain (`mipmap: true` from six faces) is a box
|
|
1441
|
+
filter, not the GGX convolution the roughness-to-level rule assumes:
|
|
1442
|
+
rough reflections read too sharp and the diffuse `envIrradiance` is a
|
|
1443
|
+
4x4 average. Bake with `srt tool 3d/environment` for anything
|
|
1444
|
+
photographed; the JS sky gradients in the examples get away with it.
|
|
1445
|
+
- Light intensities are the same numbers for `lit` and `standard`: 1
|
|
1446
|
+
lights a white matte surface to 1 face-on. A Three scene's intensities
|
|
1447
|
+
are a factor pi larger for the same look; divide when porting.
|
|
578
1448
|
- The y-down clip flip is baked into `perspective()`; scene code and
|
|
579
1449
|
geometry are plain y-up right-handed, and CCW-outward winding culls
|
|
580
1450
|
correctly with `cull: "back"`. Do NOT negate y anywhere else, and do not
|
|
@@ -589,19 +1459,46 @@ The follow-ups are filed in okf/backlog/3d-model-loader.md.
|
|
|
589
1459
|
live: it has no world matrix yet, and drawn before the sync microtask it
|
|
590
1460
|
flashes at the world origin for a frame.
|
|
591
1461
|
- Instancing pairs strictly at add(), like layout: an instanced material
|
|
592
|
-
needs a createInstancedMesh
|
|
593
|
-
the record
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
1462
|
+
needs a createInstancedMesh or createRecordMesh mesh and vice versa,
|
|
1463
|
+
and the record strides must match the material's attributes per slot
|
|
1464
|
+
(the 16 floats of INSTANCE_MATRIX_ATTRIBUTES in slot 0 on an instanced
|
|
1465
|
+
mesh, its style stride in slot 1; a record mesh is slot 0 only) - each
|
|
1466
|
+
mismatch throws there, at creation for the mesh's own material and at
|
|
1467
|
+
add() for a swapped one. The instance buffers are MESH-owned (unlike
|
|
1468
|
+
shared geometry buffers): `disposeInstances` is their one free, and
|
|
1469
|
+
the mesh cannot be re-added afterwards. Capacity grows by REPLACEMENT,
|
|
1470
|
+
never resize: an addInstance or setRecords past capacity doubles into
|
|
1471
|
+
new buffers and swaps them in (an instanced mesh's live records move
|
|
1472
|
+
in one core `retargetRecords`, its style mirror republishes) -
|
|
1473
|
+
amortized like a dynamic array, same policy as @solidrt/2d; size
|
|
1474
|
+
`capacity` or the initial records to skip the copies. An instanced
|
|
1475
|
+
mesh's style stride comes from its material AT CREATION: a later
|
|
1476
|
+
setMaterial to a class with another slot-1 layout throws at the
|
|
1477
|
+
rebuild.
|
|
1478
|
+
- Style writes are mirrored, not immediate: `setInstanceStyle` lands in
|
|
1479
|
+
`MeshInstances.style.data` and the scene's sync publishes the dirty
|
|
1480
|
+
range - so a write on a mesh outside any scene shows once it is added
|
|
1481
|
+
(the attach republishes the whole mirror), and reading the GPU buffer
|
|
1482
|
+
back mid-frame can lag the mirror by one sync.
|
|
1483
|
+
- Instanced casters: `castShadow` on a populated mesh needs a depth pass
|
|
1484
|
+
with the instance placement in it - the stock materials' `instanced`
|
|
1485
|
+
carries one, a custom class needs `shadowVertex` (see shadows below);
|
|
1486
|
+
a class without one is skipped by shadow views, silently.
|
|
1487
|
+
- A record mesh without explicit `bounds` has no BVH leaf: it never
|
|
601
1488
|
picks, pointer events never target it, and its transparent sort key
|
|
602
1489
|
falls back to the node's world position. That is deliberate - records
|
|
603
1490
|
are opaque to the library, so any inferred box would be a guess. Supply
|
|
604
|
-
`bounds` for anything pickable or transparent.
|
|
1491
|
+
`bounds` for anything pickable or transparent. An instanced mesh picks
|
|
1492
|
+
per instance regardless and culls by its instances' union; its
|
|
1493
|
+
`bounds` replace that union and give the transparent sort a center.
|
|
1494
|
+
- An instance node's own fields hold its LOCAL pose (relative to its
|
|
1495
|
+
parent, as for any node); the record the core writes is its pose
|
|
1496
|
+
relative to the MESH, composed through any group between them. A
|
|
1497
|
+
removed instance's children stay parented to the dead node: remove()
|
|
1498
|
+
one to re-use it. `disposeInstances` on an instanced mesh flushes the
|
|
1499
|
+
core before freeing the buffer, so the destroyed instances' hiding
|
|
1500
|
+
writes land in a live buffer; keep that order if you ever free a
|
|
1501
|
+
record buffer by hand.
|
|
605
1502
|
- Transparency is an EXPLICIT material flag, Three's rule: `unlit({ color:
|
|
606
1503
|
[r, g, b, 0.5] })` still draws opaque, and opaque means it: the standard
|
|
607
1504
|
classes write alpha 1 when not `transparent` (the scene target is
|
|
@@ -725,9 +1622,14 @@ The follow-ups are filed in okf/backlog/3d-model-loader.md.
|
|
|
725
1622
|
and supplies its own cutout variant as the `shadow` instance option).
|
|
726
1623
|
Opting out of receiving is on the
|
|
727
1624
|
MATERIAL here (`receiveShadow: false`), not the object (Three's
|
|
728
|
-
`mesh.receiveShadow`) - Godot's split, and URP's
|
|
729
|
-
|
|
730
|
-
|
|
1625
|
+
`mesh.receiveShadow`) - Godot's split, and URP's. An instanced mesh
|
|
1626
|
+
casts when its class declares `shadowVertex` (the vertex stage reduced
|
|
1627
|
+
to position, instance placement included; the class builds one depth
|
|
1628
|
+
program from it plus the shared depth fragment, culling the shadow
|
|
1629
|
+
side and binding only the instance slots the stage reads, and every
|
|
1630
|
+
instance shares it) - without one the shadow views
|
|
1631
|
+
SKIP instanced meshes, since the plain depth override cannot know
|
|
1632
|
+
their records. `examples/instanced.tsx` casts. Every casting light
|
|
731
1633
|
is a full extra pass over the casters plus a sampler unit on every
|
|
732
1634
|
receiving program (MAX_LIGHTS of those are always bound, placeholders
|
|
733
1635
|
included), so cast from the lights that matter, not all of them.
|
|
@@ -741,7 +1643,9 @@ The follow-ups are filed in okf/backlog/3d-model-loader.md.
|
|
|
741
1643
|
- SCENE-WIDE uniforms go through that same shared channel via
|
|
742
1644
|
`scene.setParams({ uTime })`, and this is the single highest-leverage
|
|
743
1645
|
pattern in the library. It merges an app-owned name in beside
|
|
744
|
-
uViewProj/uCamPos/uCamRight/uCamUp
|
|
1646
|
+
uViewProj/uInvViewProj/uCamPos/uCamRight/uCamUp and the scene's own
|
|
1647
|
+
fog, environment and output sets (uFog*, uEnv*, uExposure,
|
|
1648
|
+
uToneMapping) - names merge, a target tolerates
|
|
745
1649
|
zero coverage, neither side clobbers the other. One write per frame
|
|
746
1650
|
however many meshes read it, with the motion itself in vertex shaders
|
|
747
1651
|
off that one clock. `params`/`setMeshParams` is the PER-MESH answer and
|
|
@@ -802,9 +1706,9 @@ The follow-ups are filed in okf/backlog/3d-model-loader.md.
|
|
|
802
1706
|
aColor you do not read.
|
|
803
1707
|
- Picking is triangle-accurate for ordinary meshes (`point` is a surface
|
|
804
1708
|
point, hits carry `face`/`uv`/`normal`) but box-only for instanced
|
|
805
|
-
meshes: there `point` is the
|
|
806
|
-
box, and `face`/`uv
|
|
807
|
-
hit as a surface hit. Both tiers run in the spatial core (Rust); never
|
|
1709
|
+
meshes: there `point` is where the ray meets the population `bounds`
|
|
1710
|
+
box, `normal` that face's, and `face`/`uv` are absent. Never present an
|
|
1711
|
+
instanced hit as a surface hit. Both tiers run in the spatial core (Rust); never
|
|
808
1712
|
add a per-triangle path in JS - rays at mesh scale are
|
|
809
1713
|
interpreter-hostile, and the core already does it.
|
|
810
1714
|
- `scene.handlers` vs `handlersFor`: localX/localY arrive in the leaf's
|
|
@@ -815,6 +1719,16 @@ The follow-ups are filed in okf/backlog/3d-model-loader.md.
|
|
|
815
1719
|
leaf under a design size. Only a leaf whose layout size deliberately
|
|
816
1720
|
differs from the target (supersampling) needs `handlersFor`, fed the
|
|
817
1721
|
layout size the app itself set.
|
|
1722
|
+
- A custom fragment that ends in `fragColor = vec4(...)` bypasses the
|
|
1723
|
+
scene: no fog, no exposure or tone mapping, no encode - and a
|
|
1724
|
+
hand-rolled loop over `uLightDir` renders a spot light as a
|
|
1725
|
+
directional one (a lit rectangle on the floor, no cone). What you
|
|
1726
|
+
declare is what runs, and the engine injects nothing, so pick a tier
|
|
1727
|
+
(the three after the GLSL exports under "The model"): a stock fragment
|
|
1728
|
+
on your vertex stage,
|
|
1729
|
+
a `surface` function, or `SCENE` with `shadeBlinn`/`shadePbr` or
|
|
1730
|
+
`sceneLight` and `sceneOutput` at the end. Composing `FOG` and the
|
|
1731
|
+
`SHADOW_*` trio by hand still works and is no longer the shape.
|
|
818
1732
|
- Hover (enter/leave) reacts to pointer MOTION only: a mesh animating
|
|
819
1733
|
under a still pointer fires nothing until the next move - the same
|
|
820
1734
|
limit the element hit test has (hit-test-per-frame is an open platform
|
|
@@ -827,6 +1741,36 @@ The follow-ups are filed in okf/backlog/3d-model-loader.md.
|
|
|
827
1741
|
REPLACES the clearColor visually (the clear still runs; you just never
|
|
828
1742
|
see it), and a `transparent: true` mesh blends over it in-pass since the
|
|
829
1743
|
background is always entry zero.
|
|
1744
|
+
- A color map created as plain rgba8 renders WASHED OUT: the fragment
|
|
1745
|
+
reads its encoded bytes as linear light and encodes them again. Create
|
|
1746
|
+
color images (base color, emissive, a sky's faces or panorama) with
|
|
1747
|
+
`format: "rgba8-srgb"`; keep data maps rgba8. A rendered rgba8 texture
|
|
1748
|
+
(a scene view, a shader target, a UI capture) holds encoded pixels and
|
|
1749
|
+
cannot be tagged, so as a `map` it needs `srgbToLinear` from SRGB in a
|
|
1750
|
+
custom fragment (no material option yet); a draw target you create
|
|
1751
|
+
yourself can be `format: "rgba8-srgb"` and then decodes on sample (it
|
|
1752
|
+
is sampler-only: no display, readback or copy).
|
|
1753
|
+
- Reading the scene texture back (a probe's readTexture, a snapshot)
|
|
1754
|
+
gives ENCODED pixels: an expected linear value v shows as
|
|
1755
|
+
`linearToSrgb(v) * 255` - intensity 0.5 reads 188, not 128.
|
|
830
1756
|
- The background pipeline/program are SCENE-OWNED (unlike shared
|
|
831
1757
|
material pipelines): setBackground(null), replacement, and dispose()
|
|
832
1758
|
destroy them. Do not hand the background's pipeline to anything else.
|
|
1759
|
+
A skybox is the same slot with the library's fragment; only a
|
|
1760
|
+
skybox-to-skybox replace keeps the entry (params and cube rewritten).
|
|
1761
|
+
- The environment binds through the light rewrite's map set (uEnv
|
|
1762
|
+
beside uShadowAtlas on every receiving target, new views included) and
|
|
1763
|
+
directly on setEnvironment; the placeholder cube is app-lifetime like
|
|
1764
|
+
the shadow placeholder. A `lit` without `reflectivity` declares no
|
|
1765
|
+
environment sampler - the flag is part of the class key.
|
|
1766
|
+
- overlap()/sweep() test SURFACES, the trimesh contract everywhere: a
|
|
1767
|
+
volume wholly inside a closed mesh with no triangle in reach touches
|
|
1768
|
+
nothing, and a body whose center has passed through a wall reports
|
|
1769
|
+
the push-out on the side its center is on. Keep a skin (moveAndSlide
|
|
1770
|
+
does) and never teleport a body into geometry expecting it to come
|
|
1771
|
+
out the far side.
|
|
1772
|
+
- moveAndSlide owns no gravity and no velocity: fold the fall into
|
|
1773
|
+
`motion` every frame and zero it while `floor` is set. A walkable
|
|
1774
|
+
floor absorbs the vertical part (a body never creeps down a slope it
|
|
1775
|
+
can stand on), and with `floorSnap: 0` a standing body reports no
|
|
1776
|
+
floor unless its motion presses into one.
|