@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.
- package/AGENTS.md +114 -0
- package/README.md +42 -0
- package/examples/README.md +9 -0
- package/examples/scene-basic.tsx +53 -0
- package/package.json +21 -0
- package/src/components.tsx +148 -0
- package/src/geometry.ts +227 -0
- package/src/index.ts +19 -0
- package/src/material.ts +209 -0
- package/src/math.ts +179 -0
- package/src/orbit.ts +164 -0
- package/src/scene.ts +403 -0
package/src/material.ts
ADDED
|
@@ -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
|
+
}
|