@solidrt/3d 0.0.46

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.
@@ -0,0 +1,209 @@
1
+ // Materials pair GLSL with pipeline state, deduped hard: one program and
2
+ // one render pipeline per material CLASS (unlit color, unlit textured),
3
+ // created lazily at first use and kept for the app's lifetime. A material
4
+ // INSTANCE is just the per-entry uniform values (and sampler bindings) it
5
+ // contributes when a mesh becomes a draw entry - so a thousand meshes with
6
+ // a thousand colors still share one pipeline.
7
+ //
8
+ // Colors are straight [r, g, b, a?] 0..1 at the API and premultiplied here
9
+ // once, at the boundary (the engine's pixel contract). An alpha below 1
10
+ // does NOT blend yet: v1 pipelines draw opaque (blend "none"), so a
11
+ // translucent color overwrites what is behind it. Transparency arrives
12
+ // with the blend-factor vocabulary and back-to-front sorting (see
13
+ // okf/research/scene-graph-3d.md, staging step 4).
14
+ //
15
+ // Custom looks need no material system: the raw layer (compileShader /
16
+ // createRenderPipeline in @solidrt/core/gpu) is first-class, and a scene
17
+ // draws into an ordinary draw target - a custom-shaded mesh is a future
18
+ // material class here, or the app's own addDraw beside the scene's.
19
+
20
+ import {
21
+ compileShader,
22
+ createRenderPipeline,
23
+ destroyProgram,
24
+ destroyRenderPipeline,
25
+ destroyShader,
26
+ glsl,
27
+ linkProgram,
28
+ } from "@solidrt/core/gpu"
29
+ import type {
30
+ BlendMode,
31
+ CullMode,
32
+ ProgramId,
33
+ RenderPipelineId,
34
+ ShaderParams,
35
+ ShaderStageId,
36
+ TextureId,
37
+ Topology,
38
+ } from "@solidrt/core/gpu"
39
+ import { VERTEX_LAYOUT } from "./geometry.ts"
40
+
41
+ export type Material = {
42
+ /** The pipeline this material draws with (lazily created). */
43
+ pipeline(): RenderPipelineId
44
+ /** Per-entry uniform values this material contributes at addDraw. */
45
+ params: ShaderParams
46
+ /** Per-entry sampler bindings, when the material samples textures. */
47
+ textures?: Record<string, TextureId>
48
+ /** Present on materials that own their pipeline (shaderMaterial). */
49
+ dispose?(): void
50
+ }
51
+
52
+ // One vertex stage serves every unlit class: model then view-projection,
53
+ // plus the UV varying. uModel is per-entry (the scene writes it when the
54
+ // mesh moves), uViewProj is target-shared (one write per camera move) - the
55
+ // split is what keeps camera motion O(1) instead of O(meshes), and the
56
+ // extra per-vertex mat4 multiply is free on the GPU. aNormal from the
57
+ // shared layout is deliberately not declared - inactive attributes are
58
+ // skipped and only the stride accounts for them.
59
+ const VERTEX_SRC = glsl`
60
+ in vec3 aPos;
61
+ in vec2 aUV;
62
+ out vec2 vUv;
63
+ uniform mat4 uModel;
64
+ uniform mat4 uViewProj;
65
+
66
+ void main() {
67
+ gl_Position = uViewProj * uModel * vec4(aPos, 1.0);
68
+ vUv = aUV;
69
+ }
70
+ `
71
+
72
+ const FRAGMENT_COLOR_SRC = glsl`
73
+ uniform vec4 uColor;
74
+ void main() {
75
+ fragColor = uColor;
76
+ }
77
+ `
78
+
79
+ const FRAGMENT_MAP_SRC = glsl`
80
+ in vec2 vUv;
81
+ uniform sampler2D uMap;
82
+ uniform vec4 uColor;
83
+ void main() {
84
+ fragColor = texture(uMap, vUv) * uColor;
85
+ }
86
+ `
87
+
88
+ let sharedVertex: ShaderStageId | undefined
89
+ let pipelines: { color?: RenderPipelineId; map?: RenderPipelineId } = {}
90
+
91
+ function pipelineFor(kind: "color" | "map"): RenderPipelineId {
92
+ let existing = pipelines[kind]
93
+ if (existing !== undefined) return existing
94
+ if (sharedVertex === undefined) sharedVertex = compileShader("vertex", VERTEX_SRC, { header: true })
95
+ let fragment = compileShader("fragment", kind === "color" ? FRAGMENT_COLOR_SRC : FRAGMENT_MAP_SRC, {
96
+ header: true,
97
+ })
98
+ let program = linkProgram(sharedVertex, fragment, { label: "scene-unlit-" + kind })
99
+ let pipeline = createRenderPipeline(program, {
100
+ attributes: VERTEX_LAYOUT,
101
+ depth: true,
102
+ cull: "back",
103
+ label: "scene-unlit-" + kind,
104
+ })
105
+ pipelines[kind] = pipeline
106
+ return pipeline
107
+ }
108
+
109
+ export type UnlitOptions = {
110
+ /** Straight [r, g, b] or [r, g, b, a], 0..1. Default white. */
111
+ color?: [number, number, number] | [number, number, number, number]
112
+ /** A texture id to sample (tinted by `color` when both are given). */
113
+ map?: TextureId
114
+ }
115
+
116
+ /**
117
+ * An unlit material: flat color, textured when `map` is given. Unlit is
118
+ * the complete v1 set - lit materials arrive with uniform arrays (the
119
+ * light list); see the scene-graph research note.
120
+ */
121
+ export function unlit(opts: UnlitOptions = {}): Material {
122
+ let color = opts.color ?? [1, 1, 1]
123
+ let a = color.length === 4 ? color[3] : 1
124
+ let uColor = [color[0] * a, color[1] * a, color[2] * a, a]
125
+ if (opts.map !== undefined) {
126
+ return { pipeline: () => pipelineFor("map"), params: { uColor }, textures: { uMap: opts.map } }
127
+ }
128
+ return { pipeline: () => pipelineFor("color"), params: { uColor } }
129
+ }
130
+
131
+ // Mirrors the engine's own preamble rule: a source carrying its own
132
+ // #version line is compiled exactly as written.
133
+ function needsHeader(source: string): boolean {
134
+ return !source.trimStart().startsWith("#version")
135
+ }
136
+
137
+ export type ShaderMaterialOptions = {
138
+ /**
139
+ * Vertex stage GLSL. MUST declare and use `uniform mat4 uModel` (the
140
+ * mesh's world matrix, written per entry whenever the mesh moves) and
141
+ * `uniform mat4 uViewProj` (the camera's view-projection, shared by the
142
+ * whole scene target and written once per camera move) - transform with
143
+ * `uViewProj * uModel * vec4(aPos, 1.0)`. Declare any of the shared
144
+ * layout's `in` attributes (aPos vec3, aNormal vec3, aUV vec2);
145
+ * undeclared ones are skipped.
146
+ */
147
+ vertex: string
148
+ fragment: string
149
+ /** Uniform seeds beyond uModel/uViewProj; update per mesh later with
150
+ * setMeshParams. */
151
+ params?: ShaderParams
152
+ textures?: Record<string, TextureId>
153
+ /** Pipeline state; defaults match unlit: depth: true, cull: "back". */
154
+ depth?: boolean
155
+ depthWrite?: boolean
156
+ blend?: BlendMode
157
+ cull?: CullMode
158
+ topology?: Topology
159
+ label?: string
160
+ }
161
+
162
+ /**
163
+ * A material from your own GLSL: the custom-look escape hatch, first-class
164
+ * next to unlit. Sources without a `#version` line get the standard
165
+ * pipeline preamble (`fragColor`, `iResolution`).
166
+ *
167
+ * The INSTANCE is the pipeline handle: two calls with identical sources
168
+ * compile two pipelines - there is no dedupe by source value (a hidden
169
+ * cache keyed by content is the anti-pattern the GPU layer avoids
170
+ * throughout). Create one per look at app scope, share it across meshes,
171
+ * and `dispose()` it if the app is done with the look for good.
172
+ */
173
+ export function shaderMaterial(opts: ShaderMaterialOptions): Material {
174
+ let program: ProgramId | undefined
175
+ let pipeline: RenderPipelineId | undefined
176
+ return {
177
+ pipeline() {
178
+ if (pipeline === undefined) {
179
+ let vs = compileShader("vertex", opts.vertex, { header: needsHeader(opts.vertex) })
180
+ let fs = compileShader("fragment", opts.fragment, { header: needsHeader(opts.fragment) })
181
+ program = linkProgram(vs, fs, { label: opts.label })
182
+ destroyShader(vs)
183
+ destroyShader(fs)
184
+ pipeline = createRenderPipeline(program, {
185
+ attributes: VERTEX_LAYOUT,
186
+ depth: opts.depth ?? true,
187
+ depthWrite: opts.depthWrite,
188
+ blend: opts.blend,
189
+ cull: opts.cull ?? "back",
190
+ topology: opts.topology,
191
+ label: opts.label,
192
+ })
193
+ }
194
+ return pipeline
195
+ },
196
+ params: opts.params ?? {},
197
+ textures: opts.textures,
198
+ dispose() {
199
+ if (pipeline !== undefined) {
200
+ destroyRenderPipeline(pipeline)
201
+ pipeline = undefined
202
+ }
203
+ if (program !== undefined) {
204
+ destroyProgram(program)
205
+ program = undefined
206
+ }
207
+ },
208
+ }
209
+ }
package/src/math.ts ADDED
@@ -0,0 +1,179 @@
1
+ // 3D math over plain number arrays: column-major mat4 (the layout mat4
2
+ // uniforms expect) in a right-handed, y-up world with the camera looking
3
+ // down -z. perspective() bakes the engine's y-down clip flip into the
4
+ // projection, so scene code never sees the flip and standard CCW-outward
5
+ // meshes cull correctly with cull: "back" (see the pixel contract in
6
+ // @solidrt/core/gpu). Every function writes into a caller-owned `out`
7
+ // matrix and allocates nothing: the hot path - a moved node is one compose,
8
+ // one or two multiplies, one param write - must stay allocation-free on an
9
+ // interpreter. Mat4 is a 16-tuple so constant-index access stays plain
10
+ // `number` under noUncheckedIndexedAccess.
11
+
12
+ export type Vec3 = [number, number, number]
13
+ // prettier-ignore
14
+ export type Mat4 = [
15
+ number, number, number, number,
16
+ number, number, number, number,
17
+ number, number, number, number,
18
+ number, number, number, number,
19
+ ]
20
+
21
+ /** A fresh identity matrix. */
22
+ export function mat4(): Mat4 {
23
+ return [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]
24
+ }
25
+
26
+ export function identity(out: Mat4): Mat4 {
27
+ out[0] = 1; out[1] = 0; out[2] = 0; out[3] = 0
28
+ out[4] = 0; out[5] = 1; out[6] = 0; out[7] = 0
29
+ out[8] = 0; out[9] = 0; out[10] = 1; out[11] = 0
30
+ out[12] = 0; out[13] = 0; out[14] = 0; out[15] = 1
31
+ return out
32
+ }
33
+
34
+ export function copy(out: Mat4, m: Mat4): Mat4 {
35
+ out[0] = m[0]; out[1] = m[1]; out[2] = m[2]; out[3] = m[3]
36
+ out[4] = m[4]; out[5] = m[5]; out[6] = m[6]; out[7] = m[7]
37
+ out[8] = m[8]; out[9] = m[9]; out[10] = m[10]; out[11] = m[11]
38
+ out[12] = m[12]; out[13] = m[13]; out[14] = m[14]; out[15] = m[15]
39
+ return out
40
+ }
41
+
42
+ /** out = a * b (column vectors: b applies first). out may alias a or b. */
43
+ export function multiply(out: Mat4, a: Mat4, b: Mat4): Mat4 {
44
+ let a00 = a[0], a01 = a[1], a02 = a[2], a03 = a[3]
45
+ let a10 = a[4], a11 = a[5], a12 = a[6], a13 = a[7]
46
+ let a20 = a[8], a21 = a[9], a22 = a[10], a23 = a[11]
47
+ let a30 = a[12], a31 = a[13], a32 = a[14], a33 = a[15]
48
+ let b0 = b[0], b1 = b[1], b2 = b[2], b3 = b[3]
49
+ out[0] = b0 * a00 + b1 * a10 + b2 * a20 + b3 * a30
50
+ out[1] = b0 * a01 + b1 * a11 + b2 * a21 + b3 * a31
51
+ out[2] = b0 * a02 + b1 * a12 + b2 * a22 + b3 * a32
52
+ out[3] = b0 * a03 + b1 * a13 + b2 * a23 + b3 * a33
53
+ b0 = b[4]; b1 = b[5]; b2 = b[6]; b3 = b[7]
54
+ out[4] = b0 * a00 + b1 * a10 + b2 * a20 + b3 * a30
55
+ out[5] = b0 * a01 + b1 * a11 + b2 * a21 + b3 * a31
56
+ out[6] = b0 * a02 + b1 * a12 + b2 * a22 + b3 * a32
57
+ out[7] = b0 * a03 + b1 * a13 + b2 * a23 + b3 * a33
58
+ b0 = b[8]; b1 = b[9]; b2 = b[10]; b3 = b[11]
59
+ out[8] = b0 * a00 + b1 * a10 + b2 * a20 + b3 * a30
60
+ out[9] = b0 * a01 + b1 * a11 + b2 * a21 + b3 * a31
61
+ out[10] = b0 * a02 + b1 * a12 + b2 * a22 + b3 * a32
62
+ out[11] = b0 * a03 + b1 * a13 + b2 * a23 + b3 * a33
63
+ b0 = b[12]; b1 = b[13]; b2 = b[14]; b3 = b[15]
64
+ out[12] = b0 * a00 + b1 * a10 + b2 * a20 + b3 * a30
65
+ out[13] = b0 * a01 + b1 * a11 + b2 * a21 + b3 * a31
66
+ out[14] = b0 * a02 + b1 * a12 + b2 * a22 + b3 * a32
67
+ out[15] = b0 * a03 + b1 * a13 + b2 * a23 + b3 * a33
68
+ return out
69
+ }
70
+
71
+ /**
72
+ * Compose translation + rotation + scale into a local matrix. Rotation is
73
+ * Euler angles in radians applied x, then y, then z (R = Rz * Ry * Rx on
74
+ * column vectors) - the common "XYZ" order.
75
+ */
76
+ export function compose(out: Mat4, position: Vec3, rotation: Vec3, scale: Vec3): Mat4 {
77
+ let cx = Math.cos(rotation[0]), sx = Math.sin(rotation[0])
78
+ let cy = Math.cos(rotation[1]), sy = Math.sin(rotation[1])
79
+ let cz = Math.cos(rotation[2]), sz = Math.sin(rotation[2])
80
+ let r00 = cz * cy
81
+ let r01 = cz * sy * sx - sz * cx
82
+ let r02 = cz * sy * cx + sz * sx
83
+ let r10 = sz * cy
84
+ let r11 = sz * sy * sx + cz * cx
85
+ let r12 = sz * sy * cx - cz * sx
86
+ let r20 = -sy
87
+ let r21 = cy * sx
88
+ let r22 = cy * cx
89
+ out[0] = r00 * scale[0]; out[1] = r10 * scale[0]; out[2] = r20 * scale[0]; out[3] = 0
90
+ out[4] = r01 * scale[1]; out[5] = r11 * scale[1]; out[6] = r21 * scale[1]; out[7] = 0
91
+ out[8] = r02 * scale[2]; out[9] = r12 * scale[2]; out[10] = r22 * scale[2]; out[11] = 0
92
+ out[12] = position[0]; out[13] = position[1]; out[14] = position[2]; out[15] = 1
93
+ return out
94
+ }
95
+
96
+ /**
97
+ * Right-handed perspective projection with the engine's y-down clip flip
98
+ * BAKED IN (row two is negated): geometry authored y-up displays y-up, and
99
+ * the flip this projection applies is exactly what makes displayed-CCW
100
+ * front faces line up with CCW-outward meshes, so cull: "back" works.
101
+ * `fovy` is the vertical field of view in RADIANS.
102
+ */
103
+ export function perspective(out: Mat4, fovy: number, aspect: number, near: number, far: number): Mat4 {
104
+ let f = 1 / Math.tan(fovy / 2)
105
+ let nf = 1 / (near - far)
106
+ out[0] = f / aspect; out[1] = 0; out[2] = 0; out[3] = 0
107
+ out[4] = 0; out[5] = -f; out[6] = 0; out[7] = 0
108
+ out[8] = 0; out[9] = 0; out[10] = (far + near) * nf; out[11] = -1
109
+ out[12] = 0; out[13] = 0; out[14] = 2 * far * near * nf; out[15] = 0
110
+ return out
111
+ }
112
+
113
+ // Vec3 helpers for geometry construction. These allocate (unlike the matrix
114
+ // functions above): they serve generation-time code - curve frames, normals -
115
+ // not the per-frame path. Exposed on the /math subpath only, so `add` does
116
+ // not collide with the scene's add() on the package root.
117
+
118
+ export function add(a: Vec3, b: Vec3): Vec3 {
119
+ return [a[0] + b[0], a[1] + b[1], a[2] + b[2]]
120
+ }
121
+
122
+ export function sub(a: Vec3, b: Vec3): Vec3 {
123
+ return [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
124
+ }
125
+
126
+ export function cross(a: Vec3, b: Vec3): Vec3 {
127
+ return [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]]
128
+ }
129
+
130
+ export function dot(a: Vec3, b: Vec3): number {
131
+ return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
132
+ }
133
+
134
+ export function scale(v: Vec3, s: number): Vec3 {
135
+ return [v[0] * s, v[1] * s, v[2] * s]
136
+ }
137
+
138
+ /** Unit vector; a zero-length input comes back unchanged. */
139
+ export function normalize(v: Vec3): Vec3 {
140
+ let len = Math.hypot(v[0], v[1], v[2]) || 1
141
+ return [v[0] / len, v[1] / len, v[2] / len]
142
+ }
143
+
144
+ /**
145
+ * View matrix (world -> camera) for a camera at `eye` looking at `target`
146
+ * with the given `up`. Degenerate inputs (eye == target, up parallel to the
147
+ * view direction) fall back to axis defaults instead of producing NaNs.
148
+ */
149
+ export function lookAt(out: Mat4, eye: Vec3, target: Vec3, up: Vec3): Mat4 {
150
+ let zx = eye[0] - target[0]
151
+ let zy = eye[1] - target[1]
152
+ let zz = eye[2] - target[2]
153
+ let len = Math.hypot(zx, zy, zz)
154
+ if (len === 0) {
155
+ zx = 0; zy = 0; zz = 1
156
+ } else {
157
+ zx /= len; zy /= len; zz /= len
158
+ }
159
+ let xx = up[1] * zz - up[2] * zy
160
+ let xy = up[2] * zx - up[0] * zz
161
+ let xz = up[0] * zy - up[1] * zx
162
+ len = Math.hypot(xx, xy, xz)
163
+ if (len === 0) {
164
+ xx = 1; xy = 0; xz = 0
165
+ } else {
166
+ xx /= len; xy /= len; xz /= len
167
+ }
168
+ let yx = zy * xz - zz * xy
169
+ let yy = zz * xx - zx * xz
170
+ let yz = zx * xy - zy * xx
171
+ out[0] = xx; out[1] = yx; out[2] = zx; out[3] = 0
172
+ out[4] = xy; out[5] = yy; out[6] = zy; out[7] = 0
173
+ out[8] = xz; out[9] = yz; out[10] = zz; out[11] = 0
174
+ out[12] = -(xx * eye[0] + xy * eye[1] + xz * eye[2])
175
+ out[13] = -(yx * eye[0] + yy * eye[1] + yz * eye[2])
176
+ out[14] = -(zx * eye[0] + zy * eye[1] + zz * eye[2])
177
+ out[15] = 1
178
+ return out
179
+ }
package/src/orbit.ts ADDED
@@ -0,0 +1,164 @@
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 clamps - the
3
+ // standard interactive-viewer camera, extracted so apps stop rebuilding it.
4
+ //
5
+ // Pose is plain mutable state advanced by update(dt) from the app's own
6
+ // onFrame; the control registers no frame loop of its own, so whether
7
+ // anything animates stays the app's decision (a paused orbit with no other
8
+ // animation costs nothing). Only `orbiting` is a signal - slow UI state
9
+ // that HUDs read - while the pose moves at frame rate and bypasses
10
+ // reactivity: the package's structure-vs-motion split. update() pushes the
11
+ // pose to the scene camera only when it actually changed, and reports
12
+ // that, so per-frame dependents (a uCamPos uniform) can follow the camera
13
+ // without writing every frame.
14
+
15
+ import { createSignal } from "@solidjs/signals"
16
+ import type { Scene } from "./scene.ts"
17
+ import type { Vec3 } from "./math.ts"
18
+
19
+ // Baseline sensitivities at rotateSpeed/zoomSpeed 1, in radians per dragged
20
+ // pixel and zoom exponent per wheel-delta unit.
21
+ const DRAG_AZIMUTH = 0.008
22
+ const DRAG_ELEVATION = 0.006
23
+ const WHEEL_ZOOM = 0.0015
24
+
25
+ export type OrbitCameraOptions = {
26
+ /** The point the camera orbits and looks at (default origin). */
27
+ target?: Vec3
28
+ /** Initial pose, radians and world units. */
29
+ azimuth?: number
30
+ elevation?: number
31
+ distance?: number
32
+ minDistance?: number
33
+ maxDistance?: number
34
+ /** Elevation clamps, radians; the defaults stop just short of the poles. */
35
+ minElevation?: number
36
+ maxElevation?: number
37
+ /** Auto-orbit rate in radians/second (default 0: none). Runs while
38
+ * `orbiting()` and not dragging; toggle with set({ orbiting }). */
39
+ orbitSpeed?: number
40
+ /** Multipliers over the built-in drag/wheel sensitivities. */
41
+ rotateSpeed?: number
42
+ zoomSpeed?: number
43
+ }
44
+
45
+ export type OrbitPose = {
46
+ azimuth?: number
47
+ elevation?: number
48
+ distance?: number
49
+ target?: Vec3
50
+ orbiting?: boolean
51
+ }
52
+
53
+ export type OrbitCamera = {
54
+ /** Camera position for the current pose (a fresh array per call). */
55
+ eye(): Vec3
56
+ /** Pose snapshot - the shape debug commands return and set() takes. */
57
+ pose(): { azimuth: number; elevation: number; distance: number }
58
+ /** Merge a pose in (clamps apply); reaches the scene at the next
59
+ * update(). Also the auto-orbit switch: set({ orbiting: false }). */
60
+ set(pose: OrbitPose): void
61
+ /** Whether the auto-orbit is running. Reactive (signal-backed), so HUD
62
+ * text can read it. */
63
+ orbiting(): boolean
64
+ /** Advance the auto-orbit and push any pose change to the scene camera.
65
+ * Call from onFrame with the frame's dt in seconds; returns whether the
66
+ * pose changed. */
67
+ update(dt: number): boolean
68
+ /** Spread onto the element that receives input:
69
+ * `<window {...orbit.handlers} />`. */
70
+ handlers: {
71
+ onPointerDown(e: { clientX: number; clientY: number }): void
72
+ onPointerMove(e: { clientX: number; clientY: number }): void
73
+ onPointerUp(): void
74
+ onWheel(e: { deltaY: number }): void
75
+ }
76
+ }
77
+
78
+ let clampNum = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v))
79
+
80
+ /**
81
+ * Create an orbit camera driving `scene`'s camera position and target (fov,
82
+ * near, and far stay yours via scene.setCamera). The initial pose applies
83
+ * immediately. In a component tree, reach the scene via `<Scene ref>` or
84
+ * useScene() and hand the handlers to whichever element owns input.
85
+ */
86
+ export function createOrbitCamera(scene: Scene, options: OrbitCameraOptions = {}): OrbitCamera {
87
+ let target: Vec3 = options.target ? [options.target[0], options.target[1], options.target[2]] : [0, 0, 0]
88
+ let azimuth = options.azimuth ?? 0
89
+ let elevation = options.elevation ?? 0
90
+ let distance = options.distance ?? 5
91
+ let minDistance = options.minDistance ?? 0.01
92
+ let maxDistance = options.maxDistance ?? Infinity
93
+ let minElevation = options.minElevation ?? -1.55
94
+ let maxElevation = options.maxElevation ?? 1.55
95
+ let orbitSpeed = options.orbitSpeed ?? 0
96
+ let dragAzimuth = DRAG_AZIMUTH * (options.rotateSpeed ?? 1)
97
+ let dragElevation = DRAG_ELEVATION * (options.rotateSpeed ?? 1)
98
+ let wheelZoom = WHEEL_ZOOM * (options.zoomSpeed ?? 1)
99
+
100
+ let [orbiting, setOrbiting] = createSignal(orbitSpeed > 0)
101
+ let drag: { x: number; y: number } | null = null
102
+ let dirty = false
103
+
104
+ let clampPose = () => {
105
+ elevation = clampNum(elevation, minElevation, maxElevation)
106
+ distance = clampNum(distance, minDistance, maxDistance)
107
+ }
108
+ let eye = (): Vec3 => {
109
+ let ce = Math.cos(elevation)
110
+ return [
111
+ target[0] + distance * ce * Math.sin(azimuth),
112
+ target[1] + distance * Math.sin(elevation),
113
+ target[2] + distance * ce * Math.cos(azimuth),
114
+ ]
115
+ }
116
+ let apply = () => scene.setCamera({ position: eye(), target })
117
+
118
+ clampPose()
119
+ apply()
120
+
121
+ return {
122
+ eye,
123
+ pose: () => ({ azimuth, elevation, distance }),
124
+ orbiting,
125
+ set(pose) {
126
+ if (pose.azimuth !== undefined) azimuth = pose.azimuth
127
+ if (pose.elevation !== undefined) elevation = pose.elevation
128
+ if (pose.distance !== undefined) distance = pose.distance
129
+ if (pose.target) target = [pose.target[0], pose.target[1], pose.target[2]]
130
+ clampPose()
131
+ if (pose.orbiting !== undefined) setOrbiting(pose.orbiting)
132
+ dirty = true
133
+ },
134
+ update(dt) {
135
+ if (orbitSpeed !== 0 && drag === null && orbiting()) {
136
+ azimuth += dt * orbitSpeed
137
+ dirty = true
138
+ }
139
+ if (!dirty) return false
140
+ dirty = false
141
+ apply()
142
+ return true
143
+ },
144
+ handlers: {
145
+ onPointerDown(e) {
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
+ },
158
+ onWheel(e) {
159
+ distance = clampNum(distance * Math.exp(e.deltaY * wheelZoom), minDistance, maxDistance)
160
+ dirty = true
161
+ },
162
+ },
163
+ }
164
+ }