@solidrt/3d 0.0.49 → 0.0.51
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 +147 -15
- package/README.md +13 -4
- package/examples/README.md +5 -0
- package/examples/instanced.tsx +158 -0
- package/package.json +3 -2
- package/src/bvh.ts +2 -2
- package/src/components.tsx +80 -1
- package/src/geometry-gpu.ts +86 -0
- package/src/geometry.ts +94 -47
- package/src/index.ts +9 -7
- package/src/material.ts +147 -55
- package/src/math.ts +45 -0
- package/src/order.ts +51 -0
- package/src/scene.ts +354 -59
package/AGENTS.md
CHANGED
|
@@ -11,7 +11,7 @@ blendMode and pointer events like any element. Design rationale:
|
|
|
11
11
|
|
|
12
12
|
- Two layers. The imperative core is Solid-free: `createScene`,
|
|
13
13
|
`createMesh(geometry, material)`, `add`/`remove`, `setTransform`,
|
|
14
|
-
`lookAt`, `getRotation`, `setVisible` - plain objects with dirty flags, batched to a
|
|
14
|
+
`lookAt`, `getRotation`, `setVisible`, `setRenderOrder` - plain objects with dirty flags, batched to a
|
|
15
15
|
microtask,
|
|
16
16
|
one `setDrawParams` (uModel, plus uNormal for materials declaring it)
|
|
17
17
|
per changed mesh and ONE `setTargetParams` (the shared uViewProj +
|
|
@@ -39,11 +39,19 @@ blendMode and pointer events like any element. Design rationale:
|
|
|
39
39
|
Indices are uint16 or uint32 - the `Geometry.indices` array type picks
|
|
40
40
|
the draw's index format, so hand-built geometry past 64k vertices just
|
|
41
41
|
uses a Uint32Array (generators emit uint16). Geometry GPU buffers are
|
|
42
|
-
lazy, shared, and
|
|
43
|
-
|
|
42
|
+
lazy, shared, and reference-counted by draw entries: removing the last
|
|
43
|
+
entry frees them at the end of the microtask (a same-tick rebuild keeps
|
|
44
|
+
the upload), so swapping `<Mesh geometry>` reactively never accumulates
|
|
45
|
+
old generations. `disposeGeometry` is the immediate explicit free.
|
|
44
46
|
- Materials dedupe hard: one program + one pipeline per material CLASS
|
|
45
|
-
(unlit color, unlit map), `depth: true` +
|
|
47
|
+
(unlit color, unlit map, each opaque or transparent), `depth: true` +
|
|
48
|
+
`cull: "back"`; an instance is
|
|
46
49
|
just per-entry uniforms (`uColor`) and bindings (`uMap`).
|
|
50
|
+
- The pure pieces (`math.ts`, `bvh.ts`, `order.ts`, `geometry.ts`) have check rigs in
|
|
51
|
+
`checks/`, run headless on flux from the repo root:
|
|
52
|
+
`bunx srt bundle -f --stdout packages/3d/checks/<name>-check.ts | target/release/flux - [seed]`.
|
|
53
|
+
They print PASS or FAIL lines and throw on failure, which exits nonzero.
|
|
54
|
+
Extend the rig when you change the module.
|
|
47
55
|
|
|
48
56
|
## Components
|
|
49
57
|
|
|
@@ -52,6 +60,7 @@ blendMode and pointer events like any element. Design rationale:
|
|
|
52
60
|
| `Scene` | `width`, `height` (target pixels), `clearColor?`, `background?` (fragment GLSL), `label?`, `ref?(scene)`, `output?(texture)`, `events?` (mesh pointer events, default on) |
|
|
53
61
|
| `Group` | `position?`, `rotation?` (Euler radians, XYZ order), `quaternion?` (either, not both), `scale?` (number = uniform), `visible?`, pointer events (below), `ref?(node)` |
|
|
54
62
|
| `Mesh` | `geometry`, `material`, transforms as Group, `params?` (per-mesh uniforms, merge semantics - no unset), pointer events (below), `ref?(mesh)` |
|
|
63
|
+
| `InstancedMesh` | as Mesh, plus `records` (interleaved per-instance floats; buffer capacity fixed by the first value), `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 |
|
|
55
64
|
| `PerspectiveCamera` | `fov?` (vertical DEGREES, default 60), `near?`, `far?`, `position?`, `lookAt?`, `up?` |
|
|
56
65
|
|
|
57
66
|
Output composition: without `output`, `Scene` emits a minimal
|
|
@@ -155,6 +164,25 @@ hands the baker world-space vertices. `fill` indexes relative to
|
|
|
155
164
|
`first`. It trusts the buffer's layout (no tag to check); withColors is
|
|
156
165
|
the checked path.
|
|
157
166
|
|
|
167
|
+
Geometry as data: `transformGeometry(geometry, { position?, rotation?,
|
|
168
|
+
quaternion?, scale? }, label?)` bakes a placement into a copy (the
|
|
169
|
+
setTransform shape: Euler XYZ radians or a quaternion, number = uniform
|
|
170
|
+
scale), positions through the matrix and normals through its
|
|
171
|
+
inverse-transpose, renormalized - correct under non-uniform scale; uvs,
|
|
172
|
+
colors, indices and layout copy through. `mergeGeometries(parts, label?)`
|
|
173
|
+
concatenates parts into one geometry with offset indices (uint32 past 64k
|
|
174
|
+
vertices); parts must share one layout, a mixed list throws. Together
|
|
175
|
+
they collapse a static scene to one mesh per material - transform each
|
|
176
|
+
part into place, merge, draw once - so only what actually moves keeps a
|
|
177
|
+
node, a draw entry and a per-frame `uModel` write of its own. Both are
|
|
178
|
+
pure array math (Three's `applyMatrix4` + `mergeGeometries`), no GPU
|
|
179
|
+
call, and the source geometries are untouched. `geometryBounds(geometry)`
|
|
180
|
+
returns the cached local AABB `[minX, minY, minZ, maxX, maxY, maxZ]`, and
|
|
181
|
+
`rayBoxDistance(ox, oy, oz, dx, dy, dz, minX, .., maxZ)` is the picking
|
|
182
|
+
slab test (entry t >= 0 in units of the direction's length, 0 from
|
|
183
|
+
inside, -1 for a miss) - for ray-testing boxes you keep yourself
|
|
184
|
+
(triggers, collision volumes) without meshes you do not want to draw.
|
|
185
|
+
|
|
158
186
|
Profile kit (2D outlines to solids, real texture UVs): a `Profile` is a
|
|
159
187
|
closed XY polygon, bare `[x, y]` points crease, `{ p, smooth }` points
|
|
160
188
|
share an averaged normal - `fillet(points, radius, segs?)` and
|
|
@@ -194,7 +222,10 @@ Materials:
|
|
|
194
222
|
at shaderMaterial() creation. The rest is opt-in by declare-and-use:
|
|
195
223
|
`uniform vec3 uCamPos` (the camera's world position, shared and written
|
|
196
224
|
with uViewProj - the specular/fresnel view vector is
|
|
197
|
-
`normalize(uCamPos - worldPos)`)
|
|
225
|
+
`normalize(uCamPos - worldPos)`), `uniform vec3 uCamRight` / `uCamUp`
|
|
226
|
+
(the camera's world-space view axes, shared likewise - a billboard is
|
|
227
|
+
`center + uCamRight * x + uCamUp * y`; do NOT rebuild them from
|
|
228
|
+
uViewProj rows, that carries the clip flip) and `uniform mat4 uNormal` (the world
|
|
198
229
|
inverse-transpose, written beside uModel for this material's meshes;
|
|
199
230
|
take `mat3(uNormal)` - correct under non-uniform scale, where
|
|
200
231
|
mat3(uModel) bends normals off the surface). Attributes come from the
|
|
@@ -208,6 +239,42 @@ Materials:
|
|
|
208
239
|
with the `Mesh` `params` prop (same merge semantics - a key that
|
|
209
240
|
disappears from the object keeps its old value; for per-frame values
|
|
210
241
|
prefer `ref` + setMeshParams from onFrame, the setTransform split).
|
|
242
|
+
Scene-wide values (a clock, a sun direction, fog) go through
|
|
243
|
+
`scene.setParams({ uTime })` instead - one write for every mesh.
|
|
244
|
+
- `shaderMaterialClass({ vertex, fragment, ...pipeline state })` - the
|
|
245
|
+
class/instance split for your own GLSL: compiles once, and
|
|
246
|
+
`cls.instance({ params?, textures? })` returns a Material sharing that
|
|
247
|
+
pipeline with its own values. `dispose()` lives on the class alone.
|
|
248
|
+
`shaderMaterial(opts)` is exactly a class with one instance (its
|
|
249
|
+
`dispose` forwards to the class).
|
|
250
|
+
- `instanceAttributes: [{ name, format }]` on either shader-material form
|
|
251
|
+
makes an INSTANCED material: the vertex stage reads them as `in`
|
|
252
|
+
variables beside the layout's own, and each drawn instance gets one
|
|
253
|
+
record of the mesh's instance buffer. Its meshes come from
|
|
254
|
+
`createInstancedMesh` (below); a `createMesh` mesh is rejected at add().
|
|
255
|
+
|
|
256
|
+
Instancing - one draw entry covering a population:
|
|
257
|
+
`createInstancedMesh(geometry, material, records, count?, { bounds?,
|
|
258
|
+
label? })` returns an ordinary Mesh whose entry draws the geometry once
|
|
259
|
+
per record. `records` is the interleaved per-instance data (stride = the
|
|
260
|
+
material's instanceAttributes summed, a mismatch throws), uploaded to a
|
|
261
|
+
mesh-owned buffer whose CAPACITY is fixed at creation. `count` picks how
|
|
262
|
+
many records draw (default all). Everything mesh works unchanged:
|
|
263
|
+
setTransform moves the whole population through one uModel, setVisible
|
|
264
|
+
zeroes the drawn count and restores the record count on unhide,
|
|
265
|
+
renderOrder/params/geometry/material swaps apply. `setInstances(mesh,
|
|
266
|
+
records, count?)` rewrites records from the start (count defaults to the
|
|
267
|
+
records written; more than capacity throws - make a new mesh to grow),
|
|
268
|
+
`setInstanceCount(mesh, n)` is the population dial (clamped to capacity;
|
|
269
|
+
frame-rate-safe), and `disposeInstances(mesh)` detaches and frees the
|
|
270
|
+
record buffer - the one explicit free, geometry-buffer rule. Records are
|
|
271
|
+
opaque data (position/yaw/tint/whatever your shader reads), NOT matrices:
|
|
272
|
+
a per-instance mat4 would be four vec4 columns reassembled in the shader,
|
|
273
|
+
but most fleets want a few floats. Picking: the library cannot know where
|
|
274
|
+
records place instances, so an instanced mesh has NO picking leaf unless
|
|
275
|
+
you pass `bounds` (local, covering the population) - then it picks and
|
|
276
|
+
transparent-sorts conservatively as one box. `examples/instanced.tsx` is
|
|
277
|
+
the live proof.
|
|
211
278
|
|
|
212
279
|
Background: `scene.setBackground(source | null)`, the `background` option
|
|
213
280
|
on createScene, and the reactive `Scene` prop. Fragment GLSL drawn as the
|
|
@@ -245,14 +312,46 @@ system.
|
|
|
245
312
|
"fix" the negated row of `perspective()` - both would mirror the winding
|
|
246
313
|
and show mesh interiors.
|
|
247
314
|
- `visible: false` keeps the entry, drawn with `instanceCount: 0` (a
|
|
248
|
-
cheap off switch)
|
|
315
|
+
cheap off switch); unhiding writes 1, or the mesh's own record count
|
|
316
|
+
when it is instanced - never a bare 1 into an instanced entry. Hidden
|
|
317
|
+
meshes skip uModel writes; the fresh matrix is
|
|
249
318
|
written on unhide. A freshly attached entry starts off the same way and
|
|
250
319
|
sync() turns it on when it writes uModel - never add one live: it has no
|
|
251
320
|
world matrix yet, and drawn before the sync microtask it flashes at the
|
|
252
321
|
world origin for a frame.
|
|
253
|
-
-
|
|
254
|
-
|
|
255
|
-
|
|
322
|
+
- Instancing pairs strictly at add(), like layout: an instanced material
|
|
323
|
+
needs a createInstancedMesh mesh (records included) and vice versa, and
|
|
324
|
+
the record stride must match the material's attributes - each mismatch
|
|
325
|
+
throws there. The instance buffer is MESH-owned (unlike shared geometry
|
|
326
|
+
buffers): `disposeInstances` is its one free, and the mesh cannot be
|
|
327
|
+
re-added afterwards. Capacity is fixed at creation - `setInstances` with
|
|
328
|
+
more records than capacity throws rather than growing (growing is a new
|
|
329
|
+
mesh; buffers do not resize).
|
|
330
|
+
- An instanced mesh without explicit `bounds` has no BVH leaf: it never
|
|
331
|
+
picks, pointer events never target it, and its transparent sort key
|
|
332
|
+
falls back to the node's world position. That is deliberate - records
|
|
333
|
+
are opaque to the library, so any inferred box would be a guess. Supply
|
|
334
|
+
`bounds` for anything pickable or transparent.
|
|
335
|
+
- Transparency is an EXPLICIT material flag, Three's rule: `unlit({ color:
|
|
336
|
+
[r, g, b, 0.5] })` still draws opaque; `unlit({ ..., transparent: true })`
|
|
337
|
+
(or `shaderMaterial({ transparent: true })`) builds the pipeline with
|
|
338
|
+
`blend: "alpha"` and `depthWrite: false` (depth test stays on, so it hides
|
|
339
|
+
behind opaques without occluding other translucents). The one inference:
|
|
340
|
+
a `shaderMaterial` with any `blend` but "none" is transparent unless told
|
|
341
|
+
`transparent: false` - every blended draw belongs after the opaques, and
|
|
342
|
+
back-to-front is harmless for add/multiply. The scene owns the
|
|
343
|
+
order: background, opaque meshes by `renderOrder` then add order,
|
|
344
|
+
transparent meshes by `renderOrder` then back-to-front by the CENTER of
|
|
345
|
+
the mesh's world bounds in view space (not the origin: off-origin geometry
|
|
346
|
+
sorts by where it is; not the nearest bounds point: a big translucent
|
|
347
|
+
ground plane would cover the small translucents on it) - one `setDrawOrder` from sync() whenever the list changed, a
|
|
348
|
+
renderOrder changed, or (with two or more transparent meshes) the camera
|
|
349
|
+
or a transparent mesh moved, and skipped when the resort lands on the
|
|
350
|
+
permutation already issued. Per-mesh sort only: one non-convex translucent
|
|
351
|
+
mesh still overlaps itself in vertex order, and two large interpenetrating
|
|
352
|
+
translucents can sort wrong (center distance, not per-pixel) - that is the
|
|
353
|
+
engine contract, no OIT. A `shaderMaterial({ transparent: true })`
|
|
354
|
+
fragment must write PREMULTIPLIED output (`vec4(rgb * a, a)`).
|
|
256
355
|
- Rotation is stored as a QUATERNION (`node.quaternion`, `[x, y, z, w]`,
|
|
257
356
|
always unit). There is exactly one rotation field: no `node.rotation`
|
|
258
357
|
shadowing it, because a second field is a second thing to go stale (an
|
|
@@ -329,9 +428,36 @@ system.
|
|
|
329
428
|
camera writes (uEye-style per-mesh params are exactly the O(scene) cost
|
|
330
429
|
the shared channel removed). Scene scale honestly: hundreds to a
|
|
331
430
|
few thousand objects, bounded by the interpreter, not the GPU.
|
|
431
|
+
- SCENE-WIDE uniforms go through that same shared channel via
|
|
432
|
+
`scene.setParams({ uTime })`, and this is the single highest-leverage
|
|
433
|
+
pattern in the library. It merges an app-owned name in beside
|
|
434
|
+
uViewProj/uCamPos/uCamRight/uCamUp - names merge, a target tolerates
|
|
435
|
+
zero coverage, neither side clobbers the other. One write per frame
|
|
436
|
+
however many meshes read it, with the motion itself in vertex shaders
|
|
437
|
+
off that one clock. `params`/`setMeshParams` is the PER-MESH answer and
|
|
438
|
+
is O(meshes) per frame; reach for it only when the value genuinely
|
|
439
|
+
differs per mesh. (`scene.texture` IS the draw target id, so
|
|
440
|
+
`setTargetParams(scene.texture, ...)` is the same write - setParams is
|
|
441
|
+
the sanctioned spelling.)
|
|
442
|
+
- Vec3/Quat arguments are COPIED IN everywhere (`setTransform`, `lookAt`,
|
|
443
|
+
`setCamera`, params), so ONE scratch array reused every frame is safe -
|
|
444
|
+
allocating three arrays per node per frame is pure waste. The node's own
|
|
445
|
+
`position`/`quaternion`/`scale` are the live arrays: read them, do not
|
|
446
|
+
hand them out and do not mutate them (that write does not sync).
|
|
447
|
+
- `setTransform` early-outs on an unchanged value (rotation compared AFTER
|
|
448
|
+
euler conversion), so driving every node unconditionally from `onFrame`
|
|
449
|
+
costs only the compare for nodes that did not move. Compares are exact,
|
|
450
|
+
like `setVisible`.
|
|
451
|
+
- Per-generator conventions - orientation, UV mapping, which axis a solid
|
|
452
|
+
stands on, what a cap looks like - live on each generator's doc comment,
|
|
453
|
+
not here. They are consistent (`plane`/`circle`/`ring` face +z, `torus`
|
|
454
|
+
lies flat with the hole on y, discs and cylinder caps get a PLANAR disc
|
|
455
|
+
map inscribed in the unit square) but the doc comment is the source.
|
|
332
456
|
- Entry rebuild order: `setGeometry`/`setMaterial` re-add the entry at the
|
|
333
|
-
list END
|
|
334
|
-
|
|
457
|
+
list END and dirty the order, so the next sync() re-sorts and the mesh
|
|
458
|
+
keeps its place. `_transparent` on the mesh is the flag AS ATTACHED
|
|
459
|
+
(setMaterial swaps `mesh.material` before the rebuild, so _detach must
|
|
460
|
+
not read the new material's flag).
|
|
335
461
|
- `lathe` takes a CLOSED profile (a cross-section with thickness, or run
|
|
336
462
|
to the axis at x = 0) - it is a solid of revolution, NOT Three's open
|
|
337
463
|
polyline shell. An "open" outline must be closed by the author;
|
|
@@ -342,7 +468,14 @@ system.
|
|
|
342
468
|
compile twice - no dedupe by source value (deliberate; hidden
|
|
343
469
|
content-keyed caches are the anti-pattern the GPU layer avoids). Create
|
|
344
470
|
one per look at app scope, share across meshes, `dispose()` when done
|
|
345
|
-
for good.
|
|
471
|
+
for good. Looks that differ only in params/textures are ONE
|
|
472
|
+
`shaderMaterialClass` and many `instance()`s - the app-owned split, not
|
|
473
|
+
a cache. A class instance has no `dispose` of its own; disposing the
|
|
474
|
+
class invalidates every instance.
|
|
475
|
+
- A parameterised class whose variants (mapped/unmapped, ...) are SEPARATE
|
|
476
|
+
classes must have every variant reference every shared uniform it is
|
|
477
|
+
seeded with: a declared-but-unused per-entry name compiles out and
|
|
478
|
+
throws at add(). Open item: `okf/backlog/gpu-inactive-uniform-two-tier.md`.
|
|
346
479
|
- The standard-set contract is checked TEXTUALLY at shaderMaterial()
|
|
347
480
|
creation (uModel and uViewProj must appear in the vertex source) and
|
|
348
481
|
strictly at add() for the per-entry names: a uModel or uNormal that is
|
|
@@ -380,9 +513,8 @@ system.
|
|
|
380
513
|
Geometry instead.
|
|
381
514
|
- The background covers the whole target with depth off, drawn first: it
|
|
382
515
|
REPLACES the clearColor visually (the clear still runs; you just never
|
|
383
|
-
see it), and a
|
|
384
|
-
is
|
|
385
|
-
two-layer composition until blend factors land).
|
|
516
|
+
see it), and a `transparent: true` mesh blends over it in-pass since the
|
|
517
|
+
background is always entry zero.
|
|
386
518
|
- The background pipeline/program are SCENE-OWNED (unlike shared
|
|
387
519
|
material pipelines): setBackground(null), replacement, and dispose()
|
|
388
520
|
destroy them. Do not hand the background's pipeline to anything else.
|
package/README.md
CHANGED
|
@@ -62,9 +62,16 @@ bounding-box accurate in v1. A scene also takes a `background` - fragment
|
|
|
62
62
|
GLSL drawn inside its own pass behind the meshes, replacing the stacked
|
|
63
63
|
backdrop-texture pattern.
|
|
64
64
|
Custom materials get a standard uniform set - per-mesh `uModel`/`uNormal`,
|
|
65
|
-
shared `uViewProj`/`uCamPos`, each written once per
|
|
66
|
-
|
|
67
|
-
|
|
65
|
+
shared `uViewProj`/`uCamPos`/`uCamRight`/`uCamUp`, each written once per
|
|
66
|
+
change - plus your own uniforms: scene-wide via `scene.setParams` (one write
|
|
67
|
+
however many meshes read it), or per mesh, declaratively via the `params`
|
|
68
|
+
prop on `<Mesh>` or imperatively via `setMeshParams`. `shaderMaterialClass`
|
|
69
|
+
compiles one program and hands out `instance()` materials that differ only
|
|
70
|
+
in params/textures - and with `instanceAttributes` it makes an instanced
|
|
71
|
+
material: `<InstancedMesh records>` (or `createInstancedMesh`) then draws
|
|
72
|
+
the geometry once per interleaved record as ONE draw entry, the shape for
|
|
73
|
+
forests, particles, and every fleet whose per-copy data is a few floats
|
|
74
|
+
(`examples/instanced.tsx`). And
|
|
68
75
|
`@solidrt/3d/glsl` exports the lighting pieces (hemisphere, lambert,
|
|
69
76
|
blinn, fresnel, a standard vertex stage) to compose your own lit looks
|
|
70
77
|
from plain template literals.
|
|
@@ -74,7 +81,9 @@ GLSL as a first-class material), geometry generators (box, plane, circle,
|
|
|
74
81
|
ring, sphere, cylinder, cone, torus, torus knot), a profile kit for custom
|
|
75
82
|
solids (`extrude` with bevels, `lathe`, polyline `sweep`/`tube` with
|
|
76
83
|
mitred joints, flat `shape`, with `fillet`/`roundRect`/`triangulate`
|
|
77
|
-
helpers),
|
|
84
|
+
helpers), geometry as data (`transformGeometry` bakes a placement into
|
|
85
|
+
vertices and `mergeGeometries` concatenates parts, so a static scene is
|
|
86
|
+
one mesh per material), a per-vertex data channel
|
|
78
87
|
(`withColors` adds an `aColor` vec4 - tint, baked AO, any four scalars -
|
|
79
88
|
to any geometry, for materials that read it), one perspective camera
|
|
80
89
|
with an orbit control (`createOrbitCamera`: drag, pinch/wheel zoom, auto-orbit),
|
package/examples/README.md
CHANGED
|
@@ -23,3 +23,8 @@ depends on `@solidrt/3d` (or in-repo from the package directory).
|
|
|
23
23
|
scene's own pass (`<Scene background>`): one target, no stacked
|
|
24
24
|
backdrop texture, no resize plumbing; the source is shader-target
|
|
25
25
|
compatible verbatim.
|
|
26
|
+
- `instanced.tsx` - instanced meshes: one material class declaring
|
|
27
|
+
`instanceAttributes`, two `<InstancedMesh>` fleets (400 scattered
|
|
28
|
+
rocks, a ring of pines) each ONE draw entry and ONE uModel, a spinning
|
|
29
|
+
group moving both with two matrix writes, and `setInstanceCount` from
|
|
30
|
+
onFrame breathing the pine population.
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// Instanced meshes: one draw entry covering a whole population. A material
|
|
2
|
+
// class declares `instanceAttributes`, createInstancedMesh (here via the
|
|
3
|
+
// <InstancedMesh> component) supplies one interleaved record per instance,
|
|
4
|
+
// and the vertex stage reads each record through the matching `in`
|
|
5
|
+
// variables. Two meshes share the one class: 400 scattered "rocks" and a
|
|
6
|
+
// breathing ring of "pines" - each is ONE entry and ONE uModel however
|
|
7
|
+
// many instances it draws, so the spinning group below moves both fleets
|
|
8
|
+
// with two matrix writes per frame.
|
|
9
|
+
//
|
|
10
|
+
// setInstanceCount is the population dial (the pines breathe); records are
|
|
11
|
+
// data, not matrices - position/scale/tint here, whatever your shader
|
|
12
|
+
// wants in general. The explicit `bounds` cover the scatter so picking
|
|
13
|
+
// still works (one conservative box around the population; omit bounds and
|
|
14
|
+
// the mesh simply never picks).
|
|
15
|
+
import { createSignal, onFrame, pct, render } from "@solidrt/core"
|
|
16
|
+
import { glsl } from "@solidrt/core/gpu"
|
|
17
|
+
import {
|
|
18
|
+
box,
|
|
19
|
+
cone,
|
|
20
|
+
Group,
|
|
21
|
+
InstancedMesh,
|
|
22
|
+
Mesh,
|
|
23
|
+
PerspectiveCamera,
|
|
24
|
+
plane,
|
|
25
|
+
Scene,
|
|
26
|
+
setInstanceCount,
|
|
27
|
+
shaderMaterialClass,
|
|
28
|
+
unlit,
|
|
29
|
+
} from "@solidrt/3d"
|
|
30
|
+
import type { InstancedMeshNode } from "@solidrt/3d"
|
|
31
|
+
import { HEMISPHERE } from "@solidrt/3d/glsl"
|
|
32
|
+
|
|
33
|
+
const SIZE = 720
|
|
34
|
+
|
|
35
|
+
const INSTANCE_VERTEX = glsl`
|
|
36
|
+
in vec3 aPos;
|
|
37
|
+
in vec3 aNormal;
|
|
38
|
+
in vec3 iPos;
|
|
39
|
+
in float iScale;
|
|
40
|
+
in vec3 iTint;
|
|
41
|
+
out vec3 vNormal;
|
|
42
|
+
out vec3 vTint;
|
|
43
|
+
uniform mat4 uModel;
|
|
44
|
+
uniform mat4 uViewProj;
|
|
45
|
+
|
|
46
|
+
void main() {
|
|
47
|
+
vec3 p = aPos * iScale + iPos;
|
|
48
|
+
gl_Position = uViewProj * uModel * vec4(p, 1.0);
|
|
49
|
+
vNormal = mat3(uModel) * aNormal;
|
|
50
|
+
vTint = iTint;
|
|
51
|
+
}
|
|
52
|
+
`
|
|
53
|
+
|
|
54
|
+
const INSTANCE_FRAGMENT = glsl`
|
|
55
|
+
in vec3 vNormal;
|
|
56
|
+
in vec3 vTint;
|
|
57
|
+
${HEMISPHERE}
|
|
58
|
+
|
|
59
|
+
void main() {
|
|
60
|
+
vec3 c = vTint * hemisphere(normalize(vNormal), vec3(1.05, 1.0, 0.95), vec3(0.35, 0.32, 0.3));
|
|
61
|
+
fragColor = vec4(c, 1.0);
|
|
62
|
+
}
|
|
63
|
+
`
|
|
64
|
+
|
|
65
|
+
// One record per instance, interleaved in attribute order: 7 floats.
|
|
66
|
+
const STRIDE = 7
|
|
67
|
+
|
|
68
|
+
// A deterministic scatter (no per-run surprises when eyeballing).
|
|
69
|
+
function rocks(count: number): Float32Array {
|
|
70
|
+
let records = new Float32Array(count * STRIDE)
|
|
71
|
+
let a = 0
|
|
72
|
+
for (let i = 0; i < count; i++) {
|
|
73
|
+
a += 2.399963 // golden angle: an even spiral scatter
|
|
74
|
+
let r = 0.35 + 3.4 * Math.sqrt((i + 0.5) / count)
|
|
75
|
+
let s = 0.05 + 0.11 * ((i * 7) % 10) / 10
|
|
76
|
+
let o = i * STRIDE
|
|
77
|
+
records[o] = Math.cos(a) * r
|
|
78
|
+
records[o + 1] = s / 2
|
|
79
|
+
records[o + 2] = Math.sin(a) * r
|
|
80
|
+
records[o + 3] = s
|
|
81
|
+
records[o + 4] = 0.55 + 0.3 * ((i * 3) % 5) / 5
|
|
82
|
+
records[o + 5] = 0.5 + 0.2 * ((i * 11) % 7) / 7
|
|
83
|
+
records[o + 6] = 0.45
|
|
84
|
+
}
|
|
85
|
+
return records
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function pines(count: number): Float32Array {
|
|
89
|
+
let records = new Float32Array(count * STRIDE)
|
|
90
|
+
for (let i = 0; i < count; i++) {
|
|
91
|
+
let a = (i / count) * Math.PI * 2
|
|
92
|
+
let s = 0.5 + 0.25 * ((i * 5) % 8) / 8
|
|
93
|
+
let o = i * STRIDE
|
|
94
|
+
records[o] = Math.cos(a) * 2.4
|
|
95
|
+
records[o + 1] = s / 2
|
|
96
|
+
records[o + 2] = Math.sin(a) * 2.4
|
|
97
|
+
records[o + 3] = s
|
|
98
|
+
records[o + 4] = 0.15
|
|
99
|
+
records[o + 5] = 0.4 + 0.25 * ((i * 3) % 6) / 6
|
|
100
|
+
records[o + 6] = 0.2
|
|
101
|
+
}
|
|
102
|
+
return records
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const PINE_COUNT = 48
|
|
106
|
+
|
|
107
|
+
function App() {
|
|
108
|
+
let [spin, setSpin] = createSignal(0)
|
|
109
|
+
let pinesMesh!: InstancedMeshNode
|
|
110
|
+
onFrame(tick => {
|
|
111
|
+
setSpin(tick / 6000)
|
|
112
|
+
// The population dial: draw the first N records. The buffer holds the
|
|
113
|
+
// full ring; only the draw range moves, one setDrawRange per change.
|
|
114
|
+
let n = Math.round(PINE_COUNT * (0.5 + 0.5 * Math.sin(tick / 900)))
|
|
115
|
+
if (pinesMesh) setInstanceCount(pinesMesh, n)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<window>
|
|
120
|
+
<view width={pct(100)} height={pct(100)} viewBox={[SIZE, SIZE]}>
|
|
121
|
+
<Scene width={SIZE} height={SIZE} clearColor={[0.07, 0.08, 0.1, 1]} label="instanced">
|
|
122
|
+
<PerspectiveCamera fov={55} position={[0, 3.2, 5.4]} lookAt={[0, 0.2, 0]} />
|
|
123
|
+
<Mesh geometry={plane(9, 9, "meadow")} material={unlit({ color: [0.16, 0.18, 0.16] })} rotation={[-Math.PI / 2, 0, 0]} />
|
|
124
|
+
<Group rotation={[0, spin(), 0]}>
|
|
125
|
+
<InstancedMesh
|
|
126
|
+
geometry={box(1, 1, 1, "rock")}
|
|
127
|
+
material={instancedLook.instance()}
|
|
128
|
+
records={rocks(400)}
|
|
129
|
+
bounds={[-3.9, 0, -3.9, 3.9, 0.2, 3.9]}
|
|
130
|
+
/>
|
|
131
|
+
<InstancedMesh
|
|
132
|
+
geometry={cone(0.3, 1, 10, "pine")}
|
|
133
|
+
material={instancedLook.instance()}
|
|
134
|
+
records={pines(PINE_COUNT)}
|
|
135
|
+
bounds={[-2.8, 0, -2.8, 2.8, 0.8, 2.8]}
|
|
136
|
+
ref={m => (pinesMesh = m)}
|
|
137
|
+
/>
|
|
138
|
+
</Group>
|
|
139
|
+
</Scene>
|
|
140
|
+
</view>
|
|
141
|
+
</window>
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// One class, one compiled pipeline; each mesh gets its own instance() so
|
|
146
|
+
// per-mesh uniforms stay independent (none are used here).
|
|
147
|
+
let instancedLook = shaderMaterialClass({
|
|
148
|
+
vertex: INSTANCE_VERTEX,
|
|
149
|
+
fragment: INSTANCE_FRAGMENT,
|
|
150
|
+
instanceAttributes: [
|
|
151
|
+
{ name: "iPos", format: "vec3" },
|
|
152
|
+
{ name: "iScale", format: "f32" },
|
|
153
|
+
{ name: "iTint", format: "vec3" },
|
|
154
|
+
],
|
|
155
|
+
label: "instanced-look",
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
render(() => <App />)
|
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@solidrt/3d",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.51",
|
|
4
4
|
"license": "MIT",
|
|
5
|
+
"funding": "https://github.com/sponsors/wellawaretech",
|
|
5
6
|
"author": "Antoine van Wel",
|
|
6
7
|
"type": "module",
|
|
7
8
|
"main": "src/index.ts",
|
|
@@ -17,6 +18,6 @@
|
|
|
17
18
|
],
|
|
18
19
|
"peerDependencies": {
|
|
19
20
|
"@solidjs/signals": "2.0.0-rc.0",
|
|
20
|
-
"@solidrt/core": "0.0.
|
|
21
|
+
"@solidrt/core": "0.0.51"
|
|
21
22
|
}
|
|
22
23
|
}
|
package/src/bvh.ts
CHANGED
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
// pays nothing. Storage is flat parallel arrays indexed by node id (no
|
|
13
13
|
// per-node objects, no allocation at steady state past tree growth).
|
|
14
14
|
//
|
|
15
|
-
// Pure module by design: no
|
|
16
|
-
// (checks/pick-check.ts) runs it
|
|
15
|
+
// Pure module by design: no GUI imports, so the differential check rig
|
|
16
|
+
// (checks/pick-check.ts) runs it headless on flux against a linear oracle.
|
|
17
17
|
|
|
18
18
|
/** Fat-margin fraction of a leaf's largest extent. Bigger = fewer
|
|
19
19
|
* re-inserts while moving, worse query pruning; 5% is the usual trade. */
|
package/src/components.tsx
CHANGED
|
@@ -11,17 +11,22 @@ import type { Element, ParentComponent, TextureId, VoidComponent } from "@solidr
|
|
|
11
11
|
import {
|
|
12
12
|
add,
|
|
13
13
|
createGroup,
|
|
14
|
+
createInstancedMesh,
|
|
14
15
|
createMesh,
|
|
15
16
|
createScene,
|
|
17
|
+
disposeInstances,
|
|
16
18
|
remove,
|
|
17
19
|
setGeometry,
|
|
20
|
+
setInstanceCount,
|
|
21
|
+
setInstances,
|
|
18
22
|
setMaterial,
|
|
19
23
|
setMeshParams,
|
|
24
|
+
setRenderOrder,
|
|
20
25
|
setTransform,
|
|
21
26
|
setVisible,
|
|
22
27
|
} from "./scene.ts"
|
|
23
28
|
import type { ShaderParams } from "@solidrt/core/gpu"
|
|
24
|
-
import type { Mesh as MeshNode, Scene as SceneHandle, SceneNode, ScenePointerEvent } from "./scene.ts"
|
|
29
|
+
import type { InstancedMesh as InstancedMeshNode, Mesh as MeshNode, Scene as SceneHandle, SceneNode, ScenePointerEvent } from "./scene.ts"
|
|
25
30
|
import type { Geometry } from "./geometry.ts"
|
|
26
31
|
import type { Material } from "./material.ts"
|
|
27
32
|
import type { Quat, Vec3 } from "./math.ts"
|
|
@@ -179,6 +184,8 @@ export type MeshProps = TransformProps & PointerEventProps & {
|
|
|
179
184
|
* changing every frame prefer `ref` + setMeshParams from onFrame, the
|
|
180
185
|
* same split as setTransform. */
|
|
181
186
|
params?: ShaderParams
|
|
187
|
+
/** Explicit draw-order key (setRenderOrder as a prop); default 0. */
|
|
188
|
+
renderOrder?: number
|
|
182
189
|
ref?: (mesh: MeshNode) => void
|
|
183
190
|
}
|
|
184
191
|
|
|
@@ -203,12 +210,84 @@ export let Mesh: VoidComponent<MeshProps> = props => {
|
|
|
203
210
|
if (p !== undefined) setMeshParams(mesh, p)
|
|
204
211
|
},
|
|
205
212
|
)
|
|
213
|
+
createEffect(
|
|
214
|
+
() => props.renderOrder,
|
|
215
|
+
o => setRenderOrder(mesh, o ?? 0),
|
|
216
|
+
)
|
|
206
217
|
syncNode(mesh, props)
|
|
207
218
|
untrack(() => props.ref)?.(mesh)
|
|
208
219
|
onCleanup(() => remove(mesh))
|
|
209
220
|
return null
|
|
210
221
|
}
|
|
211
222
|
|
|
223
|
+
export type InstancedMeshProps = TransformProps & PointerEventProps & {
|
|
224
|
+
geometry: Geometry
|
|
225
|
+
/** Must declare instanceAttributes (shaderMaterialClass). */
|
|
226
|
+
material: Material
|
|
227
|
+
/** Interleaved per-instance records (stride = the material's instance
|
|
228
|
+
* attributes summed). Reactive, but the buffer's CAPACITY is fixed by the
|
|
229
|
+
* first value - a later array may hold at most that many records. */
|
|
230
|
+
records: Float32Array
|
|
231
|
+
/** How many records draw; default all of the latest `records`. */
|
|
232
|
+
count?: number
|
|
233
|
+
/** LOCAL bounds covering every instance ([minX..maxZ]), fixed at
|
|
234
|
+
* creation. Without them the mesh has no picking leaf, so pointer events
|
|
235
|
+
* never target it. */
|
|
236
|
+
bounds?: ArrayLike<number>
|
|
237
|
+
/** Per-mesh uniforms, merge semantics - as on Mesh. */
|
|
238
|
+
params?: ShaderParams
|
|
239
|
+
/** Explicit draw-order key (setRenderOrder as a prop); default 0. */
|
|
240
|
+
renderOrder?: number
|
|
241
|
+
ref?: (mesh: InstancedMeshNode) => void
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** One draw entry covering N instances: geometry repeated per record of
|
|
245
|
+
* `records` (createInstancedMesh as a component). The record buffer is
|
|
246
|
+
* component-owned and freed on unmount. */
|
|
247
|
+
export let InstancedMesh: VoidComponent<InstancedMeshProps> = props => {
|
|
248
|
+
let ctx = useContext(SceneContext)
|
|
249
|
+
let mesh = untrack(() =>
|
|
250
|
+
createInstancedMesh(props.geometry, props.material, props.records, props.count, { bounds: props.bounds }),
|
|
251
|
+
)
|
|
252
|
+
add(ctx.parent, mesh)
|
|
253
|
+
createEffect(
|
|
254
|
+
() => props.records,
|
|
255
|
+
r => setInstances(mesh, r, untrack(() => props.count)),
|
|
256
|
+
{ defer: true },
|
|
257
|
+
)
|
|
258
|
+
createEffect(
|
|
259
|
+
() => props.count,
|
|
260
|
+
c => {
|
|
261
|
+
if (c !== undefined) setInstanceCount(mesh, c)
|
|
262
|
+
},
|
|
263
|
+
{ defer: true },
|
|
264
|
+
)
|
|
265
|
+
createEffect(
|
|
266
|
+
() => props.geometry,
|
|
267
|
+
g => setGeometry(mesh, g),
|
|
268
|
+
{ defer: true },
|
|
269
|
+
)
|
|
270
|
+
createEffect(
|
|
271
|
+
() => props.material,
|
|
272
|
+
m => setMaterial(mesh, m),
|
|
273
|
+
{ defer: true },
|
|
274
|
+
)
|
|
275
|
+
createEffect(
|
|
276
|
+
() => props.params,
|
|
277
|
+
p => {
|
|
278
|
+
if (p !== undefined) setMeshParams(mesh, p)
|
|
279
|
+
},
|
|
280
|
+
)
|
|
281
|
+
createEffect(
|
|
282
|
+
() => props.renderOrder,
|
|
283
|
+
o => setRenderOrder(mesh, o ?? 0),
|
|
284
|
+
)
|
|
285
|
+
syncNode(mesh, props)
|
|
286
|
+
untrack(() => props.ref)?.(mesh)
|
|
287
|
+
onCleanup(() => disposeInstances(mesh))
|
|
288
|
+
return null
|
|
289
|
+
}
|
|
290
|
+
|
|
212
291
|
export type PerspectiveCameraProps = {
|
|
213
292
|
/** Vertical field of view in DEGREES (default 60). */
|
|
214
293
|
fov?: number
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Geometry on the GPU: the lazy buffer step for geometry.ts's data. Buffers
|
|
2
|
+
// are created on first acquire and shared by every mesh and scene drawing
|
|
3
|
+
// the geometry; each draw entry holds one reference, and the buffers are
|
|
4
|
+
// freed when the last reference is released - deferred to a microtask, so
|
|
5
|
+
// a same-tick entry rebuild (a material swap, a geometry that comes right
|
|
6
|
+
// back) keeps its upload. The handles and the reference count live in a
|
|
7
|
+
// map private to this module, keeping Geometry itself plain data.
|
|
8
|
+
// disposeGeometry frees immediately, the explicit override; either way the
|
|
9
|
+
// geometry stays usable - fresh buffers are created on next acquire.
|
|
10
|
+
|
|
11
|
+
import { createBuffer, destroyBuffer } from "@solidrt/core/gpu"
|
|
12
|
+
import type { BufferId, IndexFormat } from "@solidrt/core/gpu"
|
|
13
|
+
import type { Geometry } from "./geometry.ts"
|
|
14
|
+
|
|
15
|
+
/** An acquired reference to a geometry's GPU buffers: what a draw entry
|
|
16
|
+
* binds, and the token releaseGeometryBuffers takes - releasing the exact
|
|
17
|
+
* acquisition keeps the pairing correct however the caller's geometry
|
|
18
|
+
* fields have moved since. */
|
|
19
|
+
export type GeometryBuffers = {
|
|
20
|
+
buffer: BufferId
|
|
21
|
+
index: BufferId
|
|
22
|
+
indexFormat: IndexFormat
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type GpuEntry = GeometryBuffers & { geometry: Geometry; refs: number }
|
|
26
|
+
|
|
27
|
+
let entries = new WeakMap<Geometry, GpuEntry>()
|
|
28
|
+
|
|
29
|
+
/** The geometry's GPU buffers, created on first use, plus the index format
|
|
30
|
+
* the draw entry must bind them with. Takes a reference - pair every
|
|
31
|
+
* acquire with a releaseGeometryBuffers of the returned token when the
|
|
32
|
+
* entry built from it goes. */
|
|
33
|
+
export function acquireGeometryBuffers(geometry: Geometry): GeometryBuffers {
|
|
34
|
+
let entry = entries.get(geometry)
|
|
35
|
+
if (entry === undefined) {
|
|
36
|
+
entry = {
|
|
37
|
+
geometry,
|
|
38
|
+
buffer: createBuffer(geometry.vertices, {
|
|
39
|
+
autoFree: false,
|
|
40
|
+
label: geometry.label ? geometry.label + "-verts" : undefined,
|
|
41
|
+
}),
|
|
42
|
+
index: createBuffer(geometry.indices, {
|
|
43
|
+
autoFree: false,
|
|
44
|
+
label: geometry.label ? geometry.label + "-indices" : undefined,
|
|
45
|
+
}),
|
|
46
|
+
indexFormat: geometry.indices instanceof Uint32Array ? "uint32" : "uint16",
|
|
47
|
+
refs: 0,
|
|
48
|
+
}
|
|
49
|
+
entries.set(geometry, entry)
|
|
50
|
+
}
|
|
51
|
+
entry.refs++
|
|
52
|
+
return entry
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Release one acquire. At zero references the buffers are freed at the
|
|
56
|
+
* end of the microtask; an acquire before then keeps them, so a detach and
|
|
57
|
+
* re-attach in one tick never re-uploads. A token orphaned by an explicit
|
|
58
|
+
* disposeGeometry releases against the orphan, never against a successor's
|
|
59
|
+
* fresh buffers. */
|
|
60
|
+
export function releaseGeometryBuffers(acquired: GeometryBuffers): void {
|
|
61
|
+
let entry = acquired as GpuEntry
|
|
62
|
+
if (entry.refs === 0) return
|
|
63
|
+
entry.refs--
|
|
64
|
+
if (entry.refs > 0) return
|
|
65
|
+
queueMicrotask(() => {
|
|
66
|
+
if (entries.get(entry.geometry) !== entry || entry.refs > 0) return
|
|
67
|
+
entries.delete(entry.geometry)
|
|
68
|
+
destroyBuffer(entry.buffer)
|
|
69
|
+
destroyBuffer(entry.index)
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Free the geometry's GPU buffers now, held references or not - the
|
|
75
|
+
* explicit override for geometry an app is done with for good. Draw
|
|
76
|
+
* entries created from them hold their own reference, so destruction order
|
|
77
|
+
* is safe; the geometry can be used again afterwards (fresh buffers are
|
|
78
|
+
* created on next use).
|
|
79
|
+
*/
|
|
80
|
+
export function disposeGeometry(geometry: Geometry): void {
|
|
81
|
+
let entry = entries.get(geometry)
|
|
82
|
+
if (entry === undefined) return
|
|
83
|
+
entries.delete(geometry)
|
|
84
|
+
destroyBuffer(entry.buffer)
|
|
85
|
+
destroyBuffer(entry.index)
|
|
86
|
+
}
|