@solidrt/3d 0.0.46 → 0.0.47
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 +145 -35
- package/README.md +22 -5
- package/examples/scene-post-effect.tsx +82 -0
- package/package.json +4 -3
- package/src/components.tsx +20 -4
- package/src/geometry.ts +292 -37
- package/src/glsl.ts +112 -0
- package/src/index.ts +6 -4
- package/src/material.ts +43 -6
- package/src/math.ts +40 -0
- package/src/orbit.ts +187 -27
- package/src/profile.ts +520 -0
- package/src/scene.ts +91 -37
package/src/math.ts
CHANGED
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
// interpreter. Mat4 is a 16-tuple so constant-index access stays plain
|
|
10
10
|
// `number` under noUncheckedIndexedAccess.
|
|
11
11
|
|
|
12
|
+
export type Vec2 = [number, number]
|
|
12
13
|
export type Vec3 = [number, number, number]
|
|
14
|
+
export type Vec4 = [number, number, number, number]
|
|
13
15
|
// prettier-ignore
|
|
14
16
|
export type Mat4 = [
|
|
15
17
|
number, number, number, number,
|
|
@@ -39,6 +41,20 @@ export function copy(out: Mat4, m: Mat4): Mat4 {
|
|
|
39
41
|
return out
|
|
40
42
|
}
|
|
41
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Transform a point by m with w = 1, keeping the homogeneous result: the
|
|
46
|
+
* clip-space building block (scene.project, picking). The caller owns the
|
|
47
|
+
* perspective divide and the w <= 0 behind-the-camera test.
|
|
48
|
+
*/
|
|
49
|
+
export function transformPoint(out: Vec4, m: Mat4, p: Vec3): Vec4 {
|
|
50
|
+
let x = p[0], y = p[1], z = p[2]
|
|
51
|
+
out[0] = m[0] * x + m[4] * y + m[8] * z + m[12]
|
|
52
|
+
out[1] = m[1] * x + m[5] * y + m[9] * z + m[13]
|
|
53
|
+
out[2] = m[2] * x + m[6] * y + m[10] * z + m[14]
|
|
54
|
+
out[3] = m[3] * x + m[7] * y + m[11] * z + m[15]
|
|
55
|
+
return out
|
|
56
|
+
}
|
|
57
|
+
|
|
42
58
|
/** out = a * b (column vectors: b applies first). out may alias a or b. */
|
|
43
59
|
export function multiply(out: Mat4, a: Mat4, b: Mat4): Mat4 {
|
|
44
60
|
let a00 = a[0], a01 = a[1], a02 = a[2], a03 = a[3]
|
|
@@ -93,6 +109,30 @@ export function compose(out: Mat4, position: Vec3, rotation: Vec3, scale: Vec3):
|
|
|
93
109
|
return out
|
|
94
110
|
}
|
|
95
111
|
|
|
112
|
+
/**
|
|
113
|
+
* The normal matrix for a world matrix: the inverse-transpose of its upper
|
|
114
|
+
* 3x3 (the cofactor matrix over the determinant), packed into a mat4 - the
|
|
115
|
+
* engine's settable uniform set has no mat3, so shaders take `mat3(uNormal)`.
|
|
116
|
+
* Correct under any transform including non-uniform scale, where
|
|
117
|
+
* `mat3(uModel)` would bend normals off the surface. A degenerate
|
|
118
|
+
* (zero-scale) input yields the raw cofactors instead of NaNs.
|
|
119
|
+
*/
|
|
120
|
+
export function normalMatrix(out: Mat4, m: Mat4): Mat4 {
|
|
121
|
+
let a = m[0], b = m[4], c = m[8]
|
|
122
|
+
let d = m[1], e = m[5], f = m[9]
|
|
123
|
+
let g = m[2], h = m[6], i = m[10]
|
|
124
|
+
let c00 = e * i - f * h
|
|
125
|
+
let c01 = f * g - d * i
|
|
126
|
+
let c02 = d * h - e * g
|
|
127
|
+
let det = a * c00 + b * c01 + c * c02
|
|
128
|
+
let s = 1 / (det || 1)
|
|
129
|
+
out[0] = c00 * s; out[1] = (c * h - b * i) * s; out[2] = (b * f - c * e) * s; out[3] = 0
|
|
130
|
+
out[4] = c01 * s; out[5] = (a * i - c * g) * s; out[6] = (c * d - a * f) * s; out[7] = 0
|
|
131
|
+
out[8] = c02 * s; out[9] = (b * g - a * h) * s; out[10] = (a * e - b * d) * s; out[11] = 0
|
|
132
|
+
out[12] = 0; out[13] = 0; out[14] = 0; out[15] = 1
|
|
133
|
+
return out
|
|
134
|
+
}
|
|
135
|
+
|
|
96
136
|
/**
|
|
97
137
|
* Right-handed perspective projection with the engine's y-down clip flip
|
|
98
138
|
* BAKED IN (row two is negated): geometry authored y-up displays y-up, and
|
package/src/orbit.ts
CHANGED
|
@@ -1,6 +1,38 @@
|
|
|
1
1
|
// An orbit camera for a scene: azimuth/elevation/distance around a target,
|
|
2
|
-
// with drag-to-rotate, wheel-to-zoom, optional auto-orbit, and
|
|
3
|
-
// standard interactive-viewer camera, extracted so apps stop
|
|
2
|
+
// with drag-to-rotate, pinch- and wheel-to-zoom, optional auto-orbit, and
|
|
3
|
+
// clamps - the standard interactive-viewer camera, extracted so apps stop
|
|
4
|
+
// rebuilding it.
|
|
5
|
+
//
|
|
6
|
+
// Input goes through core's merged transform recognizer (createTransform), so
|
|
7
|
+
// the drag participates in the gesture arena: on a viewport embedded in a
|
|
8
|
+
// scrollable layout, an orbit drag and the ancestor scroller's pan arbitrate
|
|
9
|
+
// instead of both acting, and rotation only starts once the drag crosses the
|
|
10
|
+
// recognizer's slop. One finger (or a mouse drag) rotates; two-finger pinch
|
|
11
|
+
// zooms (pair rotation is ignored); wheel zooms directly - no recognizer
|
|
12
|
+
// needed for a discrete wheel step. What two-finger translation means
|
|
13
|
+
// depends on `viewport`: with it, two fingers pan - the scene slides under
|
|
14
|
+
// the fingers 1:1 at the target's depth, the touch convention everywhere
|
|
15
|
+
// (three.js DOLLY_PAN, Sketchfab, touch CAD) - which also keeps an
|
|
16
|
+
// imperfect pinch from smearing rotation into the zoom. Without it there
|
|
17
|
+
// is no pixel-to-world mapping, so focal translation falls back to
|
|
18
|
+
// rotating regardless of finger count.
|
|
19
|
+
//
|
|
20
|
+
// Zoom aims at the target by default. With `zoomAnchor` the app maps the
|
|
21
|
+
// pinch focal / wheel cursor to a world point, and the zoom scales the pose
|
|
22
|
+
// about that point instead - the spot under the fingers stays under the
|
|
23
|
+
// fingers, with the target sliding toward it. Only the app can own that
|
|
24
|
+
// mapping: screen-to-ray needs the projection and the element's placement
|
|
25
|
+
// (fov, aspect, viewBox scaling), which live in the app's camera and layout,
|
|
26
|
+
// not here.
|
|
27
|
+
//
|
|
28
|
+
// Anchored zoom leaves the target wherever the zoom carried it - possibly a
|
|
29
|
+
// point in empty air near the eye, and a drag orbiting THAT swings the scene
|
|
30
|
+
// wildly around nothing. `rotateAnchor` is the countermeasure: at gesture
|
|
31
|
+
// start the app names the world point rotation should pivot about (what the
|
|
32
|
+
// camera is actually looking at), and the pivot is re-seated there. The
|
|
33
|
+
// point is projected onto the view axis first, so re-seating moves only the
|
|
34
|
+
// target's depth - the eye and the picture do not change, only what a drag
|
|
35
|
+
// swings around.
|
|
4
36
|
//
|
|
5
37
|
// Pose is plain mutable state advanced by update(dt) from the app's own
|
|
6
38
|
// onFrame; the control registers no frame loop of its own, so whether
|
|
@@ -8,11 +40,14 @@
|
|
|
8
40
|
// animation costs nothing). Only `orbiting` is a signal - slow UI state
|
|
9
41
|
// that HUDs read - while the pose moves at frame rate and bypasses
|
|
10
42
|
// reactivity: the package's structure-vs-motion split. update() pushes the
|
|
11
|
-
// pose to the scene camera only when it actually changed
|
|
12
|
-
//
|
|
13
|
-
//
|
|
43
|
+
// pose to the scene camera only when it actually changed (one setCamera:
|
|
44
|
+
// the scene's own shared write carries uViewProj and uCamPos), and reports
|
|
45
|
+
// that, so per-frame dependents (reprojecting HUD overlays via
|
|
46
|
+
// scene.project) can follow the camera without recomputing every frame.
|
|
14
47
|
|
|
15
48
|
import { createSignal } from "@solidjs/signals"
|
|
49
|
+
import { createTransform } from "@solidrt/core"
|
|
50
|
+
import type { PointerEvent } from "@solidrt/core"
|
|
16
51
|
import type { Scene } from "./scene.ts"
|
|
17
52
|
import type { Vec3 } from "./math.ts"
|
|
18
53
|
|
|
@@ -35,11 +70,38 @@ export type OrbitCameraOptions = {
|
|
|
35
70
|
minElevation?: number
|
|
36
71
|
maxElevation?: number
|
|
37
72
|
/** Auto-orbit rate in radians/second (default 0: none). Runs while
|
|
38
|
-
* `orbiting()` and
|
|
73
|
+
* `orbiting()` and no drag/pinch is in progress; toggle with
|
|
74
|
+
* set({ orbiting }). */
|
|
39
75
|
orbitSpeed?: number
|
|
40
|
-
/** Multipliers over the built-in drag
|
|
76
|
+
/** Multipliers over the built-in drag and zoom (wheel + pinch) sensitivities. */
|
|
41
77
|
rotateSpeed?: number
|
|
42
78
|
zoomSpeed?: number
|
|
79
|
+
/** Multiplier over the two-finger pan (1 = the scene tracks the fingers
|
|
80
|
+
* exactly at the target's depth). */
|
|
81
|
+
panSpeed?: number
|
|
82
|
+
/** The viewport the pointer coordinates live in: height in the same
|
|
83
|
+
* logical units as clientX/clientY, and the camera's vertical fov in
|
|
84
|
+
* degrees. Providing it enables two-finger pan; without it two-finger
|
|
85
|
+
* translation rotates, as it always did. */
|
|
86
|
+
viewport?: () => { height: number; fov: number }
|
|
87
|
+
/** Constrain where a pan may put the target - return the target to use.
|
|
88
|
+
* The typical use: keep the pivot within a few radii of the subject so
|
|
89
|
+
* panning cannot strand the camera. Zoom and rotation do not consult it. */
|
|
90
|
+
clampTarget?: (target: Vec3) => Vec3
|
|
91
|
+
/** Map a screen point (window coordinates, the clientX/clientY frame) to
|
|
92
|
+
* the world point a zoom there should keep pinned. Called once per pinch
|
|
93
|
+
* gesture (at its first span change - the anchor then holds for the whole
|
|
94
|
+
* gesture, see the jitter note at `pinchAnchor`) and once per wheel event,
|
|
95
|
+
* with the pose the zoom is about to apply to. Return null to zoom toward
|
|
96
|
+
* the target as usual (also the default). */
|
|
97
|
+
zoomAnchor?: (x: number, y: number, view: { eye: Vec3; target: Vec3 }) => Vec3 | null
|
|
98
|
+
/** The world point rotation should pivot about - called when a drag or
|
|
99
|
+
* pinch starts. It is projected onto the view axis and becomes the new
|
|
100
|
+
* target, preserving the picture exactly (only the pivot's depth moves),
|
|
101
|
+
* so a drag after an anchored zoom orbits the scene under the camera
|
|
102
|
+
* instead of wherever the zoom left the target. Points at or behind the
|
|
103
|
+
* eye are ignored, as is null (both keep the current pivot). */
|
|
104
|
+
rotateAnchor?: (view: { eye: Vec3; target: Vec3 }) => Vec3 | null
|
|
43
105
|
}
|
|
44
106
|
|
|
45
107
|
export type OrbitPose = {
|
|
@@ -68,10 +130,12 @@ export type OrbitCamera = {
|
|
|
68
130
|
/** Spread onto the element that receives input:
|
|
69
131
|
* `<window {...orbit.handlers} />`. */
|
|
70
132
|
handlers: {
|
|
71
|
-
onPointerDown(e:
|
|
72
|
-
onPointerMove(e:
|
|
73
|
-
onPointerUp(): void
|
|
74
|
-
|
|
133
|
+
onPointerDown(e: PointerEvent): void
|
|
134
|
+
onPointerMove(e: PointerEvent): void
|
|
135
|
+
onPointerUp(e: PointerEvent): void
|
|
136
|
+
/** Position is optional so a bare `{ deltaY }` still zooms; without it
|
|
137
|
+
* a zoomAnchor cannot apply and the zoom falls back to the target. */
|
|
138
|
+
onWheel(e: { deltaY: number; clientX?: number; clientY?: number }): void
|
|
75
139
|
}
|
|
76
140
|
}
|
|
77
141
|
|
|
@@ -98,13 +162,33 @@ export function createOrbitCamera(scene: Scene, options: OrbitCameraOptions = {}
|
|
|
98
162
|
let wheelZoom = WHEEL_ZOOM * (options.zoomSpeed ?? 1)
|
|
99
163
|
|
|
100
164
|
let [orbiting, setOrbiting] = createSignal(orbitSpeed > 0)
|
|
101
|
-
let
|
|
165
|
+
let interacting = false
|
|
102
166
|
let dirty = false
|
|
103
167
|
|
|
104
168
|
let clampPose = () => {
|
|
105
169
|
elevation = clampNum(elevation, minElevation, maxElevation)
|
|
106
170
|
distance = clampNum(distance, minDistance, maxDistance)
|
|
107
171
|
}
|
|
172
|
+
// Slide eye and target together along the camera's right/up so the scene
|
|
173
|
+
// tracks the fingers: dragged pixels map to world units through the
|
|
174
|
+
// frustum height at the target's depth. Screen +y is down, so the up-axis
|
|
175
|
+
// term is added (fingers down -> camera up -> scene follows down).
|
|
176
|
+
let pan = (dx: number, dy: number) => {
|
|
177
|
+
let vp = options.viewport!()
|
|
178
|
+
let wpp = ((2 * Math.tan((vp.fov * Math.PI) / 360) * distance) / vp.height) * (options.panSpeed ?? 1)
|
|
179
|
+
let sa = Math.sin(azimuth)
|
|
180
|
+
let ca = Math.cos(azimuth)
|
|
181
|
+
let se = Math.sin(elevation)
|
|
182
|
+
let ce = Math.cos(elevation)
|
|
183
|
+
// right = (ca, 0, -sa), up = (-sa*se, ce, -ca*se) for this pose - the
|
|
184
|
+
// camera basis written out for eye() = target + distance * (ce*sa, se, ce*ca).
|
|
185
|
+
let next: Vec3 = [
|
|
186
|
+
target[0] - dx * wpp * ca - dy * wpp * sa * se,
|
|
187
|
+
target[1] + dy * wpp * ce,
|
|
188
|
+
target[2] + dx * wpp * sa - dy * wpp * ca * se,
|
|
189
|
+
]
|
|
190
|
+
target = options.clampTarget ? options.clampTarget(next) : next
|
|
191
|
+
}
|
|
108
192
|
let eye = (): Vec3 => {
|
|
109
193
|
let ce = Math.cos(elevation)
|
|
110
194
|
return [
|
|
@@ -115,9 +199,97 @@ export function createOrbitCamera(scene: Scene, options: OrbitCameraOptions = {}
|
|
|
115
199
|
}
|
|
116
200
|
let apply = () => scene.setCamera({ position: eye(), target })
|
|
117
201
|
|
|
202
|
+
// Zoom by `ratio` (new distance over old, before clamping) about a world
|
|
203
|
+
// anchor (null zooms toward the target). Scaling eye and target about the
|
|
204
|
+
// anchor by the distance ratio keeps it projecting to the same pixel, so
|
|
205
|
+
// the shift below uses the ratio that actually applied after clamping -
|
|
206
|
+
// once distance pins at a clamp the target stops moving too, instead of
|
|
207
|
+
// sliding the view sideways under a dead zoom.
|
|
208
|
+
let zoomAbout = (ratio: number, anchor: Vec3 | null | undefined) => {
|
|
209
|
+
let prev = distance
|
|
210
|
+
// Bounds widen to the current distance so a pose already outside them
|
|
211
|
+
// (a rotateAnchor re-seat may land anywhere) zooms back toward range
|
|
212
|
+
// instead of snap-jumping into it.
|
|
213
|
+
distance = clampNum(distance * ratio, Math.min(minDistance, prev), Math.max(maxDistance, prev))
|
|
214
|
+
if (!anchor) return
|
|
215
|
+
let s = distance / prev
|
|
216
|
+
target = [
|
|
217
|
+
anchor[0] + (target[0] - anchor[0]) * s,
|
|
218
|
+
anchor[1] + (target[1] - anchor[1]) * s,
|
|
219
|
+
anchor[2] + (target[2] - anchor[2]) * s,
|
|
220
|
+
]
|
|
221
|
+
}
|
|
222
|
+
let anchorAt = (x?: number, y?: number) =>
|
|
223
|
+
x !== undefined && y !== undefined && options.zoomAnchor
|
|
224
|
+
? options.zoomAnchor(x, y, { eye: eye(), target: [target[0], target[1], target[2]] })
|
|
225
|
+
: null
|
|
226
|
+
|
|
227
|
+
// The pinch keeps ONE anchor for its whole gesture, taken at the first
|
|
228
|
+
// span change. Per-event re-derivation looks equivalent but jitters in
|
|
229
|
+
// practice: the two fingers' events interleave, so the measured span
|
|
230
|
+
// oscillates around its true value even while the fingers rest, and every
|
|
231
|
+
// event zooms slightly in or back out. About a fixed point those pairs
|
|
232
|
+
// cancel exactly (the ratios telescope); about a fresh anchor each time -
|
|
233
|
+
// a new raycast against a pose the previous event just moved, or the
|
|
234
|
+
// app's hit/fallback choice flipping between events - each pair leaves a
|
|
235
|
+
// residual target slide and the model visibly crawls under resting
|
|
236
|
+
// fingers. The wheel stays per-event: its notches are discrete, there is
|
|
237
|
+
// no noise to cancel, and each notch re-aiming at the cursor is the point.
|
|
238
|
+
let pinchAnchor: Vec3 | null = null
|
|
239
|
+
let pinchSeen = false
|
|
240
|
+
|
|
118
241
|
clampPose()
|
|
119
242
|
apply()
|
|
120
243
|
|
|
244
|
+
// One finger rotates. With two down, the span change zooms and the focal
|
|
245
|
+
// translation pans (or, with no viewport to map pixels through, keeps
|
|
246
|
+
// rotating). Pair rotation has no orbit meaning and is ignored.
|
|
247
|
+
let transform = createTransform({
|
|
248
|
+
onTransformStart: () => {
|
|
249
|
+
interacting = true
|
|
250
|
+
let anchor = options.rotateAnchor?.({ eye: eye(), target: [target[0], target[1], target[2]] })
|
|
251
|
+
if (anchor) {
|
|
252
|
+
let e = eye()
|
|
253
|
+
let fx = (target[0] - e[0]) / distance
|
|
254
|
+
let fy = (target[1] - e[1]) / distance
|
|
255
|
+
let fz = (target[2] - e[2]) / distance
|
|
256
|
+
let depth = (anchor[0] - e[0]) * fx + (anchor[1] - e[1]) * fy + (anchor[2] - e[2]) * fz
|
|
257
|
+
if (depth > 0) {
|
|
258
|
+
// Deliberately unclamped: the pose is unchanged, this only decides
|
|
259
|
+
// what the gesture pivots about. The zoom clamps below widen to
|
|
260
|
+
// the current distance, so a pivot outside [min, max] cannot make
|
|
261
|
+
// the next zoom snap-jump either.
|
|
262
|
+
distance = depth
|
|
263
|
+
target = [e[0] + fx * depth, e[1] + fy * depth, e[2] + fz * depth]
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
onTransformMove: (t) => {
|
|
268
|
+
if (t.pointers >= 2 && options.viewport) {
|
|
269
|
+
pan(t.dx, t.dy)
|
|
270
|
+
} else {
|
|
271
|
+
azimuth -= t.dx * dragAzimuth
|
|
272
|
+
elevation = clampNum(elevation + t.dy * dragElevation, minElevation, maxElevation)
|
|
273
|
+
}
|
|
274
|
+
if (t.scale !== 1) {
|
|
275
|
+
// Fingers spreading (scale > 1) zooms in: the distance shrinks by the
|
|
276
|
+
// span ratio, exponent-weighted by the zoomSpeed multiplier, about
|
|
277
|
+
// the gesture's anchor when the app provides one.
|
|
278
|
+
if (!pinchSeen) {
|
|
279
|
+
pinchSeen = true
|
|
280
|
+
pinchAnchor = anchorAt(t.x, t.y)
|
|
281
|
+
}
|
|
282
|
+
zoomAbout(1 / Math.pow(t.scale, options.zoomSpeed ?? 1), pinchAnchor)
|
|
283
|
+
}
|
|
284
|
+
dirty = true
|
|
285
|
+
},
|
|
286
|
+
onTransformEnd: () => {
|
|
287
|
+
interacting = false
|
|
288
|
+
pinchSeen = false
|
|
289
|
+
pinchAnchor = null
|
|
290
|
+
},
|
|
291
|
+
})
|
|
292
|
+
|
|
121
293
|
return {
|
|
122
294
|
eye,
|
|
123
295
|
pose: () => ({ azimuth, elevation, distance }),
|
|
@@ -132,7 +304,7 @@ export function createOrbitCamera(scene: Scene, options: OrbitCameraOptions = {}
|
|
|
132
304
|
dirty = true
|
|
133
305
|
},
|
|
134
306
|
update(dt) {
|
|
135
|
-
if (orbitSpeed !== 0 &&
|
|
307
|
+
if (orbitSpeed !== 0 && !interacting && orbiting()) {
|
|
136
308
|
azimuth += dt * orbitSpeed
|
|
137
309
|
dirty = true
|
|
138
310
|
}
|
|
@@ -142,21 +314,9 @@ export function createOrbitCamera(scene: Scene, options: OrbitCameraOptions = {}
|
|
|
142
314
|
return true
|
|
143
315
|
},
|
|
144
316
|
handlers: {
|
|
145
|
-
|
|
146
|
-
drag = { x: e.clientX, y: e.clientY }
|
|
147
|
-
},
|
|
148
|
-
onPointerMove(e) {
|
|
149
|
-
if (!drag) return
|
|
150
|
-
azimuth -= (e.clientX - drag.x) * dragAzimuth
|
|
151
|
-
elevation = clampNum(elevation + (e.clientY - drag.y) * dragElevation, minElevation, maxElevation)
|
|
152
|
-
drag = { x: e.clientX, y: e.clientY }
|
|
153
|
-
dirty = true
|
|
154
|
-
},
|
|
155
|
-
onPointerUp() {
|
|
156
|
-
drag = null
|
|
157
|
-
},
|
|
317
|
+
...transform.handlers,
|
|
158
318
|
onWheel(e) {
|
|
159
|
-
|
|
319
|
+
zoomAbout(Math.exp(e.deltaY * wheelZoom), anchorAt(e.clientX, e.clientY))
|
|
160
320
|
dirty = true
|
|
161
321
|
},
|
|
162
322
|
},
|