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