@solidrt/3d 0.0.47 → 0.0.49
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 +178 -14
- package/README.md +32 -6
- package/examples/README.md +16 -0
- package/examples/aim.tsx +119 -0
- package/examples/pick.tsx +98 -0
- package/examples/scene-background.tsx +43 -0
- package/examples/sweep-paths.tsx +79 -0
- package/package.json +3 -3
- package/src/bvh.ts +258 -0
- package/src/components.tsx +81 -10
- package/src/geometry.ts +35 -0
- package/src/index.ts +10 -6
- package/src/material.ts +37 -0
- package/src/math.ts +336 -19
- package/src/profile.ts +43 -248
- package/src/scene.ts +530 -17
- package/src/sweep.ts +460 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@solidrt/3d",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.49",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"author": "Antoine van Wel",
|
|
6
6
|
"type": "module",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"AGENTS.md"
|
|
17
17
|
],
|
|
18
18
|
"peerDependencies": {
|
|
19
|
-
"@solidjs/signals": "2.0.0-
|
|
20
|
-
"@solidrt/core": "0.0.
|
|
19
|
+
"@solidjs/signals": "2.0.0-rc.0",
|
|
20
|
+
"@solidrt/core": "0.0.49"
|
|
21
21
|
}
|
|
22
22
|
}
|
package/src/bvh.ts
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// A dynamic bounding-volume hierarchy - the physics-broadphase AABB tree
|
|
2
|
+
// (Box2D/Bullet lineage): a binary tree over the items' world boxes, NOT a
|
|
3
|
+
// subdivision of space. Leaves store FAT boxes (a margin around the tight
|
|
4
|
+
// bounds) so an item moving a little refits nothing; an item escaping its
|
|
5
|
+
// fat box is removed and re-inserted along the cheapest path (least area
|
|
6
|
+
// growth). A ray query walks O(log n) nodes instead of testing every item,
|
|
7
|
+
// which is what keeps per-pointer-move picking off the O(meshes) path.
|
|
8
|
+
//
|
|
9
|
+
// The scene keeps the tree current from its own sync walk: transforms that
|
|
10
|
+
// changed are exactly the leaves to update, so maintenance is O(changed)
|
|
11
|
+
// per frame - the same delta discipline as rendering, and a static scene
|
|
12
|
+
// pays nothing. Storage is flat parallel arrays indexed by node id (no
|
|
13
|
+
// per-node objects, no allocation at steady state past tree growth).
|
|
14
|
+
//
|
|
15
|
+
// Pure module by design: no engine imports, so the differential check rig
|
|
16
|
+
// (checks/pick-check.ts) runs it under plain bun against a linear oracle.
|
|
17
|
+
|
|
18
|
+
/** Fat-margin fraction of a leaf's largest extent. Bigger = fewer
|
|
19
|
+
* re-inserts while moving, worse query pruning; 5% is the usual trade. */
|
|
20
|
+
const MARGIN = 0.05
|
|
21
|
+
|
|
22
|
+
type Visit<T> = (item: T) => void
|
|
23
|
+
|
|
24
|
+
export type Bvh<T> = {
|
|
25
|
+
/** Insert an item with its tight world box; returns the leaf handle. */
|
|
26
|
+
insert(item: T, minX: number, minY: number, minZ: number, maxX: number, maxY: number, maxZ: number): number
|
|
27
|
+
/** Update a leaf's tight box. Free while it stays inside the fat box;
|
|
28
|
+
* otherwise the leaf re-inserts. Returns true when it moved. */
|
|
29
|
+
update(leaf: number, minX: number, minY: number, minZ: number, maxX: number, maxY: number, maxZ: number): boolean
|
|
30
|
+
/** Remove a leaf (the handle is dead afterwards). */
|
|
31
|
+
remove(leaf: number): void
|
|
32
|
+
/** Visit every item whose FAT box the ray hits (t >= 0). Broadphase
|
|
33
|
+
* only: the caller narrowphases against its own tight volumes. */
|
|
34
|
+
raycast(ox: number, oy: number, oz: number, dx: number, dy: number, dz: number, visit: Visit<T>): void
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Entry distance of a ray against a box: the smallest t >= 0 with
|
|
39
|
+
* origin + t * direction inside [min, max] (0 when the origin starts
|
|
40
|
+
* inside), or -1 for a miss. The direction need not be normalized - t is
|
|
41
|
+
* in units of its length, which is what keeps a ray transformed into a
|
|
42
|
+
* mesh's local space reporting world distances. Shared by the tree's
|
|
43
|
+
* broadphase and the scene's narrowphase.
|
|
44
|
+
*/
|
|
45
|
+
export function rayBoxDistance(
|
|
46
|
+
ox: number, oy: number, oz: number,
|
|
47
|
+
dx: number, dy: number, dz: number,
|
|
48
|
+
minX: number, minY: number, minZ: number,
|
|
49
|
+
maxX: number, maxY: number, maxZ: number,
|
|
50
|
+
): number {
|
|
51
|
+
let tNear = 0
|
|
52
|
+
let tFar = Infinity
|
|
53
|
+
// Per axis: a zero direction component never crosses the slab, so the
|
|
54
|
+
// origin must already be inside it (the multiply-by-inverse shortcut
|
|
55
|
+
// turns that case into NaN, hence the explicit branch).
|
|
56
|
+
if (dx === 0) {
|
|
57
|
+
if (ox < minX || ox > maxX) return -1
|
|
58
|
+
} else {
|
|
59
|
+
let inv = 1 / dx
|
|
60
|
+
let t1 = (minX - ox) * inv
|
|
61
|
+
let t2 = (maxX - ox) * inv
|
|
62
|
+
if (t1 > t2) { let t = t1; t1 = t2; t2 = t }
|
|
63
|
+
if (t1 > tNear) tNear = t1
|
|
64
|
+
if (t2 < tFar) tFar = t2
|
|
65
|
+
}
|
|
66
|
+
if (dy === 0) {
|
|
67
|
+
if (oy < minY || oy > maxY) return -1
|
|
68
|
+
} else {
|
|
69
|
+
let inv = 1 / dy
|
|
70
|
+
let t1 = (minY - oy) * inv
|
|
71
|
+
let t2 = (maxY - oy) * inv
|
|
72
|
+
if (t1 > t2) { let t = t1; t1 = t2; t2 = t }
|
|
73
|
+
if (t1 > tNear) tNear = t1
|
|
74
|
+
if (t2 < tFar) tFar = t2
|
|
75
|
+
}
|
|
76
|
+
if (dz === 0) {
|
|
77
|
+
if (oz < minZ || oz > maxZ) return -1
|
|
78
|
+
} else {
|
|
79
|
+
let inv = 1 / dz
|
|
80
|
+
let t1 = (minZ - oz) * inv
|
|
81
|
+
let t2 = (maxZ - oz) * inv
|
|
82
|
+
if (t1 > t2) { let t = t1; t1 = t2; t2 = t }
|
|
83
|
+
if (t1 > tNear) tNear = t1
|
|
84
|
+
if (t2 < tFar) tFar = t2
|
|
85
|
+
}
|
|
86
|
+
return tFar >= tNear ? tNear : -1
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function createBvh<T>(): Bvh<T> {
|
|
90
|
+
// Node storage, 6 bounds floats per node; child1 === -1 marks a leaf.
|
|
91
|
+
let bounds: number[] = []
|
|
92
|
+
let parent: number[] = []
|
|
93
|
+
let child1: number[] = []
|
|
94
|
+
let child2: number[] = []
|
|
95
|
+
let items: (T | undefined)[] = []
|
|
96
|
+
let free: number[] = []
|
|
97
|
+
let root = -1
|
|
98
|
+
// Traversal stack, reused across queries.
|
|
99
|
+
let stack: number[] = []
|
|
100
|
+
|
|
101
|
+
let allocate = (): number => {
|
|
102
|
+
let node = free.pop()
|
|
103
|
+
if (node === undefined) {
|
|
104
|
+
node = parent.length
|
|
105
|
+
bounds.push(0, 0, 0, 0, 0, 0)
|
|
106
|
+
parent.push(-1)
|
|
107
|
+
child1.push(-1)
|
|
108
|
+
child2.push(-1)
|
|
109
|
+
items.push(undefined)
|
|
110
|
+
}
|
|
111
|
+
return node
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Half the surface area - the SAH cost of a node's box. Degenerate
|
|
115
|
+
// (flat) boxes still cost via their other faces, which is what makes
|
|
116
|
+
// the heuristic sane for planes.
|
|
117
|
+
let area = (n: number): number => {
|
|
118
|
+
let i = n * 6
|
|
119
|
+
let w = bounds[i + 3]! - bounds[i]!
|
|
120
|
+
let h = bounds[i + 4]! - bounds[i + 1]!
|
|
121
|
+
let d = bounds[i + 5]! - bounds[i + 2]!
|
|
122
|
+
return w * (h + d) + h * d
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
let unionArea = (n: number, minX: number, minY: number, minZ: number, maxX: number, maxY: number, maxZ: number): number => {
|
|
126
|
+
let i = n * 6
|
|
127
|
+
let w = Math.max(bounds[i + 3]!, maxX) - Math.min(bounds[i]!, minX)
|
|
128
|
+
let h = Math.max(bounds[i + 4]!, maxY) - Math.min(bounds[i + 1]!, minY)
|
|
129
|
+
let d = Math.max(bounds[i + 5]!, maxZ) - Math.min(bounds[i + 2]!, minZ)
|
|
130
|
+
return w * (h + d) + h * d
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Recompute an internal node's box as the union of its children.
|
|
134
|
+
let refit = (n: number): void => {
|
|
135
|
+
let a = child1[n]! * 6
|
|
136
|
+
let b = child2[n]! * 6
|
|
137
|
+
let i = n * 6
|
|
138
|
+
bounds[i] = Math.min(bounds[a]!, bounds[b]!)
|
|
139
|
+
bounds[i + 1] = Math.min(bounds[a + 1]!, bounds[b + 1]!)
|
|
140
|
+
bounds[i + 2] = Math.min(bounds[a + 2]!, bounds[b + 2]!)
|
|
141
|
+
bounds[i + 3] = Math.max(bounds[a + 3]!, bounds[b + 3]!)
|
|
142
|
+
bounds[i + 4] = Math.max(bounds[a + 4]!, bounds[b + 4]!)
|
|
143
|
+
bounds[i + 5] = Math.max(bounds[a + 5]!, bounds[b + 5]!)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
let insertLeaf = (leaf: number): void => {
|
|
147
|
+
if (root === -1) {
|
|
148
|
+
root = leaf
|
|
149
|
+
parent[leaf] = -1
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
let i = leaf * 6
|
|
153
|
+
let minX = bounds[i]!, minY = bounds[i + 1]!, minZ = bounds[i + 2]!
|
|
154
|
+
let maxX = bounds[i + 3]!, maxY = bounds[i + 4]!, maxZ = bounds[i + 5]!
|
|
155
|
+
// Descend toward the sibling that grows the least (the classic
|
|
156
|
+
// incremental surface-area heuristic).
|
|
157
|
+
let sibling = root
|
|
158
|
+
while (child1[sibling] !== -1) {
|
|
159
|
+
let a = child1[sibling]!
|
|
160
|
+
let b = child2[sibling]!
|
|
161
|
+
let costA = unionArea(a, minX, minY, minZ, maxX, maxY, maxZ) - area(a)
|
|
162
|
+
let costB = unionArea(b, minX, minY, minZ, maxX, maxY, maxZ) - area(b)
|
|
163
|
+
sibling = costA < costB ? a : b
|
|
164
|
+
}
|
|
165
|
+
let oldParent = parent[sibling]!
|
|
166
|
+
let newParent = allocate()
|
|
167
|
+
parent[newParent] = oldParent
|
|
168
|
+
child1[newParent] = sibling
|
|
169
|
+
child2[newParent] = leaf
|
|
170
|
+
items[newParent] = undefined
|
|
171
|
+
parent[sibling] = newParent
|
|
172
|
+
parent[leaf] = newParent
|
|
173
|
+
if (oldParent === -1) {
|
|
174
|
+
root = newParent
|
|
175
|
+
} else if (child1[oldParent] === sibling) {
|
|
176
|
+
child1[oldParent] = newParent
|
|
177
|
+
} else {
|
|
178
|
+
child2[oldParent] = newParent
|
|
179
|
+
}
|
|
180
|
+
for (let n = newParent; n !== -1; n = parent[n]!) refit(n)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
let removeLeaf = (leaf: number): void => {
|
|
184
|
+
if (leaf === root) {
|
|
185
|
+
root = -1
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
let p = parent[leaf]!
|
|
189
|
+
let sibling = child1[p] === leaf ? child2[p]! : child1[p]!
|
|
190
|
+
let grand = parent[p]!
|
|
191
|
+
parent[sibling] = grand
|
|
192
|
+
if (grand === -1) {
|
|
193
|
+
root = sibling
|
|
194
|
+
} else {
|
|
195
|
+
if (child1[grand] === p) child1[grand] = sibling
|
|
196
|
+
else child2[grand] = sibling
|
|
197
|
+
for (let n = grand; n !== -1; n = parent[n]!) refit(n)
|
|
198
|
+
}
|
|
199
|
+
child1[p] = -1
|
|
200
|
+
items[p] = undefined
|
|
201
|
+
free.push(p)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
let setFat = (leaf: number, minX: number, minY: number, minZ: number, maxX: number, maxY: number, maxZ: number): void => {
|
|
205
|
+
let m = MARGIN * Math.max(maxX - minX, maxY - minY, maxZ - minZ)
|
|
206
|
+
let i = leaf * 6
|
|
207
|
+
bounds[i] = minX - m
|
|
208
|
+
bounds[i + 1] = minY - m
|
|
209
|
+
bounds[i + 2] = minZ - m
|
|
210
|
+
bounds[i + 3] = maxX + m
|
|
211
|
+
bounds[i + 4] = maxY + m
|
|
212
|
+
bounds[i + 5] = maxZ + m
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
insert(item, minX, minY, minZ, maxX, maxY, maxZ) {
|
|
217
|
+
let leaf = allocate()
|
|
218
|
+
items[leaf] = item
|
|
219
|
+
setFat(leaf, minX, minY, minZ, maxX, maxY, maxZ)
|
|
220
|
+
insertLeaf(leaf)
|
|
221
|
+
return leaf
|
|
222
|
+
},
|
|
223
|
+
update(leaf, minX, minY, minZ, maxX, maxY, maxZ) {
|
|
224
|
+
let i = leaf * 6
|
|
225
|
+
if (
|
|
226
|
+
bounds[i]! <= minX && bounds[i + 1]! <= minY && bounds[i + 2]! <= minZ &&
|
|
227
|
+
bounds[i + 3]! >= maxX && bounds[i + 4]! >= maxY && bounds[i + 5]! >= maxZ
|
|
228
|
+
) {
|
|
229
|
+
return false
|
|
230
|
+
}
|
|
231
|
+
removeLeaf(leaf)
|
|
232
|
+
setFat(leaf, minX, minY, minZ, maxX, maxY, maxZ)
|
|
233
|
+
insertLeaf(leaf)
|
|
234
|
+
return true
|
|
235
|
+
},
|
|
236
|
+
remove(leaf) {
|
|
237
|
+
removeLeaf(leaf)
|
|
238
|
+
items[leaf] = undefined
|
|
239
|
+
free.push(leaf)
|
|
240
|
+
},
|
|
241
|
+
raycast(ox, oy, oz, dx, dy, dz, visit) {
|
|
242
|
+
if (root === -1) return
|
|
243
|
+
stack.length = 0
|
|
244
|
+
stack.push(root)
|
|
245
|
+
while (stack.length > 0) {
|
|
246
|
+
let n = stack.pop()!
|
|
247
|
+
let i = n * 6
|
|
248
|
+
let t = rayBoxDistance(ox, oy, oz, dx, dy, dz, bounds[i]!, bounds[i + 1]!, bounds[i + 2]!, bounds[i + 3]!, bounds[i + 4]!, bounds[i + 5]!)
|
|
249
|
+
if (t < 0) continue
|
|
250
|
+
if (child1[n] === -1) {
|
|
251
|
+
visit(items[n] as T)
|
|
252
|
+
} else {
|
|
253
|
+
stack.push(child1[n]!, child2[n]!)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
}
|
|
258
|
+
}
|
package/src/components.tsx
CHANGED
|
@@ -16,13 +16,15 @@ import {
|
|
|
16
16
|
remove,
|
|
17
17
|
setGeometry,
|
|
18
18
|
setMaterial,
|
|
19
|
+
setMeshParams,
|
|
19
20
|
setTransform,
|
|
20
21
|
setVisible,
|
|
21
22
|
} from "./scene.ts"
|
|
22
|
-
import type {
|
|
23
|
+
import type { ShaderParams } from "@solidrt/core/gpu"
|
|
24
|
+
import type { Mesh as MeshNode, Scene as SceneHandle, SceneNode, ScenePointerEvent } from "./scene.ts"
|
|
23
25
|
import type { Geometry } from "./geometry.ts"
|
|
24
26
|
import type { Material } from "./material.ts"
|
|
25
|
-
import type { Vec3 } from "./math.ts"
|
|
27
|
+
import type { Quat, Vec3 } from "./math.ts"
|
|
26
28
|
|
|
27
29
|
type SceneCtx = { scene: SceneHandle; parent: SceneNode }
|
|
28
30
|
let SceneContext = createContext<SceneCtx>()
|
|
@@ -37,20 +39,50 @@ export function useScene(): SceneCtx {
|
|
|
37
39
|
|
|
38
40
|
export type TransformProps = {
|
|
39
41
|
position?: Vec3
|
|
40
|
-
/** Euler radians
|
|
42
|
+
/** Euler radians in XYZ order (x first), Three's `Euler` default. */
|
|
41
43
|
rotation?: Vec3
|
|
44
|
+
/** The rotation as a quaternion - what the node stores. Pass this or
|
|
45
|
+
* `rotation`, not both. */
|
|
46
|
+
quaternion?: Quat
|
|
42
47
|
scale?: Vec3 | number
|
|
43
48
|
visible?: boolean
|
|
44
49
|
}
|
|
45
50
|
|
|
46
|
-
|
|
51
|
+
/**
|
|
52
|
+
* Mesh pointer events, the element vocabulary one tree deeper: the nearest
|
|
53
|
+
* hit mesh receives the event, down/move/up bubble to ancestor Groups
|
|
54
|
+
* (stopPropagation stops the walk), enter/leave pair on the mesh alone.
|
|
55
|
+
* Events flow while the element showing the scene carries scene.handlers -
|
|
56
|
+
* the built-in <Scene> leaf does (opt out with events={false}); an `output`
|
|
57
|
+
* leaf spreads them itself.
|
|
58
|
+
*/
|
|
59
|
+
export type PointerEventProps = {
|
|
60
|
+
onPointerDown?: (event: ScenePointerEvent) => void
|
|
61
|
+
onPointerMove?: (event: ScenePointerEvent) => void
|
|
62
|
+
onPointerUp?: (event: ScenePointerEvent) => void
|
|
63
|
+
/** Meshes only: a Group never receives enter/leave. */
|
|
64
|
+
onPointerEnter?: (event: ScenePointerEvent) => void
|
|
65
|
+
onPointerLeave?: (event: ScenePointerEvent) => void
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function syncNode(node: SceneNode, props: TransformProps & PointerEventProps): void {
|
|
47
69
|
createEffect(
|
|
48
|
-
() => [props.position, props.rotation, props.scale, props.visible] as const,
|
|
49
|
-
([position, rotation, scale, visible]) => {
|
|
50
|
-
setTransform(node, { position, rotation, scale })
|
|
70
|
+
() => [props.position, props.rotation, props.quaternion, props.scale, props.visible] as const,
|
|
71
|
+
([position, rotation, quaternion, scale, visible]) => {
|
|
72
|
+
setTransform(node, { position, rotation, quaternion, scale })
|
|
51
73
|
setVisible(node, visible !== false)
|
|
52
74
|
},
|
|
53
75
|
)
|
|
76
|
+
createEffect(
|
|
77
|
+
() => [props.onPointerDown, props.onPointerMove, props.onPointerUp, props.onPointerEnter, props.onPointerLeave] as const,
|
|
78
|
+
([down, move, up, enter, leave]) => {
|
|
79
|
+
node.onPointerDown = down
|
|
80
|
+
node.onPointerMove = move
|
|
81
|
+
node.onPointerUp = up
|
|
82
|
+
node.onPointerEnter = enter
|
|
83
|
+
node.onPointerLeave = leave
|
|
84
|
+
},
|
|
85
|
+
)
|
|
54
86
|
}
|
|
55
87
|
|
|
56
88
|
export type SceneProps = {
|
|
@@ -59,6 +91,12 @@ export type SceneProps = {
|
|
|
59
91
|
width: number
|
|
60
92
|
height: number
|
|
61
93
|
clearColor?: [number, number, number, number]
|
|
94
|
+
/** Fragment GLSL drawn behind the meshes, inside the scene's own pass
|
|
95
|
+
* (scene.setBackground): vUV/iResolution/fragColor contract, so a
|
|
96
|
+
* createShaderTexture backdrop ports verbatim. Reactive - swapping the
|
|
97
|
+
* source replaces the background; undefined removes it. Three's
|
|
98
|
+
* `scene.background = color` is `clearColor` here. */
|
|
99
|
+
background?: string
|
|
62
100
|
label?: string
|
|
63
101
|
ref?: (scene: SceneHandle) => void
|
|
64
102
|
/**
|
|
@@ -67,8 +105,16 @@ export type SceneProps = {
|
|
|
67
105
|
* leaf - a `<d-texture>`, a leaf carrying paint/pointer/layout props, or
|
|
68
106
|
* a post-effect chain (a shader target sampling the id; created in the
|
|
69
107
|
* callback it disposes with the Scene). Return null to render no leaf.
|
|
108
|
+
* Mesh pointer events then need the scene's handlers on your leaf:
|
|
109
|
+
* `<texture src={texture} {...useScene().scene.handlers} />`.
|
|
70
110
|
*/
|
|
71
111
|
output?: (texture: TextureId) => Element
|
|
112
|
+
/**
|
|
113
|
+
* Mesh pointer events (default on): the built-in leaf carries
|
|
114
|
+
* scene.handlers, so Mesh/Group onPointer* props receive events. `false`
|
|
115
|
+
* detaches them - the leaf then costs no pointer routing at all.
|
|
116
|
+
*/
|
|
117
|
+
events?: boolean
|
|
72
118
|
}
|
|
73
119
|
|
|
74
120
|
/**
|
|
@@ -86,14 +132,27 @@ export let Scene: ParentComponent<SceneProps> = props => {
|
|
|
86
132
|
() => [props.width, props.height] as const,
|
|
87
133
|
([w, h]) => scene.setSize(w, h),
|
|
88
134
|
)
|
|
135
|
+
createEffect(
|
|
136
|
+
() => props.background,
|
|
137
|
+
b => scene.setBackground(b ?? null),
|
|
138
|
+
)
|
|
89
139
|
untrack(() => props.ref)?.(scene)
|
|
90
140
|
let output = untrack(() => props.output)
|
|
141
|
+
let events = untrack(() => props.events) !== false
|
|
91
142
|
return (
|
|
92
143
|
<SceneContext value={{ scene, parent: scene.root }}>
|
|
93
144
|
{output ? (
|
|
94
145
|
untrack(() => output(scene.texture))
|
|
95
146
|
) : (
|
|
96
|
-
<texture
|
|
147
|
+
<texture
|
|
148
|
+
src={scene.texture}
|
|
149
|
+
width={props.width}
|
|
150
|
+
height={props.height}
|
|
151
|
+
onPointerDown={events ? scene.handlers.onPointerDown : undefined}
|
|
152
|
+
onPointerMove={events ? scene.handlers.onPointerMove : undefined}
|
|
153
|
+
onPointerUp={events ? scene.handlers.onPointerUp : undefined}
|
|
154
|
+
onPointerLeave={events ? scene.handlers.onPointerLeave : undefined}
|
|
155
|
+
/>
|
|
97
156
|
)}
|
|
98
157
|
{props.children}
|
|
99
158
|
</SceneContext>
|
|
@@ -101,7 +160,7 @@ export let Scene: ParentComponent<SceneProps> = props => {
|
|
|
101
160
|
}
|
|
102
161
|
|
|
103
162
|
/** A transform node: children inherit its position/rotation/scale. */
|
|
104
|
-
export let Group: ParentComponent<TransformProps & { ref?: (node: SceneNode) => void }> = props => {
|
|
163
|
+
export let Group: ParentComponent<TransformProps & PointerEventProps & { ref?: (node: SceneNode) => void }> = props => {
|
|
105
164
|
let ctx = useContext(SceneContext)
|
|
106
165
|
let node = createGroup()
|
|
107
166
|
add(ctx.parent, node)
|
|
@@ -111,9 +170,15 @@ export let Group: ParentComponent<TransformProps & { ref?: (node: SceneNode) =>
|
|
|
111
170
|
return <SceneContext value={{ scene: ctx.scene, parent: node }}>{props.children}</SceneContext>
|
|
112
171
|
}
|
|
113
172
|
|
|
114
|
-
export type MeshProps = TransformProps & {
|
|
173
|
+
export type MeshProps = TransformProps & PointerEventProps & {
|
|
115
174
|
geometry: Geometry
|
|
116
175
|
material: Material
|
|
176
|
+
/** Per-mesh uniforms for a custom material (setMeshParams as a prop).
|
|
177
|
+
* Keys merge - a key that disappears keeps its old value; there is no
|
|
178
|
+
* unset. Names must be declared by the material's shaders. For values
|
|
179
|
+
* changing every frame prefer `ref` + setMeshParams from onFrame, the
|
|
180
|
+
* same split as setTransform. */
|
|
181
|
+
params?: ShaderParams
|
|
117
182
|
ref?: (mesh: MeshNode) => void
|
|
118
183
|
}
|
|
119
184
|
|
|
@@ -132,6 +197,12 @@ export let Mesh: VoidComponent<MeshProps> = props => {
|
|
|
132
197
|
m => setMaterial(mesh, m),
|
|
133
198
|
{ defer: true },
|
|
134
199
|
)
|
|
200
|
+
createEffect(
|
|
201
|
+
() => props.params,
|
|
202
|
+
p => {
|
|
203
|
+
if (p !== undefined) setMeshParams(mesh, p)
|
|
204
|
+
},
|
|
205
|
+
)
|
|
135
206
|
syncNode(mesh, props)
|
|
136
207
|
untrack(() => props.ref)?.(mesh)
|
|
137
208
|
onCleanup(() => remove(mesh))
|
package/src/geometry.ts
CHANGED
|
@@ -43,6 +43,12 @@ export const VERTEX_LAYOUTS: Record<VertexLayout, VertexAttribute[]> = {
|
|
|
43
43
|
export const FLOATS_PER_VERTEX = 8
|
|
44
44
|
const COLORED_FLOATS = 12
|
|
45
45
|
|
|
46
|
+
/** Uint16 indices when they fit, Uint32Array past 64k vertices - the draw
|
|
47
|
+
* entry follows the array type. The tail of every unbounded generator. */
|
|
48
|
+
export function packIndices(indices: number[], vertexCount: number): Uint16Array | Uint32Array {
|
|
49
|
+
return vertexCount > 65535 ? new Uint32Array(indices) : new Uint16Array(indices)
|
|
50
|
+
}
|
|
51
|
+
|
|
46
52
|
export type Geometry = {
|
|
47
53
|
/** Interleaved [pos.xyz, normal.xyz, uv.xy] per vertex, plus color.rgba
|
|
48
54
|
* in the "colored" layout. */
|
|
@@ -57,6 +63,35 @@ export type Geometry = {
|
|
|
57
63
|
label?: string
|
|
58
64
|
_buffer?: BufferId
|
|
59
65
|
_index?: BufferId
|
|
66
|
+
_bounds?: Float32Array
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The geometry's LOCAL axis-aligned bounds as [minX, minY, minZ, maxX,
|
|
71
|
+
* maxY, maxZ], computed from the vertices on first use and cached (like
|
|
72
|
+
* the GPU buffers, geometry is treated as immutable after creation).
|
|
73
|
+
* Picking's narrowphase volume; a flat geometry legitimately has zero
|
|
74
|
+
* extent on an axis. An empty geometry yields a zero box at the origin.
|
|
75
|
+
*/
|
|
76
|
+
export function geometryBounds(geometry: Geometry): Float32Array {
|
|
77
|
+
let bounds = geometry._bounds
|
|
78
|
+
if (bounds === undefined) {
|
|
79
|
+
bounds = new Float32Array([Infinity, Infinity, Infinity, -Infinity, -Infinity, -Infinity])
|
|
80
|
+
let v = geometry.vertices
|
|
81
|
+
let stride = geometry.layout === "colored" ? COLORED_FLOATS : FLOATS_PER_VERTEX
|
|
82
|
+
for (let i = 0; i + 2 < v.length; i += stride) {
|
|
83
|
+
let x = v[i]!, y = v[i + 1]!, z = v[i + 2]!
|
|
84
|
+
if (x < bounds[0]!) bounds[0] = x
|
|
85
|
+
if (y < bounds[1]!) bounds[1] = y
|
|
86
|
+
if (z < bounds[2]!) bounds[2] = z
|
|
87
|
+
if (x > bounds[3]!) bounds[3] = x
|
|
88
|
+
if (y > bounds[4]!) bounds[4] = y
|
|
89
|
+
if (z > bounds[5]!) bounds[5] = z
|
|
90
|
+
}
|
|
91
|
+
if (bounds[0]! > bounds[3]!) bounds.fill(0)
|
|
92
|
+
geometry._bounds = bounds
|
|
93
|
+
}
|
|
94
|
+
return bounds
|
|
60
95
|
}
|
|
61
96
|
|
|
62
97
|
/** The geometry's GPU buffers, created on first use and cached on it,
|
package/src/index.ts
CHANGED
|
@@ -5,17 +5,21 @@
|
|
|
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, remove, setGeometry, setMaterial, setMeshParams, setTransform, setVisible } from "./scene.ts"
|
|
9
|
-
export type { CameraUpdate, Mesh as MeshNode, Scene as SceneHandle, SceneNode, SceneOptions, TransformUpdate } from "./scene.ts"
|
|
8
|
+
export { add, createGroup, createMesh, createScene, getRotation, lookAt, remove, setGeometry, setMaterial, setMeshParams, 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
10
|
export { box, circle, cone, cylinder, disposeGeometry, fillColors, plane, ring, sphere, torus, torusKnot, withColors, FLOATS_PER_VERTEX, VERTEX_LAYOUTS } from "./geometry.ts"
|
|
11
11
|
export type { ColorFill, Geometry, VertexLayout } from "./geometry.ts"
|
|
12
|
-
export {
|
|
12
|
+
export { fillet, roundRect, shape, triangulate } from "./profile.ts"
|
|
13
13
|
export type { Profile, ProfilePoint } from "./profile.ts"
|
|
14
|
+
export { extrude, lathe, pathFrames, sweep, tube } from "./sweep.ts"
|
|
15
|
+
export type { PathFrames, PathPoint, SweepPath } from "./sweep.ts"
|
|
14
16
|
export { shaderMaterial, unlit } from "./material.ts"
|
|
15
17
|
export type { Material, ShaderMaterialOptions, UnlitOptions } from "./material.ts"
|
|
16
18
|
export { Group, Mesh, PerspectiveCamera, Scene, useScene } from "./components.tsx"
|
|
17
|
-
export type { MeshProps, PerspectiveCameraProps, SceneProps, TransformProps } from "./components.tsx"
|
|
19
|
+
export type { MeshProps, PerspectiveCameraProps, PointerEventProps, SceneProps, TransformProps } from "./components.tsx"
|
|
18
20
|
export { createOrbitCamera } from "./orbit.ts"
|
|
19
21
|
export type { OrbitCamera, OrbitCameraOptions, OrbitPose } from "./orbit.ts"
|
|
20
|
-
|
|
21
|
-
|
|
22
|
+
// math's lookAt (the camera view matrix) stays on the /math subpath: the
|
|
23
|
+
// root's lookAt is the scene verb, the same split as `add`.
|
|
24
|
+
export { compose, copy, eulerFromQuat, identity, mat4, multiply, normalMatrix, perspective, quat, quatFromAxisAngle, quatFromEuler, quatFromFrame, quatFromTo, quatMultiply, quatNormalize, quatSlerp } from "./math.ts"
|
|
25
|
+
export type { Mat4, Quat, Vec2, Vec3 } from "./math.ts"
|
package/src/material.ts
CHANGED
|
@@ -144,6 +144,43 @@ function needsHeader(source: string): boolean {
|
|
|
144
144
|
return !source.trimStart().startsWith("#version")
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
+
// The scene-background pass (scene.setBackground). The vertex stage is the
|
|
148
|
+
// engine's own attributeless fullscreen triangle (gl_VertexID, no vertex
|
|
149
|
+
// buffer), emitting the SAME vUV the shader-target contract provides: 0..1
|
|
150
|
+
// with origin at the displayed top-left - so a backdrop fragment written
|
|
151
|
+
// for createShaderTexture ports verbatim.
|
|
152
|
+
const BACKGROUND_VERTEX = glsl`
|
|
153
|
+
out vec2 vUV;
|
|
154
|
+
void main() {
|
|
155
|
+
vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
|
|
156
|
+
vUV = p;
|
|
157
|
+
gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
|
|
158
|
+
}
|
|
159
|
+
`
|
|
160
|
+
|
|
161
|
+
// Pipeline fragments get no vUV from the engine preamble (a pipeline's
|
|
162
|
+
// varyings are its own), so the background slot injects the full
|
|
163
|
+
// shader-target fragment contract itself: vUV, fragColor, iResolution.
|
|
164
|
+
const BACKGROUND_FRAGMENT_PREAMBLE =
|
|
165
|
+
"#version 300 es\nprecision highp float;\nin vec2 vUV;\nout vec4 fragColor;\nuniform vec2 iResolution;\n"
|
|
166
|
+
|
|
167
|
+
/** The scene's background pipeline (internal - reached via
|
|
168
|
+
* scene.setBackground): depth-free, attributeless, drawn as entry zero of
|
|
169
|
+
* the scene pass. */
|
|
170
|
+
export function backgroundPipeline(fragment: string, label: string): { pipeline: RenderPipelineId; program: ProgramId } {
|
|
171
|
+
let vs = compileShader("vertex", BACKGROUND_VERTEX, { header: true })
|
|
172
|
+
let fs = compileShader(
|
|
173
|
+
"fragment",
|
|
174
|
+
needsHeader(fragment) ? BACKGROUND_FRAGMENT_PREAMBLE + fragment : fragment,
|
|
175
|
+
{ header: false },
|
|
176
|
+
)
|
|
177
|
+
let program = linkProgram(vs, fs, { label })
|
|
178
|
+
destroyShader(vs)
|
|
179
|
+
destroyShader(fs)
|
|
180
|
+
let pipeline = createRenderPipeline(program, { label })
|
|
181
|
+
return { pipeline, program }
|
|
182
|
+
}
|
|
183
|
+
|
|
147
184
|
export type ShaderMaterialOptions = {
|
|
148
185
|
/**
|
|
149
186
|
* Vertex stage GLSL. MUST declare and use `uniform mat4 uModel` (the
|