@solidrt/3d 0.0.56 → 0.0.58

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.
Files changed (48) hide show
  1. package/AGENTS.md +1052 -639
  2. package/README.md +2 -1
  3. package/examples/README.md +19 -8
  4. package/examples/collision.tsx +17 -4
  5. package/examples/first-person.tsx +20 -10
  6. package/examples/fleet.tsx +11 -5
  7. package/examples/model.tsx +3 -3
  8. package/examples/pick.tsx +144 -34
  9. package/examples/probe.tsx +10 -4
  10. package/examples/scene-views.tsx +49 -58
  11. package/examples/sky-lit.tsx +10 -4
  12. package/examples/skybox.tsx +10 -4
  13. package/examples/standard.tsx +10 -4
  14. package/examples/wireframe.tsx +107 -0
  15. package/package.json +2 -2
  16. package/src/collision.ts +14 -193
  17. package/src/components/context.tsx +35 -0
  18. package/src/components/directional-light.tsx +48 -0
  19. package/src/components/first-person-camera.tsx +72 -0
  20. package/src/components/group.tsx +18 -0
  21. package/src/components/hemisphere-light.tsx +24 -0
  22. package/src/components/index.ts +46 -0
  23. package/src/components/instanced-mesh.tsx +105 -0
  24. package/src/components/mesh.tsx +92 -0
  25. package/src/components/node-props.ts +90 -0
  26. package/src/components/orbit-camera.tsx +75 -0
  27. package/src/components/perspective-camera.tsx +32 -0
  28. package/src/components/point-light.tsx +52 -0
  29. package/src/components/record-mesh.tsx +68 -0
  30. package/src/components/scene.tsx +281 -0
  31. package/src/components/spot-light.tsx +65 -0
  32. package/src/components/sprite.tsx +38 -0
  33. package/src/components/view3d.tsx +152 -0
  34. package/src/first-person.ts +140 -188
  35. package/src/geometry-gpu.ts +45 -13
  36. package/src/geometry.ts +186 -5
  37. package/src/gltf.ts +97 -16
  38. package/src/index.ts +15 -10
  39. package/src/input.ts +79 -0
  40. package/src/material.ts +26 -8
  41. package/src/mesh.ts +5 -6
  42. package/src/model.ts +65 -33
  43. package/src/node.ts +100 -26
  44. package/src/orbit.ts +266 -235
  45. package/src/scene-pointer.ts +229 -101
  46. package/src/scene.ts +256 -79
  47. package/src/skeleton.ts +211 -0
  48. package/src/components.tsx +0 -1101
@@ -1,59 +1,53 @@
1
- // A first-person camera for a scene: a position plus yaw/pitch, with mouse
2
- // look under pointer lock, drag-to-look without it (the touch path), and
3
- // walk (or fly) from keys and gamepad sticks - Unity's FirstPersonController
4
- // shape, look and move in one control, rather than Three's split into
5
- // PointerLockControls (look only) and a hand-written key loop.
1
+ // A first-person camera for a scene: a position plus yaw/pitch, look and
2
+ // move in one control - Unity's FirstPersonController shape, rather than
3
+ // Three's split into PointerLockControls (look only) and a hand-written
4
+ // key loop. The control is pure (ARCHITECTURE.md: controls through an
5
+ // abstraction, never direct event handling): it consumes three axes,
6
+ // `look` (vec2), `move` (vec2) and `rise` (axis), in device-free units,
7
+ // and exposes pose verbs for scripted moves. Mouse look under pointer
8
+ // lock, drag-to-look on touch, WASD, sticks: all of that is the app's
9
+ // wiring through an input map, and `firstPersonBindings` in input.ts is
10
+ // the standard set.
6
11
  //
7
- // Look input has three sources, all summed into the same yaw/pitch:
8
- // - pointer-move deltas (movementX/movementY) while the pointer is locked,
9
- // the desktop mouse-look path; the control never engages the lock itself
10
- // (click-to-lock and Escape-to-release are window-level decisions that
11
- // stay app code, see lockPointer in @solidrt/core);
12
- // - a one-finger drag through core's transform recognizer while NOT locked,
13
- // so a touch viewport looks around with the arena arbitration the orbit
14
- // camera has, and viewport-relative like it;
15
- // - the right stick, read from core's gamepads() at update time.
16
- // Move input: WASD/arrows (physical codes AND logical keys, so a layout
17
- // or a synthetic event without a code both work), Q/E for down/up in fly
18
- // mode, and the left stick. Walking flattens the forward vector onto the
19
- // ground plane at fixed height; flying moves along the view direction.
12
+ // `look` deltas are element heights of travel (a drag across the element
13
+ // sweeps DRAG_TURNS turns) or the pointer feed's mouse-motion
14
+ // equivalent; a rate (a stick) turns at LOOK_RATE turns per second at
15
+ // full deflection. `move` is [right, forward] in the screen convention
16
+ // (forward is -y, as a stick pushed up reads): a rate walks at
17
+ // `moveSpeed`, a delta is a step in world units. `rise` moves along
18
+ // world up at the same speed, in fly mode only. Walking flattens the
19
+ // forward vector onto the ground plane at fixed height; flying moves
20
+ // along the view direction.
20
21
  //
21
- // Pose is plain mutable state advanced by update(dt) from the app's own
22
- // onFrame, as in orbit.ts: only `active` is reactive - whether anything is
23
- // held or deflected, what a frame loop gates on; it derives from the held
24
- // count and core's gamepads() so a stick moved while the loop is off wakes
25
- // it - while the pose moves at frame rate and bypasses reactivity. Held
26
- // keys cannot be polled (there is no key-state accessor in core), so the
27
- // control tracks them from the down/up pair; an `onBlur` handler drops
28
- // them all, because the up never arrives once focus has left.
22
+ // Pose is plain mutable state; a nudge or a verb pushes it at once (a
23
+ // mouse move under lock needs no frame loop), and update(dt) integrates
24
+ // the rates from the app's own onFrame. Only `active` is reactive: the
25
+ // frame-loop gate, true while any rate reads non-zero (a held key, a
26
+ // deflected stick), so a still scene renders nothing new. update()
27
+ // returns whether the pose changed since the previous update.
29
28
  //
30
29
  // Collision is deliberately absent: a camera control cannot know the
31
30
  // level. Clamp or reject positions through `clampPosition`.
32
31
  //
33
32
  // Options are read where they apply, not copied out: `fly`, `moveSpeed`,
34
- // `lookSpeed`, the pitch clamps, `viewport` and `clampPosition` are
35
- // re-read from the options object on every input or update, so a caller
36
- // may change a field (or hand in an object of getters, which is what
37
- // `<FirstPersonCamera>` does with its props) and the next move sees it -
38
- // walk and fly are one control, not two mounts. Only the initial pose is
39
- // copied at creation; later pose changes go through set().
33
+ // `lookSpeed`, the pitch clamps and `clampPosition` are re-read from the
34
+ // options object on every input or update, so a caller may change a field
35
+ // (or hand in an object of getters, which is what `<FirstPersonCamera>`
36
+ // does with its props) and the next move sees it - walk and fly are one
37
+ // control, not two mounts. Only the initial pose is copied at creation;
38
+ // later pose changes go through set() and the verbs.
40
39
 
41
- import { createMemo, createSignal } from "@solidjs/signals"
42
- import { createTransform, gamepads, pointerLocked } from "@solidrt/core"
43
- import type { KeyEvent, PointerEvent } from "@solidrt/core"
40
+ import { untrack } from "@solidjs/signals"
41
+ import { createAxes } from "@solidrt/core/input"
42
+ import type { Axes, Vec2 } from "@solidrt/core/input"
44
43
  import type { CameraUpdate } from "./camera.ts"
45
44
  import type { Vec3 } from "./math.ts"
46
45
 
47
- // Baseline sensitivities at lookSpeed 1. Mouse look is radians per moved
48
- // logical pixel under lock (Three's PointerLockControls figure); with a
49
- // `viewport` the drag is viewport-relative, one viewport height sweeping
50
- // DRAG_TURNS turns, and without one it falls back to the mouse figure.
51
- // The stick turns at LOOK_STICK radians/second at full deflection, with
52
- // a dead zone below which a resting stick reads as zero.
53
- const LOOK_MOUSE = 0.002
46
+ // One element height of drag sweeps this many turns of look (half a turn:
47
+ // a drag across the screen turns the walker around).
54
48
  const DRAG_TURNS = 0.5
55
- const LOOK_STICK = 2.5
56
- const STICK_DEADZONE = 0.15
49
+ // Look rate at full stick deflection, turns per second.
50
+ const LOOK_RATE = 0.4
57
51
  // Default walking speed, world units per second.
58
52
  const MOVE_SPEED = 3
59
53
  // Pitch clamps stop short of the poles so the look direction never
@@ -80,17 +74,14 @@ export type FirstPersonCameraOptions = {
80
74
  maxPitch?: number
81
75
  /** Movement speed in world units per second (default 3). */
82
76
  moveSpeed?: number
83
- /** Multiplier over the built-in mouse, drag and stick look rates. */
77
+ /** Multiplier over the built-in look sensitivities (drags, mouse motion
78
+ * and rates alike). */
84
79
  lookSpeed?: number
85
80
  /** Walk (default) keeps the height fixed and moves along the ground
86
- * projection of the view; fly moves along the view itself and arms Q/E
87
- * for down/up (bound either way, inert while walking). Toggling it on a
88
- * running control keeps the pose and the held keys. */
81
+ * projection of the view; fly moves along the view itself and lets
82
+ * `rise` move along world up. Toggling it on a running control keeps
83
+ * the pose. */
89
84
  fly?: boolean
90
- /** The viewport a drag lives in: the input element's own laid-out
91
- * height (the frame the recognizer's deltas arrive in); makes the drag
92
- * viewport-relative. Null while unknown. */
93
- viewport?: () => { height: number } | null
94
85
  /** Constrain where a move may put the eye: called with the eye the
95
86
  * move asks for and the eye it starts from (both fresh arrays), returns
96
87
  * the position to use - a level's bounds, a floor height, a collision
@@ -105,6 +96,8 @@ export type FirstPersonPose = {
105
96
  pitch?: number
106
97
  }
107
98
 
99
+ export type FirstPersonAxes = { look: "vec2"; move: "vec2"; rise: "axis" }
100
+
108
101
  export type FirstPersonCamera = {
109
102
  /** Eye position (a fresh array per call). */
110
103
  eye(): Vec3
@@ -112,195 +105,154 @@ export type FirstPersonCamera = {
112
105
  forward(): Vec3
113
106
  /** Pose snapshot - the shape debug commands return and set() takes. */
114
107
  pose(): { position: Vec3; yaw: number; pitch: number }
115
- /** Merge a pose in (clamps apply); reaches the scene at the next
116
- * update(). */
108
+ /** Merge a pose in (clamps apply) and push it. */
117
109
  set(pose: FirstPersonPose): void
118
- /** Whether a movement key is held or a stick deflected - what a frame
119
- * loop should run on. Reactive (signal-backed). */
110
+ /** Whether any axis rate reads non-zero - what a frame loop should run
111
+ * on. Reactive. */
120
112
  active(): boolean
121
- /** Integrate held keys and sticks over dt seconds and push any pose
122
- * change to the driven camera; returns whether the pose changed. */
113
+ /** Integrate the axis rates over dt seconds and push any pose change;
114
+ * returns whether the pose changed since the previous update (nudges
115
+ * and verbs included). */
123
116
  update(dt: number): boolean
124
- /** Spread onto the element that receives input. Keys route through the
125
- * focused node, so that element must hold focus (or be the window). */
126
- handlers: {
127
- onPointerDown(e: PointerEvent): void
128
- onPointerMove(e: PointerEvent): void
129
- onPointerUp(e: PointerEvent): void
130
- onKeyDown(e: KeyEvent): void
131
- onKeyUp(e: KeyEvent): void
132
- onBlur(): void
133
- }
117
+ /** The input abstraction: `look` (vec2, element heights of drag / turns
118
+ * per second), `move` (vec2 [right, forward], forward = -y; world units
119
+ * per delta, `moveSpeed` per second) and `rise` (axis, fly only). */
120
+ axes: Axes<FirstPersonAxes>
121
+ /** Turn by radians (yaw positive left, pitch positive up; clamps
122
+ * apply) and push. */
123
+ lookBy(yaw: number, pitch: number): void
124
+ /** Step by world units in the walker's frame: right, forward (ground
125
+ * projection when walking, the view direction when flying) and up
126
+ * (world up, fly mode only), through `clampPosition`, and push. */
127
+ moveBy(right: number, forward: number, up?: number): void
134
128
  }
135
129
 
136
- type Move = "forward" | "back" | "left" | "right" | "up" | "down"
130
+ let clampNum = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v))
137
131
 
138
- // Physical codes first (layout-independent), then the logical keys a
139
- // synthetic event or an odd layout reports.
140
- const KEY_MOVES: Record<string, Move> = {
141
- KeyW: "forward", ArrowUp: "forward", w: "forward", W: "forward",
142
- KeyS: "back", ArrowDown: "back", s: "back", S: "back",
143
- KeyA: "left", ArrowLeft: "left", a: "left", A: "left",
144
- KeyD: "right", ArrowRight: "right", d: "right", D: "right",
145
- KeyE: "up", e: "up", E: "up",
146
- KeyQ: "down", q: "down", Q: "down",
132
+ function finite(what: string, v: number): void {
133
+ if (!Number.isFinite(v)) throw new Error(`createFirstPersonCamera: ${what} must be a finite number, got ${v}`)
147
134
  }
148
135
 
149
- let clampNum = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v))
150
- let deadzone = (v: number) => (Math.abs(v) < STICK_DEADZONE ? 0 : v)
151
-
152
136
  /**
153
137
  * Create a first-person camera driving `camera`'s position and target,
154
138
  * where `camera` is a Scene or one of its Views (fov, near, and far stay
155
139
  * yours via its setCamera). The initial pose applies immediately. In a
156
- * component tree, prefer the `<FirstPersonCamera>` component: it wires
157
- * scene, input, focus, viewport and the frame loop through the Scene
158
- * context.
140
+ * component tree, prefer the `<FirstPersonCamera>` component: it drives
141
+ * the enclosing scene or view through context and takes an input map as
142
+ * a prop.
159
143
  */
160
144
  export function createFirstPersonCamera(camera: FirstPersonTarget, options: FirstPersonCameraOptions = {}): FirstPersonCamera {
145
+ if (!camera || typeof camera.setCamera !== "function") throw new Error("createFirstPersonCamera: the target needs setCamera() (a Scene or a View)")
161
146
  let position: Vec3 = options.position ? [options.position[0], options.position[1], options.position[2]] : [0, 1.6, 0]
162
147
  let yaw = options.yaw ?? 0
163
148
  let pitch = options.pitch ?? 0
164
149
  // Everything below the pose is read from `options` where it applies.
165
150
  let lookSpeed = () => options.lookSpeed ?? 1
151
+ let moveSpeed = () => options.moveSpeed ?? MOVE_SPEED
166
152
  let clampPitch = () => {
167
153
  pitch = clampNum(pitch, options.minPitch ?? -PITCH_LIMIT, options.maxPitch ?? PITCH_LIMIT)
168
154
  }
169
-
170
- let held = new Set<Move>()
171
- let [heldCount, setHeldCount] = createSignal(0)
172
- let dirty = false
155
+ let changed = false
173
156
 
174
157
  let forward = (): Vec3 => {
175
158
  let cp = Math.cos(pitch)
176
159
  return [-Math.sin(yaw) * cp, Math.sin(pitch), -Math.cos(yaw) * cp]
177
160
  }
178
- let apply = () => {
161
+ let push = () => {
162
+ changed = true
179
163
  let f = forward()
180
164
  camera.setCamera({ position: [position[0], position[1], position[2]], target: [position[0] + f[0], position[1] + f[1], position[2] + f[2]] })
181
165
  }
182
- let look = (dx: number, dy: number) => {
183
- yaw -= dx
184
- pitch -= dy
166
+ let look = (dYaw: number, dPitch: number) => {
167
+ yaw += dYaw
168
+ pitch += dPitch
185
169
  clampPitch()
186
- dirty = true
187
170
  }
188
- let moveBy = (dx: number, dy: number, dz: number) => {
189
- let next: Vec3 = [position[0] + dx, position[1] + dy, position[2] + dz]
171
+ // A step in the walker's frame. Walking projects the view onto the
172
+ // ground plane, so looking down does not slow the walk; right is always
173
+ // horizontal; up applies in fly mode only.
174
+ let step = (right: number, ahead: number, up: number) => {
175
+ let fly = options.fly ?? false
176
+ let f = forward()
177
+ let fx = fly ? f[0] : -Math.sin(yaw)
178
+ let fy = fly ? f[1] : 0
179
+ let fz = fly ? f[2] : -Math.cos(yaw)
180
+ let rx = Math.cos(yaw)
181
+ let rz = -Math.sin(yaw)
182
+ let rise = fly ? up : 0
183
+ let next: Vec3 = [position[0] + fx * ahead + rx * right, position[1] + fy * ahead + rise, position[2] + fz * ahead + rz * right]
190
184
  position = options.clampPosition ? options.clampPosition(next, [position[0], position[1], position[2]]) : next
191
- dirty = true
192
- }
193
-
194
- // The stick axes of every connected pad, summed: a game with one player
195
- // sees its one pad, and a pad at rest contributes nothing.
196
- let sticks = () => {
197
- let s = { moveX: 0, moveY: 0, lookX: 0, lookY: 0 }
198
- for (let pad of gamepads()) {
199
- if (!pad) continue
200
- s.moveX += deadzone(pad.axes.leftX ?? 0)
201
- s.moveY += deadzone(pad.axes.leftY ?? 0)
202
- s.lookX += deadzone(pad.axes.rightX ?? 0)
203
- s.lookY += deadzone(pad.axes.rightY ?? 0)
204
- }
205
- return s
206
185
  }
207
- let active = createMemo(() => {
208
- let s = sticks()
209
- return heldCount() > 0 || s.moveX !== 0 || s.moveY !== 0 || s.lookX !== 0 || s.lookY !== 0
210
- })
211
186
 
212
- // The unlocked look: a one-finger drag, arena-arbitrated. Two fingers
213
- // have no first-person meaning and keep looking.
214
- let transform = createTransform({
215
- onTransformMove: t => {
216
- let vp = options.viewport?.() ?? null
217
- let rate = (vp !== null ? (DRAG_TURNS * 2 * Math.PI) / vp.height : LOOK_MOUSE) * lookSpeed()
218
- look(t.dx * rate, t.dy * rate)
219
- apply()
220
- dirty = false
187
+ let axes = createAxes<FirstPersonAxes>(
188
+ { look: "vec2", move: "vec2", rise: "axis" },
189
+ {
190
+ onNudge: (name, delta) => {
191
+ if (name === "look") {
192
+ let d = delta as Vec2
193
+ let rel = DRAG_TURNS * 2 * Math.PI * lookSpeed()
194
+ look(-d[0] * rel, -d[1] * rel)
195
+ } else if (name === "move") {
196
+ let d = delta as Vec2
197
+ step(d[0], -d[1], 0)
198
+ } else {
199
+ step(0, 0, delta as number)
200
+ }
201
+ push()
202
+ },
221
203
  },
222
- })
204
+ )
223
205
 
224
206
  clampPitch()
225
- apply()
207
+ push()
208
+ changed = false
226
209
 
227
210
  return {
228
211
  eye: () => [position[0], position[1], position[2]],
229
212
  forward,
230
213
  pose: () => ({ position: [position[0], position[1], position[2]], yaw, pitch }),
231
- active,
214
+ active: axes.active,
215
+ axes,
232
216
  set(pose) {
233
217
  if (pose.position) position = [pose.position[0], pose.position[1], pose.position[2]]
234
218
  if (pose.yaw !== undefined) yaw = pose.yaw
235
219
  if (pose.pitch !== undefined) pitch = pose.pitch
236
220
  clampPitch()
237
- dirty = true
221
+ push()
238
222
  },
239
223
  update(dt) {
240
- let s = sticks()
241
- if (s.lookX !== 0 || s.lookY !== 0) {
242
- let rate = LOOK_STICK * lookSpeed() * dt
243
- look(s.lookX * rate, s.lookY * rate)
224
+ finite("update dt", dt)
225
+ let moved = false
226
+ let [lx, ly] = untrack(() => axes.rate("look"))
227
+ if (lx !== 0 || ly !== 0) {
228
+ let rate = LOOK_RATE * 2 * Math.PI * lookSpeed() * dt
229
+ look(-lx * rate, -ly * rate)
230
+ moved = true
244
231
  }
245
- // Key axes: -1..1 per axis, the stick added on top (stick up is -y,
246
- // the web convention, so forward is -leftY), then clamped so a key
247
- // and a stick together do not exceed full speed.
248
- let ahead = clampNum((held.has("forward") ? 1 : 0) - (held.has("back") ? 1 : 0) - s.moveY, -1, 1)
249
- let side = clampNum((held.has("right") ? 1 : 0) - (held.has("left") ? 1 : 0) + s.moveX, -1, 1)
250
- // One read per update, so a mode toggle lands between steps, never
251
- // between the rise and the heading of the same step.
252
- let fly = options.fly ?? false
253
- let rise = fly ? (held.has("up") ? 1 : 0) - (held.has("down") ? 1 : 0) : 0
254
- if (ahead !== 0 || side !== 0 || rise !== 0) {
255
- let step = (options.moveSpeed ?? MOVE_SPEED) * dt
256
- let f = forward()
257
- // Walking projects the view onto the ground plane, so looking down
258
- // does not slow the walk; right is always horizontal.
259
- let fx = fly ? f[0] : -Math.sin(yaw)
260
- let fy = fly ? f[1] : 0
261
- let fz = fly ? f[2] : -Math.cos(yaw)
262
- let rx = Math.cos(yaw)
263
- let rz = -Math.sin(yaw)
264
- moveBy((fx * ahead + rx * side) * step, (fy * ahead + rise) * step, (fz * ahead + rz * side) * step)
232
+ let [mx, my] = untrack(() => axes.rate("move"))
233
+ let rise = untrack(() => axes.rate("rise"))
234
+ if (mx !== 0 || my !== 0 || rise !== 0) {
235
+ let speed = moveSpeed() * dt
236
+ step(mx * speed, -my * speed, rise * speed)
237
+ moved = true
265
238
  }
266
- if (!dirty) return false
267
- dirty = false
268
- apply()
269
- return true
239
+ if (moved) push()
240
+ let result = changed
241
+ changed = false
242
+ return result
270
243
  },
271
- handlers: {
272
- onPointerDown(e) {
273
- if (!pointerLocked()) transform.handlers.onPointerDown(e)
274
- },
275
- onPointerMove(e) {
276
- if (pointerLocked()) {
277
- let rate = LOOK_MOUSE * lookSpeed()
278
- look(e.movementX * rate, e.movementY * rate)
279
- apply()
280
- dirty = false
281
- } else {
282
- transform.handlers.onPointerMove(e)
283
- }
284
- },
285
- onPointerUp(e) {
286
- transform.handlers.onPointerUp(e)
287
- },
288
- onKeyDown(e) {
289
- let move = KEY_MOVES[e.code] ?? KEY_MOVES[e.key]
290
- if (!move) return
291
- held.add(move)
292
- setHeldCount(held.size)
293
- },
294
- onKeyUp(e) {
295
- let move = KEY_MOVES[e.code] ?? KEY_MOVES[e.key]
296
- if (!move) return
297
- held.delete(move)
298
- setHeldCount(held.size)
299
- },
300
- onBlur() {
301
- held.clear()
302
- setHeldCount(0)
303
- },
244
+ lookBy(dYaw, dPitch) {
245
+ finite("lookBy yaw", dYaw)
246
+ finite("lookBy pitch", dPitch)
247
+ look(dYaw, dPitch)
248
+ push()
249
+ },
250
+ moveBy(right, ahead, up = 0) {
251
+ finite("moveBy right", right)
252
+ finite("moveBy forward", ahead)
253
+ finite("moveBy up", up)
254
+ step(right, ahead, up)
255
+ push()
304
256
  },
305
257
  }
306
258
  }
@@ -15,7 +15,7 @@ import { createBuffer, destroyBuffer } from "@solidrt/core/gpu"
15
15
  import type { BufferId, IndexFormat } from "@solidrt/core/gpu"
16
16
  import { createShape, destroyShape } from "flux:spatial"
17
17
  import type { ShapeId } from "flux:spatial"
18
- import { layoutStride } from "./geometry.ts"
18
+ import { geometryTopology, layoutStride } from "./geometry.ts"
19
19
  import type { Geometry } from "./geometry.ts"
20
20
 
21
21
  /** An acquired reference to a geometry's GPU buffers: what a draw entry
@@ -26,14 +26,45 @@ export type GeometryBuffers = {
26
26
  buffer: BufferId
27
27
  index: BufferId
28
28
  indexFormat: IndexFormat
29
- /** The picking shape (positions at 0, uv at 6 of every layout). */
30
- shape: ShapeId
29
+ /** The picking shape (positions at 0, uv at 6 of every layout); null
30
+ * for anything but a triangle list, which picks by its box. */
31
+ shape: ShapeId | null
31
32
  }
32
33
 
33
- type GpuEntry = GeometryBuffers & { geometry: Geometry; refs: number }
34
+ type GpuEntry = GeometryBuffers & { geometry: Geometry; vertices: Float32Array; refs: number }
34
35
 
35
36
  let entries = new WeakMap<Geometry, GpuEntry>()
36
37
 
38
+ /** Vertex uploads keyed by the array itself: geometries sharing one
39
+ * vertex array (a wireframe or edges geometry over its source's) share
40
+ * one GPU buffer, each entry holding a reference to it. */
41
+ let vertexUploads = new WeakMap<Float32Array, { buffer: BufferId; refs: number }>()
42
+
43
+ function acquireVertices(geometry: Geometry): BufferId {
44
+ let upload = vertexUploads.get(geometry.vertices)
45
+ if (upload === undefined) {
46
+ upload = {
47
+ buffer: createBuffer(geometry.vertices, {
48
+ autoFree: false,
49
+ label: geometry.label ? geometry.label + "-verts" : undefined,
50
+ }),
51
+ refs: 0,
52
+ }
53
+ vertexUploads.set(geometry.vertices, upload)
54
+ }
55
+ upload.refs++
56
+ return upload.buffer
57
+ }
58
+
59
+ function releaseVertices(vertices: Float32Array): void {
60
+ let upload = vertexUploads.get(vertices)
61
+ if (upload === undefined) return
62
+ upload.refs--
63
+ if (upload.refs > 0) return
64
+ vertexUploads.delete(vertices)
65
+ destroyBuffer(upload.buffer)
66
+ }
67
+
37
68
  /** The geometry's GPU buffers, created on first use, plus the index format
38
69
  * the draw entry must bind them with. Takes a reference - pair every
39
70
  * acquire with a releaseGeometryBuffers of the returned token when the
@@ -43,16 +74,17 @@ export function acquireGeometryBuffers(geometry: Geometry): GeometryBuffers {
43
74
  if (entry === undefined) {
44
75
  entry = {
45
76
  geometry,
46
- buffer: createBuffer(geometry.vertices, {
47
- autoFree: false,
48
- label: geometry.label ? geometry.label + "-verts" : undefined,
49
- }),
77
+ vertices: geometry.vertices,
78
+ buffer: acquireVertices(geometry),
50
79
  index: createBuffer(geometry.indices, {
51
80
  autoFree: false,
52
81
  label: geometry.label ? geometry.label + "-indices" : undefined,
53
82
  }),
54
83
  indexFormat: geometry.indices instanceof Uint32Array ? "uint32" : "uint16",
55
- shape: createShape(geometry.vertices, layoutStride(geometry.layout), 0, 6, geometry.indices),
84
+ shape:
85
+ geometryTopology(geometry) === "triangles"
86
+ ? createShape(geometry.vertices, layoutStride(geometry.layout), 0, 6, geometry.indices)
87
+ : null,
56
88
  refs: 0,
57
89
  }
58
90
  entries.set(geometry, entry)
@@ -74,9 +106,9 @@ export function releaseGeometryBuffers(acquired: GeometryBuffers): void {
74
106
  queueMicrotask(() => {
75
107
  if (entries.get(entry.geometry) !== entry || entry.refs > 0) return
76
108
  entries.delete(entry.geometry)
77
- destroyBuffer(entry.buffer)
109
+ releaseVertices(entry.vertices)
78
110
  destroyBuffer(entry.index)
79
- destroyShape(entry.shape)
111
+ if (entry.shape !== null) destroyShape(entry.shape)
80
112
  })
81
113
  }
82
114
 
@@ -91,7 +123,7 @@ export function disposeGeometry(geometry: Geometry): void {
91
123
  let entry = entries.get(geometry)
92
124
  if (entry === undefined) return
93
125
  entries.delete(geometry)
94
- destroyBuffer(entry.buffer)
126
+ releaseVertices(entry.vertices)
95
127
  destroyBuffer(entry.index)
96
- destroyShape(entry.shape)
128
+ if (entry.shape !== null) destroyShape(entry.shape)
97
129
  }