@solidrt/3d 0.0.50 → 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 +71 -6
- package/README.md +8 -2
- package/examples/README.md +5 -0
- package/examples/instanced.tsx +158 -0
- package/package.json +3 -2
- package/src/components.tsx +73 -1
- package/src/geometry-gpu.ts +86 -0
- package/src/geometry.ts +94 -47
- package/src/index.ts +7 -5
- package/src/material.ts +22 -1
- package/src/math.ts +45 -0
- package/src/scene.ts +238 -42
package/AGENTS.md
CHANGED
|
@@ -39,17 +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
47
|
(unlit color, unlit map, each opaque or transparent), `depth: true` +
|
|
46
48
|
`cull: "back"`; an instance is
|
|
47
49
|
just per-entry uniforms (`uColor`) and bindings (`uMap`).
|
|
48
|
-
- The pure pieces (`math.ts`, `bvh.ts`, `order.ts`) have check rigs in
|
|
50
|
+
- The pure pieces (`math.ts`, `bvh.ts`, `order.ts`, `geometry.ts`) have check rigs in
|
|
49
51
|
`checks/`, run headless on flux from the repo root:
|
|
50
52
|
`bunx srt bundle -f --stdout packages/3d/checks/<name>-check.ts | target/release/flux - [seed]`.
|
|
51
|
-
They print PASS or FAIL lines and throw on failure
|
|
52
|
-
|
|
53
|
+
They print PASS or FAIL lines and throw on failure, which exits nonzero.
|
|
54
|
+
Extend the rig when you change the module.
|
|
53
55
|
|
|
54
56
|
## Components
|
|
55
57
|
|
|
@@ -58,6 +60,7 @@ blendMode and pointer events like any element. Design rationale:
|
|
|
58
60
|
| `Scene` | `width`, `height` (target pixels), `clearColor?`, `background?` (fragment GLSL), `label?`, `ref?(scene)`, `output?(texture)`, `events?` (mesh pointer events, default on) |
|
|
59
61
|
| `Group` | `position?`, `rotation?` (Euler radians, XYZ order), `quaternion?` (either, not both), `scale?` (number = uniform), `visible?`, pointer events (below), `ref?(node)` |
|
|
60
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 |
|
|
61
64
|
| `PerspectiveCamera` | `fov?` (vertical DEGREES, default 60), `near?`, `far?`, `position?`, `lookAt?`, `up?` |
|
|
62
65
|
|
|
63
66
|
Output composition: without `output`, `Scene` emits a minimal
|
|
@@ -161,6 +164,25 @@ hands the baker world-space vertices. `fill` indexes relative to
|
|
|
161
164
|
`first`. It trusts the buffer's layout (no tag to check); withColors is
|
|
162
165
|
the checked path.
|
|
163
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
|
+
|
|
164
186
|
Profile kit (2D outlines to solids, real texture UVs): a `Profile` is a
|
|
165
187
|
closed XY polygon, bare `[x, y]` points crease, `{ p, smooth }` points
|
|
166
188
|
share an averaged normal - `fillet(points, radius, segs?)` and
|
|
@@ -225,6 +247,34 @@ Materials:
|
|
|
225
247
|
pipeline with its own values. `dispose()` lives on the class alone.
|
|
226
248
|
`shaderMaterial(opts)` is exactly a class with one instance (its
|
|
227
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.
|
|
228
278
|
|
|
229
279
|
Background: `scene.setBackground(source | null)`, the `background` option
|
|
230
280
|
on createScene, and the reactive `Scene` prop. Fragment GLSL drawn as the
|
|
@@ -262,11 +312,26 @@ system.
|
|
|
262
312
|
"fix" the negated row of `perspective()` - both would mirror the winding
|
|
263
313
|
and show mesh interiors.
|
|
264
314
|
- `visible: false` keeps the entry, drawn with `instanceCount: 0` (a
|
|
265
|
-
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
|
|
266
318
|
written on unhide. A freshly attached entry starts off the same way and
|
|
267
319
|
sync() turns it on when it writes uModel - never add one live: it has no
|
|
268
320
|
world matrix yet, and drawn before the sync microtask it flashes at the
|
|
269
321
|
world origin for a frame.
|
|
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.
|
|
270
335
|
- Transparency is an EXPLICIT material flag, Three's rule: `unlit({ color:
|
|
271
336
|
[r, g, b, 0.5] })` still draws opaque; `unlit({ ..., transparent: true })`
|
|
272
337
|
(or `shaderMaterial({ transparent: true })`) builds the pipeline with
|
package/README.md
CHANGED
|
@@ -67,7 +67,11 @@ change - plus your own uniforms: scene-wide via `scene.setParams` (one write
|
|
|
67
67
|
however many meshes read it), or per mesh, declaratively via the `params`
|
|
68
68
|
prop on `<Mesh>` or imperatively via `setMeshParams`. `shaderMaterialClass`
|
|
69
69
|
compiles one program and hands out `instance()` materials that differ only
|
|
70
|
-
in params/textures
|
|
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
|
|
71
75
|
`@solidrt/3d/glsl` exports the lighting pieces (hemisphere, lambert,
|
|
72
76
|
blinn, fresnel, a standard vertex stage) to compose your own lit looks
|
|
73
77
|
from plain template literals.
|
|
@@ -77,7 +81,9 @@ GLSL as a first-class material), geometry generators (box, plane, circle,
|
|
|
77
81
|
ring, sphere, cylinder, cone, torus, torus knot), a profile kit for custom
|
|
78
82
|
solids (`extrude` with bevels, `lathe`, polyline `sweep`/`tube` with
|
|
79
83
|
mitred joints, flat `shape`, with `fillet`/`roundRect`/`triangulate`
|
|
80
|
-
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
|
|
81
87
|
(`withColors` adds an `aColor` vec4 - tint, baked AO, any four scalars -
|
|
82
88
|
to any geometry, for materials that read it), one perspective camera
|
|
83
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/components.tsx
CHANGED
|
@@ -11,10 +11,14 @@ 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,
|
|
20
24
|
setRenderOrder,
|
|
@@ -22,7 +26,7 @@ import {
|
|
|
22
26
|
setVisible,
|
|
23
27
|
} from "./scene.ts"
|
|
24
28
|
import type { ShaderParams } from "@solidrt/core/gpu"
|
|
25
|
-
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"
|
|
26
30
|
import type { Geometry } from "./geometry.ts"
|
|
27
31
|
import type { Material } from "./material.ts"
|
|
28
32
|
import type { Quat, Vec3 } from "./math.ts"
|
|
@@ -216,6 +220,74 @@ export let Mesh: VoidComponent<MeshProps> = props => {
|
|
|
216
220
|
return null
|
|
217
221
|
}
|
|
218
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
|
+
|
|
219
291
|
export type PerspectiveCameraProps = {
|
|
220
292
|
/** Vertical field of view in DEGREES (default 60). */
|
|
221
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
|
+
}
|
package/src/geometry.ts
CHANGED
|
@@ -12,16 +12,13 @@
|
|
|
12
12
|
// unused by the unlit materials so the layout is ready for lights without
|
|
13
13
|
// a geometry change (inactive attributes are skipped but keep the stride).
|
|
14
14
|
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
// owner-scoped auto-free would free a buffer other scenes still draw from.
|
|
19
|
-
// disposeGeometry frees them when an app is done with a geometry for good.
|
|
15
|
+
// Pure module by design - geometry is data, and every function here is
|
|
16
|
+
// array math (the check rig checks/geometry-check.ts runs it headless on
|
|
17
|
+
// flux). The GPU buffer step lives in geometry-gpu.ts.
|
|
20
18
|
|
|
21
|
-
import {
|
|
22
|
-
import
|
|
23
|
-
import {
|
|
24
|
-
import type { Vec2, Vec3, Vec4 } from "./math.ts"
|
|
19
|
+
import type { VertexAttribute } from "@solidrt/core/gpu"
|
|
20
|
+
import { add, compose, cross, mat4, normalize, normalMatrix, sub, updateRotation, updateScale } from "./math.ts"
|
|
21
|
+
import type { Quat, TransformUpdate, Vec2, Vec3, Vec4 } from "./math.ts"
|
|
25
22
|
|
|
26
23
|
export type VertexLayout = "standard" | "colored"
|
|
27
24
|
|
|
@@ -61,8 +58,6 @@ export type Geometry = {
|
|
|
61
58
|
layout?: VertexLayout
|
|
62
59
|
/** Debug name for the lazily-created GPU buffers. */
|
|
63
60
|
label?: string
|
|
64
|
-
_buffer?: BufferId
|
|
65
|
-
_index?: BufferId
|
|
66
61
|
_bounds?: Float32Array
|
|
67
62
|
}
|
|
68
63
|
|
|
@@ -94,42 +89,6 @@ export function geometryBounds(geometry: Geometry): Float32Array {
|
|
|
94
89
|
return bounds
|
|
95
90
|
}
|
|
96
91
|
|
|
97
|
-
/** The geometry's GPU buffers, created on first use and cached on it,
|
|
98
|
-
* plus the index format the draw entry must bind them with. */
|
|
99
|
-
export function geometryBuffers(geometry: Geometry): {
|
|
100
|
-
buffer: BufferId
|
|
101
|
-
index: BufferId
|
|
102
|
-
indexFormat: IndexFormat
|
|
103
|
-
} {
|
|
104
|
-
let buffer = geometry._buffer
|
|
105
|
-
let index = geometry._index
|
|
106
|
-
if (buffer === undefined || index === undefined) {
|
|
107
|
-
buffer = createBuffer(geometry.vertices, {
|
|
108
|
-
autoFree: false,
|
|
109
|
-
label: geometry.label ? geometry.label + "-verts" : undefined,
|
|
110
|
-
})
|
|
111
|
-
index = createBuffer(geometry.indices, {
|
|
112
|
-
autoFree: false,
|
|
113
|
-
label: geometry.label ? geometry.label + "-indices" : undefined,
|
|
114
|
-
})
|
|
115
|
-
geometry._buffer = buffer
|
|
116
|
-
geometry._index = index
|
|
117
|
-
}
|
|
118
|
-
return { buffer, index, indexFormat: geometry.indices instanceof Uint32Array ? "uint32" : "uint16" }
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Free the geometry's GPU buffers. Draw entries created from them hold
|
|
123
|
-
* their own reference, so destruction order is safe; the geometry can be
|
|
124
|
-
* used again afterwards (fresh buffers are created on next use).
|
|
125
|
-
*/
|
|
126
|
-
export function disposeGeometry(geometry: Geometry): void {
|
|
127
|
-
if (geometry._buffer !== undefined) destroyBuffer(geometry._buffer)
|
|
128
|
-
if (geometry._index !== undefined) destroyBuffer(geometry._index)
|
|
129
|
-
geometry._buffer = undefined
|
|
130
|
-
geometry._index = undefined
|
|
131
|
-
}
|
|
132
|
-
|
|
133
92
|
/** Per-vertex aColor values for withColors/fillColors: a flat 4-per-vertex
|
|
134
93
|
* array, or a callback deriving each vertex's vec4 from the vertex data. */
|
|
135
94
|
export type ColorFill = ArrayLike<number> | ((index: number, pos: Vec3, normal: Vec3, uv: Vec2) => Vec4)
|
|
@@ -210,6 +169,94 @@ export function fillColors(vertices: Float32Array, fill: ColorFill, first = 0, c
|
|
|
210
169
|
return vertices
|
|
211
170
|
}
|
|
212
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Bake a placement (the setTransform shape: Euler XYZ radians or a
|
|
174
|
+
* quaternion, not both; number = uniform scale; absent = identity) into a
|
|
175
|
+
* geometry: a new geometry (the source is
|
|
176
|
+
* untouched, its GPU buffers stay independent) whose positions are moved
|
|
177
|
+
* by the transform and whose normals follow through the inverse-transpose,
|
|
178
|
+
* renormalized - correct under non-uniform scale. UVs, colors, indices and
|
|
179
|
+
* layout copy through. This is Three's `geometry.applyMatrix4`, the first
|
|
180
|
+
* half of authoring a static scene as data: transform each part into place,
|
|
181
|
+
* mergeGeometries the parts, draw one mesh.
|
|
182
|
+
*/
|
|
183
|
+
export function transformGeometry(geometry: Geometry, transform: TransformUpdate, label?: string): Geometry {
|
|
184
|
+
let rot: Quat = [0, 0, 0, 1]
|
|
185
|
+
updateRotation(rot, transform, "transformGeometry")
|
|
186
|
+
let scl: Vec3 = [1, 1, 1]
|
|
187
|
+
if (transform.scale !== undefined) updateScale(scl, transform.scale)
|
|
188
|
+
let m = compose(mat4(), transform.position ?? [0, 0, 0], rot, scl)
|
|
189
|
+
let n = normalMatrix(mat4(), m)
|
|
190
|
+
let stride = geometry.layout === "colored" ? COLORED_FLOATS : FLOATS_PER_VERTEX
|
|
191
|
+
let src = geometry.vertices
|
|
192
|
+
if (src.length % stride !== 0) {
|
|
193
|
+
throw new Error("transformGeometry: vertex data is not a whole number of " + (geometry.layout ?? "standard") + "-layout vertices")
|
|
194
|
+
}
|
|
195
|
+
let out = new Float32Array(src)
|
|
196
|
+
for (let i = 0; i < out.length; i += stride) {
|
|
197
|
+
let x = src[i]!, y = src[i + 1]!, z = src[i + 2]!
|
|
198
|
+
out[i] = m[0] * x + m[4] * y + m[8] * z + m[12]
|
|
199
|
+
out[i + 1] = m[1] * x + m[5] * y + m[9] * z + m[13]
|
|
200
|
+
out[i + 2] = m[2] * x + m[6] * y + m[10] * z + m[14]
|
|
201
|
+
let nx = src[i + 3]!, ny = src[i + 4]!, nz = src[i + 5]!
|
|
202
|
+
let tx = n[0] * nx + n[4] * ny + n[8] * nz
|
|
203
|
+
let ty = n[1] * nx + n[5] * ny + n[9] * nz
|
|
204
|
+
let tz = n[2] * nx + n[6] * ny + n[10] * nz
|
|
205
|
+
let len = Math.hypot(tx, ty, tz) || 1
|
|
206
|
+
out[i + 3] = tx / len
|
|
207
|
+
out[i + 4] = ty / len
|
|
208
|
+
out[i + 5] = tz / len
|
|
209
|
+
}
|
|
210
|
+
return {
|
|
211
|
+
vertices: out,
|
|
212
|
+
indices: geometry.indices,
|
|
213
|
+
layout: geometry.layout,
|
|
214
|
+
label: label ?? (geometry.label ? geometry.label + "-transformed" : undefined),
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Concatenate geometries into one: vertices appended in order, indices
|
|
220
|
+
* offset to match, uint32 indices past 64k vertices. Every part must share
|
|
221
|
+
* one layout - a mixed list throws, because the strides differ and a merge
|
|
222
|
+
* that picked one would draw garbage, not a mesh missing a channel. The
|
|
223
|
+
* second half of authoring a static scene as data (Three's
|
|
224
|
+
* `BufferGeometryUtils.mergeGeometries`): the result is one draw entry and
|
|
225
|
+
* one uModel write however many parts went in, so only what actually moves
|
|
226
|
+
* keeps a node of its own.
|
|
227
|
+
*/
|
|
228
|
+
export function mergeGeometries(parts: Geometry[], label?: string): Geometry {
|
|
229
|
+
if (parts.length === 0) throw new Error("mergeGeometries: no parts")
|
|
230
|
+
let layout = parts[0]!.layout ?? "standard"
|
|
231
|
+
let stride = layout === "colored" ? COLORED_FLOATS : FLOATS_PER_VERTEX
|
|
232
|
+
let floats = 0
|
|
233
|
+
let indexCount = 0
|
|
234
|
+
for (let part of parts) {
|
|
235
|
+
if ((part.layout ?? "standard") !== layout) {
|
|
236
|
+
throw new Error("mergeGeometries: mixed layouts (" + layout + " and " + (part.layout ?? "standard") + ")")
|
|
237
|
+
}
|
|
238
|
+
if (part.vertices.length % stride !== 0) {
|
|
239
|
+
throw new Error("mergeGeometries: a part's vertex data is not a whole number of " + layout + "-layout vertices")
|
|
240
|
+
}
|
|
241
|
+
floats += part.vertices.length
|
|
242
|
+
indexCount += part.indices.length
|
|
243
|
+
}
|
|
244
|
+
let vertexCount = floats / stride
|
|
245
|
+
let vertices = new Float32Array(floats)
|
|
246
|
+
let indices = vertexCount > 65535 ? new Uint32Array(indexCount) : new Uint16Array(indexCount)
|
|
247
|
+
let vOffset = 0
|
|
248
|
+
let iOffset = 0
|
|
249
|
+
for (let part of parts) {
|
|
250
|
+
vertices.set(part.vertices, vOffset)
|
|
251
|
+
let base = vOffset / stride
|
|
252
|
+
let src = part.indices
|
|
253
|
+
for (let i = 0; i < src.length; i++) indices[iOffset + i] = src[i]! + base
|
|
254
|
+
vOffset += part.vertices.length
|
|
255
|
+
iOffset += src.length
|
|
256
|
+
}
|
|
257
|
+
return { vertices, indices, layout: parts[0]!.layout, label }
|
|
258
|
+
}
|
|
259
|
+
|
|
213
260
|
// Indices for a row-major (cellRows + 1) x (cellCols + 1) vertex grid: two
|
|
214
261
|
// CCW triangles per cell, split across the row0col0-row1col1 diagonal -
|
|
215
262
|
// the one quad pattern every grid generator here shares (rows run along
|
package/src/index.ts
CHANGED
|
@@ -5,18 +5,20 @@
|
|
|
5
5
|
// without Solid components) and the component face (Scene/Mesh/Group/
|
|
6
6
|
// PerspectiveCamera) on top. See AGENTS.md for the model and the traps.
|
|
7
7
|
|
|
8
|
-
export { add, createGroup, createMesh, createScene, getRotation, lookAt, remove, setGeometry, setMaterial, setMeshParams, setRenderOrder, setTransform, setVisible, worldPosition } from "./scene.ts"
|
|
9
|
-
export type { CameraUpdate, Hit, Mesh as MeshNode, Scene as SceneHandle, SceneHandlers, SceneNode, SceneOptions, ScenePointerEvent, TransformUpdate } from "./scene.ts"
|
|
10
|
-
export {
|
|
8
|
+
export { add, createGroup, createInstancedMesh, createMesh, createScene, disposeInstances, getRotation, lookAt, remove, setGeometry, setInstanceCount, setInstances, setMaterial, setMeshParams, setRenderOrder, setTransform, setVisible, worldPosition } from "./scene.ts"
|
|
9
|
+
export type { CameraUpdate, Hit, InstancedMesh as InstancedMeshNode, InstancedMeshOptions, Mesh as MeshNode, MeshInstances, Scene as SceneHandle, SceneHandlers, SceneNode, SceneOptions, ScenePointerEvent, TransformUpdate } from "./scene.ts"
|
|
10
|
+
export { disposeGeometry } from "./geometry-gpu.ts"
|
|
11
|
+
export { box, circle, cone, cylinder, fillColors, geometryBounds, mergeGeometries, plane, ring, sphere, torus, torusKnot, transformGeometry, withColors, FLOATS_PER_VERTEX, VERTEX_LAYOUTS } from "./geometry.ts"
|
|
11
12
|
export type { ColorFill, Geometry, VertexLayout } from "./geometry.ts"
|
|
13
|
+
export { rayBoxDistance } from "./bvh.ts"
|
|
12
14
|
export { fillet, roundRect, shape, triangulate } from "./profile.ts"
|
|
13
15
|
export type { Profile, ProfilePoint } from "./profile.ts"
|
|
14
16
|
export { extrude, lathe, pathFrames, sweep, tube } from "./sweep.ts"
|
|
15
17
|
export type { PathFrames, PathPoint, SweepPath } from "./sweep.ts"
|
|
16
18
|
export { shaderMaterial, shaderMaterialClass, unlit } from "./material.ts"
|
|
17
19
|
export type { Material, ShaderMaterialClass, ShaderMaterialClassOptions, ShaderMaterialInstanceOptions, ShaderMaterialOptions, UnlitOptions } from "./material.ts"
|
|
18
|
-
export { Group, Mesh, PerspectiveCamera, Scene, useScene } from "./components.tsx"
|
|
19
|
-
export type { MeshProps, PerspectiveCameraProps, PointerEventProps, SceneProps, TransformProps } from "./components.tsx"
|
|
20
|
+
export { Group, InstancedMesh, Mesh, PerspectiveCamera, Scene, useScene } from "./components.tsx"
|
|
21
|
+
export type { InstancedMeshProps, MeshProps, PerspectiveCameraProps, PointerEventProps, SceneProps, TransformProps } from "./components.tsx"
|
|
20
22
|
export { createOrbitCamera } from "./orbit.ts"
|
|
21
23
|
export type { OrbitCamera, OrbitCameraOptions, OrbitPose } from "./orbit.ts"
|
|
22
24
|
// math's lookAt (the camera view matrix) stays on the /math subpath: the
|
package/src/material.ts
CHANGED
|
@@ -35,6 +35,7 @@ import type {
|
|
|
35
35
|
ShaderStageId,
|
|
36
36
|
TextureId,
|
|
37
37
|
Topology,
|
|
38
|
+
VertexAttribute,
|
|
38
39
|
} from "@solidrt/core/gpu"
|
|
39
40
|
import { VERTEX_LAYOUTS } from "./geometry.ts"
|
|
40
41
|
import type { VertexLayout } from "./geometry.ts"
|
|
@@ -59,6 +60,11 @@ export type Material = {
|
|
|
59
60
|
* the scene draws this material's meshes after every opaque one, sorted
|
|
60
61
|
* back-to-front by mesh origin, and re-sorts them when the camera moves. */
|
|
61
62
|
transparent?: boolean
|
|
63
|
+
/** Per-instance attributes, when the material's pipeline declares them
|
|
64
|
+
* (shaderMaterialClass's `instanceAttributes`). Such a material draws
|
|
65
|
+
* instanced meshes only - createInstancedMesh supplies the record buffer,
|
|
66
|
+
* and createMesh meshes are rejected at add(). */
|
|
67
|
+
instanceAttributes?: VertexAttribute[]
|
|
62
68
|
/** Present on materials that own their pipeline (shaderMaterial). */
|
|
63
69
|
dispose?(): void
|
|
64
70
|
}
|
|
@@ -226,6 +232,17 @@ export type ShaderMaterialClassOptions = {
|
|
|
226
232
|
*/
|
|
227
233
|
vertex: string
|
|
228
234
|
fragment: string
|
|
235
|
+
/**
|
|
236
|
+
* Per-instance attributes: the vertex stage reads these as `in` variables
|
|
237
|
+
* beside the layout's own, and each drawn instance gets one record from
|
|
238
|
+
* the mesh's instance buffer (interleaved floats in this order). A class
|
|
239
|
+
* with instance attributes makes INSTANCED materials: attach their meshes
|
|
240
|
+
* with createInstancedMesh, which carries the records - a createMesh mesh
|
|
241
|
+
* is rejected at add(). A per-instance transform is data, not a matrix:
|
|
242
|
+
* a position/yaw/scale record beats four vec4 columns for most fleets,
|
|
243
|
+
* and the composed uModel still places the whole population.
|
|
244
|
+
*/
|
|
245
|
+
instanceAttributes?: VertexAttribute[]
|
|
229
246
|
/** Blend over what is behind, with the scene sorting this material's
|
|
230
247
|
* meshes back-to-front after the opaque ones (see Material.transparent).
|
|
231
248
|
* Sets the pipeline defaults blend "alpha" and depthWrite false; the
|
|
@@ -298,6 +315,9 @@ export function shaderMaterialClass(opts: ShaderMaterialClassOptions): ShaderMat
|
|
|
298
315
|
let normalMatrix = /\buNormal\b/.test(opts.vertex) || /\buNormal\b/.test(opts.fragment)
|
|
299
316
|
let transparent = opts.transparent ?? (opts.blend !== undefined && opts.blend !== "none")
|
|
300
317
|
let depth = opts.depth ?? true
|
|
318
|
+
// An empty list declares nothing - same as absent (the engine requires an
|
|
319
|
+
// instance buffer exactly when attributes are declared).
|
|
320
|
+
let instanceAttributes = opts.instanceAttributes?.length ? opts.instanceAttributes.map(a => ({ ...a })) : undefined
|
|
301
321
|
let pipelineFor = (): RenderPipelineId => {
|
|
302
322
|
if (pipeline === undefined) {
|
|
303
323
|
let vs = compileShader("vertex", opts.vertex, { header: needsHeader(opts.vertex) })
|
|
@@ -307,6 +327,7 @@ export function shaderMaterialClass(opts: ShaderMaterialClassOptions): ShaderMat
|
|
|
307
327
|
destroyShader(fs)
|
|
308
328
|
pipeline = createRenderPipeline(program, {
|
|
309
329
|
attributes: VERTEX_LAYOUTS[layout],
|
|
330
|
+
instanceAttributes,
|
|
310
331
|
depth,
|
|
311
332
|
// depthWrite needs a depth buffer, so the transparent default
|
|
312
333
|
// only applies when there is one.
|
|
@@ -321,7 +342,7 @@ export function shaderMaterialClass(opts: ShaderMaterialClassOptions): ShaderMat
|
|
|
321
342
|
}
|
|
322
343
|
return {
|
|
323
344
|
instance(inst = {}) {
|
|
324
|
-
return { normalMatrix, layout, transparent, pipeline: pipelineFor, params: inst.params ?? {}, textures: inst.textures }
|
|
345
|
+
return { normalMatrix, layout, transparent, instanceAttributes, pipeline: pipelineFor, params: inst.params ?? {}, textures: inst.textures }
|
|
325
346
|
},
|
|
326
347
|
dispose() {
|
|
327
348
|
if (pipeline !== undefined) {
|
package/src/math.ts
CHANGED
|
@@ -213,6 +213,51 @@ export function quatNormalize(out: Quat, q: Quat): Quat {
|
|
|
213
213
|
return out
|
|
214
214
|
}
|
|
215
215
|
|
|
216
|
+
/**
|
|
217
|
+
* A transform update - the shape setTransform writes and transformGeometry
|
|
218
|
+
* bakes. Absent keys mean "keep" (nodes) or identity (geometry).
|
|
219
|
+
*/
|
|
220
|
+
export type TransformUpdate = {
|
|
221
|
+
position?: Vec3
|
|
222
|
+
/** Euler radians in XYZ order (x first), Three's `Euler` default -
|
|
223
|
+
* converted to a quaternion on use. */
|
|
224
|
+
rotation?: Vec3
|
|
225
|
+
/** The rotation itself. Normalized on use, so a hand-built or drifted
|
|
226
|
+
* quaternion cannot silently scale the geometry. Passing this together
|
|
227
|
+
* with `rotation` is an error, not a precedence question. */
|
|
228
|
+
quaternion?: Quat
|
|
229
|
+
/** A number is uniform scale. */
|
|
230
|
+
scale?: Vec3 | number
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Resolve an update's rotation into `out`: euler converted, quaternion
|
|
235
|
+
* normalized. Returns false (out untouched) when the update carries
|
|
236
|
+
* neither; throws when it carries both. `caller` names the verb in the
|
|
237
|
+
* error.
|
|
238
|
+
*/
|
|
239
|
+
export function updateRotation(out: Quat, update: TransformUpdate, caller: string): boolean {
|
|
240
|
+
let r = update.rotation
|
|
241
|
+
let q = update.quaternion
|
|
242
|
+
if (r !== undefined && q !== undefined) {
|
|
243
|
+
throw new Error("Pass rotation or quaternion to " + caller + ", not both")
|
|
244
|
+
}
|
|
245
|
+
if (r !== undefined) quatFromEuler(out, r)
|
|
246
|
+
else if (q !== undefined) quatNormalize(out, q)
|
|
247
|
+
else return false
|
|
248
|
+
return true
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Expand an update's scale (number = uniform) into `out`. */
|
|
252
|
+
export function updateScale(out: Vec3, scale: Vec3 | number): Vec3 {
|
|
253
|
+
if (typeof scale === "number") {
|
|
254
|
+
out[0] = scale; out[1] = scale; out[2] = scale
|
|
255
|
+
} else {
|
|
256
|
+
out[0] = scale[0]; out[1] = scale[1]; out[2] = scale[2]
|
|
257
|
+
}
|
|
258
|
+
return out
|
|
259
|
+
}
|
|
260
|
+
|
|
216
261
|
/**
|
|
217
262
|
* Euler radians to a quaternion, in XYZ order: x applied first, then y,
|
|
218
263
|
* then z (R = Rx * Ry * Rz on column vectors), Three's `Euler` default - a
|
package/src/scene.ts
CHANGED
|
@@ -19,15 +19,17 @@
|
|
|
19
19
|
// each write lands here, the microtask syncs the affected uModels, and the
|
|
20
20
|
// flush renders once that frame.
|
|
21
21
|
|
|
22
|
-
import { addDraw, createDrawTarget, destroyProgram, destroyRenderPipeline, destroyTexture, removeDraw, setDrawOrder, setDrawParams, setDrawRange, setTargetParams, setTargetSize } from "@solidrt/core/gpu"
|
|
23
|
-
import type { DrawId, FilterMode, ProgramId, RenderPipelineId, ShaderParams, TextureId, WrapMode } from "@solidrt/core/gpu"
|
|
22
|
+
import { addDraw, createBuffer, createDrawTarget, destroyBuffer, destroyProgram, destroyRenderPipeline, destroyTexture, removeDraw, setDrawOrder, setDrawParams, setDrawRange, setTargetParams, setTargetSize, writeBuffer } from "@solidrt/core/gpu"
|
|
23
|
+
import type { BufferId, DrawId, FilterMode, ProgramId, RenderPipelineId, ShaderParams, TextureId, VertexAttribute, WrapMode } from "@solidrt/core/gpu"
|
|
24
24
|
import { getOwner, onCleanup } from "@solidrt/core"
|
|
25
25
|
import type { PointerEvent as ElementPointerEvent } from "@solidrt/core"
|
|
26
26
|
// The scene's lookAt() aims a node; math's builds a camera's view matrix -
|
|
27
27
|
// the same pairing (and the same name) as Three's Object3D/Matrix4.
|
|
28
|
-
import { compose, copy, eulerFromQuat, identity, invertAffine, lookAt as lookAtMatrix, mat4, multiply, normalMatrix, perspective, quat,
|
|
29
|
-
import type { Mat4, Quat, Vec3, Vec4 } from "./math.ts"
|
|
30
|
-
import { geometryBounds
|
|
28
|
+
import { compose, copy, eulerFromQuat, identity, invertAffine, lookAt as lookAtMatrix, mat4, multiply, normalMatrix, perspective, quat, quatFromFrame, transformPoint, transformVector, updateRotation, updateScale } from "./math.ts"
|
|
29
|
+
import type { Mat4, Quat, TransformUpdate, Vec3, Vec4 } from "./math.ts"
|
|
30
|
+
import { geometryBounds } from "./geometry.ts"
|
|
31
|
+
import { acquireGeometryBuffers, releaseGeometryBuffers } from "./geometry-gpu.ts"
|
|
32
|
+
import type { GeometryBuffers } from "./geometry-gpu.ts"
|
|
31
33
|
import type { Geometry } from "./geometry.ts"
|
|
32
34
|
import { backgroundPipeline } from "./material.ts"
|
|
33
35
|
import { orderEntries } from "./order.ts"
|
|
@@ -57,6 +59,7 @@ let pickDir: Vec3 = [0, 0, 0]
|
|
|
57
59
|
// setTransform's rotation compare happens AFTER conversion, so an euler and
|
|
58
60
|
// the quaternion it produces are the same write. Nothing outlives the call.
|
|
59
61
|
let rotScratch = quat()
|
|
62
|
+
let scaleScratch: Vec3 = [1, 1, 1]
|
|
60
63
|
|
|
61
64
|
// The scene half a node needs to reach: attach/detach entries and schedule
|
|
62
65
|
// a sync. Kept separate from the public Scene type so internals stay off
|
|
@@ -68,6 +71,7 @@ type SceneHooks = {
|
|
|
68
71
|
_attach(mesh: Mesh): void
|
|
69
72
|
_detach(mesh: Mesh): void
|
|
70
73
|
_setParams(mesh: Mesh, params: ShaderParams): void
|
|
74
|
+
_setCount(mesh: Mesh): void
|
|
71
75
|
_reorder(): void
|
|
72
76
|
}
|
|
73
77
|
|
|
@@ -109,6 +113,10 @@ export type Mesh = SceneNode & {
|
|
|
109
113
|
* transparent group always follows the opaque one. Set with setRenderOrder. */
|
|
110
114
|
renderOrder: number
|
|
111
115
|
_entry: DrawId | null
|
|
116
|
+
/** The geometry-buffer reference the entry was built from, acquired at
|
|
117
|
+
* attach and what _detach releases - like _transparent, a snapshot,
|
|
118
|
+
* because setGeometry swaps mesh.geometry before the rebuild. */
|
|
119
|
+
_buffers: GeometryBuffers | null
|
|
112
120
|
/** material.transparent as of the last attach - the entry's actual
|
|
113
121
|
* pipeline state, and what _detach counts against (setMaterial swaps
|
|
114
122
|
* mesh.material before the rebuild). */
|
|
@@ -120,8 +128,35 @@ export type Mesh = SceneNode & {
|
|
|
120
128
|
_fresh: boolean
|
|
121
129
|
_params: ShaderParams | null
|
|
122
130
|
_pickLeaf: number | null
|
|
131
|
+
/** Instance state when the mesh was made by createInstancedMesh; null on
|
|
132
|
+
* an ordinary mesh. */
|
|
133
|
+
_instances: MeshInstances | null
|
|
123
134
|
}
|
|
124
135
|
|
|
136
|
+
/** The per-mesh half of instancing: the record buffer and its bookkeeping.
|
|
137
|
+
* Read the public fields freely; write through setInstances /
|
|
138
|
+
* setInstanceCount so the draw range follows. */
|
|
139
|
+
export type MeshInstances = {
|
|
140
|
+
/** The GPU record buffer, owned by the mesh (disposeInstances frees it). */
|
|
141
|
+
buffer: BufferId
|
|
142
|
+
/** Floats per record - the material's instanceAttributes summed. */
|
|
143
|
+
stride: number
|
|
144
|
+
/** Records the buffer has room for; fixed at creation, like every GPU
|
|
145
|
+
* buffer's byte size. */
|
|
146
|
+
capacity: number
|
|
147
|
+
/** Records currently drawn (the entry's instanceCount while visible). */
|
|
148
|
+
count: number
|
|
149
|
+
/** Explicit LOCAL bounds covering the whole population ([minX, minY,
|
|
150
|
+
* minZ, maxX, maxY, maxZ]), or null: the mesh then has no picking leaf -
|
|
151
|
+
* records are opaque data, so the library cannot derive where the
|
|
152
|
+
* instances are. */
|
|
153
|
+
bounds: Float32Array | null
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** A mesh from createInstancedMesh: an ordinary Mesh whose entry draws
|
|
157
|
+
* `instances.count` copies of the geometry, one record each. */
|
|
158
|
+
export type InstancedMesh = Mesh & { _instances: MeshInstances }
|
|
159
|
+
|
|
125
160
|
/** One picking intersection: the mesh, the camera-ray distance in world
|
|
126
161
|
* units, and the world-space point - Three's intersect result minus the
|
|
127
162
|
* triangle fields (`face`, `uv`), which cannot exist at the volume tier. */
|
|
@@ -276,9 +311,10 @@ export type Scene = {
|
|
|
276
311
|
* layout just works: `scene.handlersFor(() => ({ width: w(), height:
|
|
277
312
|
* h() }))`. */
|
|
278
313
|
handlersFor(layout: () => { width: number; height: number }): SceneHandlers
|
|
279
|
-
/** Destroy the target (entries die with it). Idempotent.
|
|
280
|
-
*
|
|
281
|
-
*
|
|
314
|
+
/** Destroy the target (entries die with it). Idempotent. Material
|
|
315
|
+
* pipelines are shared and survive (app-lifetime, see material.ts);
|
|
316
|
+
* geometry buffers are reference-counted and freed with their last
|
|
317
|
+
* entry (see geometry-gpu.ts). */
|
|
282
318
|
dispose(): void
|
|
283
319
|
}
|
|
284
320
|
|
|
@@ -308,15 +344,147 @@ export function createMesh(geometry: Geometry, material: Material): Mesh {
|
|
|
308
344
|
mesh.material = material
|
|
309
345
|
mesh.renderOrder = 0
|
|
310
346
|
mesh._entry = null
|
|
347
|
+
mesh._buffers = null
|
|
311
348
|
mesh._transparent = false
|
|
312
349
|
mesh._center = [0, 0, 0]
|
|
313
350
|
mesh._hidden = false
|
|
314
351
|
mesh._fresh = false
|
|
315
352
|
mesh._params = null
|
|
316
353
|
mesh._pickLeaf = null
|
|
354
|
+
mesh._instances = null
|
|
317
355
|
return mesh
|
|
318
356
|
}
|
|
319
357
|
|
|
358
|
+
/** The local box picking and sorting work from: explicit instance bounds
|
|
359
|
+
* when the mesh is instanced (null without them - no leaf, no hits), the
|
|
360
|
+
* geometry's own bounds otherwise. */
|
|
361
|
+
function localBounds(mesh: Mesh): Float32Array | null {
|
|
362
|
+
return mesh._instances !== null ? mesh._instances.bounds : geometryBounds(mesh.geometry)
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const ATTRIBUTE_FLOATS: Record<VertexAttribute["format"], number> = { f32: 1, vec2: 2, vec3: 3, vec4: 4 }
|
|
366
|
+
|
|
367
|
+
function instanceStride(attributes: VertexAttribute[]): number {
|
|
368
|
+
let stride = 0
|
|
369
|
+
for (let a of attributes) stride += ATTRIBUTE_FLOATS[a.format]
|
|
370
|
+
return stride
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export type InstancedMeshOptions = {
|
|
374
|
+
/** LOCAL bounds covering every instance the records place ([minX, minY,
|
|
375
|
+
* minZ, maxX, maxY, maxZ] - geometryBounds' shape), copied in. Records
|
|
376
|
+
* are opaque data, so only the app knows where its instances are: with
|
|
377
|
+
* bounds the mesh picks and transparent-sorts like any other
|
|
378
|
+
* (conservatively - one box around the whole population); without, it
|
|
379
|
+
* has no picking leaf and pointer events never target it. */
|
|
380
|
+
bounds?: ArrayLike<number>
|
|
381
|
+
/** Debug label for the record buffer. */
|
|
382
|
+
label?: string
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* A mesh drawing `geometry` once per record of `records`: one draw entry,
|
|
387
|
+
* one uModel write, N instances - the shape for forests, particles, and
|
|
388
|
+
* every fleet whose per-copy data is a few floats rather than a merged
|
|
389
|
+
* vertex buffer. The material must declare `instanceAttributes`
|
|
390
|
+
* (shaderMaterialClass); its vertex stage reads each record through those
|
|
391
|
+
* `in` variables. `records` is the interleaved attribute data (stride =
|
|
392
|
+
* the attributes' floats summed) and is uploaded here - the buffer's
|
|
393
|
+
* capacity is fixed at creation, like any GPU buffer. `count` limits how
|
|
394
|
+
* many records draw (default all); grow it later only up to capacity.
|
|
395
|
+
*
|
|
396
|
+
* The result is an ordinary Mesh: add/remove, setTransform (uModel places
|
|
397
|
+
* the whole population), setVisible (hiding zeroes the drawn count,
|
|
398
|
+
* unhiding restores it), setMeshParams and renderOrder all apply. Update
|
|
399
|
+
* records with setInstances, the drawn count with setInstanceCount, and
|
|
400
|
+
* free the record buffer with disposeInstances when done for good.
|
|
401
|
+
*/
|
|
402
|
+
export function createInstancedMesh(
|
|
403
|
+
geometry: Geometry,
|
|
404
|
+
material: Material,
|
|
405
|
+
records: Float32Array,
|
|
406
|
+
count?: number,
|
|
407
|
+
opts?: InstancedMeshOptions,
|
|
408
|
+
): InstancedMesh {
|
|
409
|
+
let attributes = material.instanceAttributes
|
|
410
|
+
if (attributes === undefined) {
|
|
411
|
+
throw new Error(
|
|
412
|
+
"createInstancedMesh: the material declares no instanceAttributes - build it with shaderMaterialClass({ instanceAttributes: [...] })",
|
|
413
|
+
)
|
|
414
|
+
}
|
|
415
|
+
let stride = instanceStride(attributes)
|
|
416
|
+
if (records.length % stride !== 0) {
|
|
417
|
+
throw new Error(
|
|
418
|
+
"createInstancedMesh: " + records.length + " floats is not a whole number of " + stride + "-float records",
|
|
419
|
+
)
|
|
420
|
+
}
|
|
421
|
+
let bounds: Float32Array | null = null
|
|
422
|
+
if (opts?.bounds !== undefined) {
|
|
423
|
+
if (opts.bounds.length !== 6) {
|
|
424
|
+
throw new Error("createInstancedMesh: bounds must be [minX, minY, minZ, maxX, maxY, maxZ]")
|
|
425
|
+
}
|
|
426
|
+
bounds = new Float32Array(6)
|
|
427
|
+
for (let i = 0; i < 6; i++) bounds[i] = opts.bounds[i]!
|
|
428
|
+
}
|
|
429
|
+
let capacity = records.length / stride
|
|
430
|
+
let mesh = createMesh(geometry, material) as InstancedMesh
|
|
431
|
+
mesh._instances = {
|
|
432
|
+
buffer: createBuffer(records, { autoFree: false, label: opts?.label }),
|
|
433
|
+
stride,
|
|
434
|
+
capacity,
|
|
435
|
+
count: Math.max(0, Math.min(Math.floor(count ?? capacity), capacity)),
|
|
436
|
+
bounds,
|
|
437
|
+
}
|
|
438
|
+
return mesh
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Overwrite an instanced mesh's records from the start of its buffer and
|
|
443
|
+
* (by default) draw exactly the records written - pass `count` to draw
|
|
444
|
+
* fewer, or to keep more previously written ones alive past a partial
|
|
445
|
+
* rewrite. The buffer's capacity is fixed at creation; more records than
|
|
446
|
+
* capacity throw (make a new mesh for a bigger population). Frame-rate-safe
|
|
447
|
+
* like setMeshParams.
|
|
448
|
+
*/
|
|
449
|
+
export function setInstances(mesh: InstancedMesh, records: Float32Array, count?: number): void {
|
|
450
|
+
let inst = mesh._instances
|
|
451
|
+
if (records.length % inst.stride !== 0) {
|
|
452
|
+
throw new Error("setInstances: " + records.length + " floats is not a whole number of " + inst.stride + "-float records")
|
|
453
|
+
}
|
|
454
|
+
let written = records.length / inst.stride
|
|
455
|
+
if (written > inst.capacity) {
|
|
456
|
+
throw new Error(
|
|
457
|
+
"setInstances: " + written + " records exceed the buffer's capacity of " + inst.capacity + " (fixed at creation)",
|
|
458
|
+
)
|
|
459
|
+
}
|
|
460
|
+
writeBuffer(inst.buffer, records)
|
|
461
|
+
setInstanceCount(mesh, count ?? written)
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** Set how many records draw (clamped to [0, capacity]). The visibility
|
|
465
|
+
* switch composes: a hidden mesh stores the count and draws it on unhide. */
|
|
466
|
+
export function setInstanceCount(mesh: InstancedMesh, count: number): void {
|
|
467
|
+
let inst = mesh._instances
|
|
468
|
+
let n = Math.max(0, Math.min(Math.floor(count), inst.capacity))
|
|
469
|
+
if (n === inst.count) return
|
|
470
|
+
inst.count = n
|
|
471
|
+
mesh._scene?._setCount(mesh)
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Detach the mesh (if attached) and free its record buffer. The buffer is
|
|
476
|
+
* mesh-owned with no reference count (unlike geometry buffers it is never
|
|
477
|
+
* shared), so this is the one explicit free; the mesh cannot be re-added
|
|
478
|
+
* afterwards.
|
|
479
|
+
*/
|
|
480
|
+
export function disposeInstances(mesh: InstancedMesh): void {
|
|
481
|
+
let inst: MeshInstances | null = mesh._instances
|
|
482
|
+
if (inst === null) return
|
|
483
|
+
if (mesh._scene) remove(mesh)
|
|
484
|
+
destroyBuffer(inst.buffer)
|
|
485
|
+
;(mesh as Mesh)._instances = null
|
|
486
|
+
}
|
|
487
|
+
|
|
320
488
|
/** Attach `child` under `parent` (re-parenting detaches it first). */
|
|
321
489
|
export function add(parent: SceneNode, child: SceneNode): void {
|
|
322
490
|
if (child.parent !== null) remove(child)
|
|
@@ -352,18 +520,7 @@ function leaveScene(node: SceneNode): void {
|
|
|
352
520
|
for (let c of node.children) leaveScene(c)
|
|
353
521
|
}
|
|
354
522
|
|
|
355
|
-
export type TransformUpdate
|
|
356
|
-
position?: Vec3
|
|
357
|
-
/** Euler radians in XYZ order (x first), Three's `Euler` default -
|
|
358
|
-
* converted to the node's quaternion on write. */
|
|
359
|
-
rotation?: Vec3
|
|
360
|
-
/** The rotation itself. Normalized on write, so a hand-built or
|
|
361
|
-
* drifted quaternion cannot silently scale the geometry. Passing this
|
|
362
|
-
* together with `rotation` is an error, not a precedence question. */
|
|
363
|
-
quaternion?: Quat
|
|
364
|
-
/** A number is uniform scale. */
|
|
365
|
-
scale?: Vec3 | number
|
|
366
|
-
}
|
|
523
|
+
export type { TransformUpdate } from "./math.ts"
|
|
367
524
|
|
|
368
525
|
/**
|
|
369
526
|
* The one write path for node transforms (so the scene knows to sync).
|
|
@@ -377,11 +534,6 @@ export type TransformUpdate = {
|
|
|
377
534
|
* equal to the node's current quaternion is also a no-op.
|
|
378
535
|
*/
|
|
379
536
|
export function setTransform(node: SceneNode, update: TransformUpdate): void {
|
|
380
|
-
let r = update.rotation
|
|
381
|
-
let q = update.quaternion
|
|
382
|
-
if (r !== undefined && q !== undefined) {
|
|
383
|
-
throw new Error("Pass rotation or quaternion to setTransform, not both")
|
|
384
|
-
}
|
|
385
537
|
// A no-op write costs nothing: driving every node from onFrame is the
|
|
386
538
|
// intended shape, and most nodes did not move. Exact compares, like
|
|
387
539
|
// setVisible - a value that survives a float round trip unchanged is the
|
|
@@ -394,9 +546,7 @@ export function setTransform(node: SceneNode, update: TransformUpdate): void {
|
|
|
394
546
|
node.position[2] = p[2]
|
|
395
547
|
changed = true
|
|
396
548
|
}
|
|
397
|
-
if (
|
|
398
|
-
else if (q !== undefined) quatNormalize(rotScratch, q)
|
|
399
|
-
if (r !== undefined || q !== undefined) {
|
|
549
|
+
if (updateRotation(rotScratch, update, "setTransform")) {
|
|
400
550
|
let n = node.quaternion
|
|
401
551
|
if (rotScratch[0] !== n[0] || rotScratch[1] !== n[1] || rotScratch[2] !== n[2] || rotScratch[3] !== n[3]) {
|
|
402
552
|
n[0] = rotScratch[0]
|
|
@@ -406,15 +556,12 @@ export function setTransform(node: SceneNode, update: TransformUpdate): void {
|
|
|
406
556
|
changed = true
|
|
407
557
|
}
|
|
408
558
|
}
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
node.scale[0] = sx
|
|
416
|
-
node.scale[1] = sy
|
|
417
|
-
node.scale[2] = sz
|
|
559
|
+
if (update.scale !== undefined) {
|
|
560
|
+
updateScale(scaleScratch, update.scale)
|
|
561
|
+
if (scaleScratch[0] !== node.scale[0] || scaleScratch[1] !== node.scale[1] || scaleScratch[2] !== node.scale[2]) {
|
|
562
|
+
node.scale[0] = scaleScratch[0]
|
|
563
|
+
node.scale[1] = scaleScratch[1]
|
|
564
|
+
node.scale[2] = scaleScratch[2]
|
|
418
565
|
changed = true
|
|
419
566
|
}
|
|
420
567
|
}
|
|
@@ -628,8 +775,17 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
|
|
|
628
775
|
// the local box's center/extents carried through the absolute matrix (the
|
|
629
776
|
// standard tight-AABB-of-a-transformed-AABB construction).
|
|
630
777
|
let updateLeaf = (mesh: Mesh): void => {
|
|
631
|
-
let b =
|
|
778
|
+
let b = localBounds(mesh)
|
|
632
779
|
let m = mesh._world
|
|
780
|
+
if (b === null) {
|
|
781
|
+
// An instanced mesh without explicit bounds: records are opaque, so
|
|
782
|
+
// there is nothing to build a leaf from - the mesh never picks. Keep
|
|
783
|
+
// the transparent sort key at the node's own world position.
|
|
784
|
+
mesh._center[0] = m[12]
|
|
785
|
+
mesh._center[1] = m[13]
|
|
786
|
+
mesh._center[2] = m[14]
|
|
787
|
+
return
|
|
788
|
+
}
|
|
633
789
|
let cx = (b[0]! + b[3]!) / 2
|
|
634
790
|
let cy = (b[1]! + b[4]!) / 2
|
|
635
791
|
let cz = (b[2]! + b[5]!) / 2
|
|
@@ -712,8 +868,9 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
|
|
|
712
868
|
let mesh = node as Mesh
|
|
713
869
|
if (mesh._entry !== null) {
|
|
714
870
|
if (mesh._hidden === shown) {
|
|
715
|
-
// Mismatch: flip the entry's cheap off switch.
|
|
716
|
-
|
|
871
|
+
// Mismatch: flip the entry's cheap off switch. An instanced
|
|
872
|
+
// mesh's "on" is its own record count, not 1.
|
|
873
|
+
setDrawRange(texture, mesh._entry, { instanceCount: shown ? (mesh._instances !== null ? mesh._instances.count : 1) : 0 })
|
|
717
874
|
mesh._hidden = !shown
|
|
718
875
|
if (shown) mesh._fresh = true
|
|
719
876
|
}
|
|
@@ -763,7 +920,30 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
|
|
|
763
920
|
"' - a material reading aColor needs withColors() geometry, and colored geometry needs such a material",
|
|
764
921
|
)
|
|
765
922
|
}
|
|
766
|
-
|
|
923
|
+
// Instancing pairs the same way layout does: the pipeline's instance
|
|
924
|
+
// attributes describe the mesh's record buffer, so one without the
|
|
925
|
+
// other (or a record stride from a different attribute list) would
|
|
926
|
+
// bind garbage - errors here, at add().
|
|
927
|
+
let inst = mesh._instances
|
|
928
|
+
let instAttrs = mesh.material.instanceAttributes
|
|
929
|
+
if (instAttrs !== undefined && inst === null) {
|
|
930
|
+
throw new Error(
|
|
931
|
+
"Material declares instanceAttributes - create its meshes with createInstancedMesh (records included), not createMesh",
|
|
932
|
+
)
|
|
933
|
+
}
|
|
934
|
+
if (inst !== null) {
|
|
935
|
+
if (instAttrs === undefined) {
|
|
936
|
+
throw new Error("Instanced mesh with a non-instanced material - the material must declare instanceAttributes")
|
|
937
|
+
}
|
|
938
|
+
let stride = instanceStride(instAttrs)
|
|
939
|
+
if (stride !== inst.stride) {
|
|
940
|
+
throw new Error(
|
|
941
|
+
"Instanced mesh records are " + inst.stride + " floats but the material's instanceAttributes take " + stride,
|
|
942
|
+
)
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
let bufs = acquireGeometryBuffers(mesh.geometry)
|
|
946
|
+
mesh._buffers = bufs
|
|
767
947
|
// The uNormal seed keys off the material flag because entry params
|
|
768
948
|
// validate strictly - and a material declaring uNormal without using
|
|
769
949
|
// it therefore throws right here, at add().
|
|
@@ -780,6 +960,7 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
|
|
|
780
960
|
indexBuffer: bufs.index,
|
|
781
961
|
indexFormat: bufs.indexFormat,
|
|
782
962
|
textures: mesh.material.textures,
|
|
963
|
+
instanceBuffer: inst !== null ? inst.buffer : undefined,
|
|
783
964
|
instanceCount: 0,
|
|
784
965
|
})
|
|
785
966
|
meshes.push(mesh)
|
|
@@ -793,6 +974,8 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
|
|
|
793
974
|
_detach(mesh) {
|
|
794
975
|
if (mesh._entry !== null) {
|
|
795
976
|
if (!disposed) removeDraw(texture, mesh._entry)
|
|
977
|
+
if (mesh._buffers !== null) releaseGeometryBuffers(mesh._buffers)
|
|
978
|
+
mesh._buffers = null
|
|
796
979
|
let i = meshes.indexOf(mesh)
|
|
797
980
|
if (i >= 0) meshes.splice(i, 1)
|
|
798
981
|
if (mesh._transparent) transparentCount--
|
|
@@ -809,6 +992,12 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
|
|
|
809
992
|
_setParams(mesh, params) {
|
|
810
993
|
if (mesh._entry !== null && !disposed) setDrawParams(texture, mesh._entry, params)
|
|
811
994
|
},
|
|
995
|
+
_setCount(mesh) {
|
|
996
|
+
// A hidden entry stays at 0; the unhide write restores the count.
|
|
997
|
+
if (mesh._entry !== null && !mesh._hidden && !disposed && mesh._instances !== null) {
|
|
998
|
+
setDrawRange(texture, mesh._entry, { instanceCount: mesh._instances.count })
|
|
999
|
+
}
|
|
1000
|
+
},
|
|
812
1001
|
_reorder() {
|
|
813
1002
|
orderDirty = true
|
|
814
1003
|
this._schedule()
|
|
@@ -1035,7 +1224,10 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
|
|
|
1035
1224
|
pickDir[1] = dy
|
|
1036
1225
|
pickDir[2] = dz
|
|
1037
1226
|
transformVector(pickDir, pickInv, pickDir)
|
|
1038
|
-
|
|
1227
|
+
// A boundless instanced mesh has no leaf, so the BVH never visits
|
|
1228
|
+
// it; this read is the bounds the leaf was built from.
|
|
1229
|
+
let b = localBounds(mesh)
|
|
1230
|
+
if (b === null) return
|
|
1039
1231
|
let t = rayBoxDistance(
|
|
1040
1232
|
pickOrigin[0], pickOrigin[1], pickOrigin[2],
|
|
1041
1233
|
pickDir[0], pickDir[1], pickDir[2],
|
|
@@ -1053,6 +1245,10 @@ export function createScene(width: number, height: number, opts?: SceneOptions):
|
|
|
1053
1245
|
dispose() {
|
|
1054
1246
|
if (disposed) return
|
|
1055
1247
|
disposed = true
|
|
1248
|
+
// Full mesh-side teardown, not just the target: _detach drops each
|
|
1249
|
+
// entry's geometry-buffer reference and pick leaf and clears _entry,
|
|
1250
|
+
// so a disposed scene leaves no mesh bookkeeping behind.
|
|
1251
|
+
for (let mesh of meshes.slice()) hooks._detach(mesh)
|
|
1056
1252
|
destroyTexture(texture)
|
|
1057
1253
|
if (background !== null) {
|
|
1058
1254
|
// The entry died with the target; the pipeline and program are the
|