@solidrt/3d 0.0.55 → 0.0.57

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 (72) hide show
  1. package/AGENTS.md +1206 -224
  2. package/README.md +16 -7
  3. package/examples/README.md +88 -9
  4. package/examples/aim.tsx +2 -3
  5. package/examples/cascades.tsx +1 -4
  6. package/examples/collision.tsx +263 -0
  7. package/examples/first-person.tsx +129 -0
  8. package/examples/fleet.tsx +200 -0
  9. package/examples/fog.tsx +2 -3
  10. package/examples/instanced.tsx +72 -32
  11. package/examples/lamps.tsx +92 -0
  12. package/examples/lit.tsx +3 -5
  13. package/examples/materials.tsx +150 -0
  14. package/examples/model-load.tsx +95 -0
  15. package/examples/model.glb +0 -0
  16. package/examples/model.tsx +35 -9
  17. package/examples/pick.tsx +145 -37
  18. package/examples/probe.tsx +107 -0
  19. package/examples/scene-background.tsx +2 -4
  20. package/examples/scene-basic.tsx +2 -4
  21. package/examples/scene-views.tsx +68 -48
  22. package/examples/shadows.tsx +2 -4
  23. package/examples/sky-lit.tsx +92 -0
  24. package/examples/skybox.tsx +134 -0
  25. package/examples/sprites.tsx +4 -6
  26. package/examples/standard.tsx +138 -0
  27. package/examples/sweep-paths.tsx +2 -4
  28. package/package.json +3 -3
  29. package/src/camera.ts +148 -0
  30. package/src/clip.ts +96 -0
  31. package/src/collision.ts +20 -0
  32. package/src/color.ts +32 -0
  33. package/src/components/context.tsx +35 -0
  34. package/src/components/directional-light.tsx +48 -0
  35. package/src/components/first-person-camera.tsx +72 -0
  36. package/src/components/group.tsx +18 -0
  37. package/src/components/hemisphere-light.tsx +24 -0
  38. package/src/components/index.ts +46 -0
  39. package/src/components/instanced-mesh.tsx +105 -0
  40. package/src/components/mesh.tsx +92 -0
  41. package/src/components/node-props.ts +90 -0
  42. package/src/components/orbit-camera.tsx +75 -0
  43. package/src/components/perspective-camera.tsx +32 -0
  44. package/src/components/point-light.tsx +52 -0
  45. package/src/components/record-mesh.tsx +68 -0
  46. package/src/components/scene.tsx +281 -0
  47. package/src/components/spot-light.tsx +65 -0
  48. package/src/components/sprite.tsx +38 -0
  49. package/src/components/view3d.tsx +152 -0
  50. package/src/environment-bake.ts +492 -0
  51. package/src/environment.ts +325 -0
  52. package/src/first-person.ts +258 -0
  53. package/src/geometry.ts +6 -2
  54. package/src/glsl.ts +1343 -82
  55. package/src/gltf.ts +432 -112
  56. package/src/index.ts +61 -9
  57. package/src/input.ts +70 -0
  58. package/src/light.ts +383 -0
  59. package/src/material.ts +667 -232
  60. package/src/math.ts +110 -0
  61. package/src/mesh.ts +728 -0
  62. package/src/mixer.ts +476 -0
  63. package/src/model-file.ts +80 -15
  64. package/src/model.ts +204 -35
  65. package/src/node.ts +579 -0
  66. package/src/orbit.ts +292 -212
  67. package/src/scene-pointer.ts +294 -0
  68. package/src/scene-shadows.ts +471 -0
  69. package/src/scene.ts +1435 -1576
  70. package/tools/environment.ts +69 -0
  71. package/tools/model.ts +4 -3
  72. package/src/components.tsx +0 -420
@@ -0,0 +1,325 @@
1
+ // Environment helpers: the baked HDR environment loader (the .srte chain
2
+ // `srt tool 3d/environment` writes), the equirectangular-to-cube
3
+ // conversion a fetched LDR panorama needs at runtime, the GPU prefilter
4
+ // that turns a rendered cube (a reflection probe's, a baked sky's) into
5
+ // the roughness chain, and the placeholder cube a scene binds as uEnv
6
+ // while no environment is set. The face passes here render straight into
7
+ // cube draw targets: no readback, no re-upload.
8
+
9
+ import { file } from "flux:fs"
10
+ import {
11
+ addDraw,
12
+ compileShader,
13
+ createCubeDrawTarget,
14
+ createCubeTexture,
15
+ createRenderPipeline,
16
+ destroyProgram,
17
+ destroyRenderPipeline,
18
+ destroyShader,
19
+ destroyTexture,
20
+ glsl,
21
+ limits,
22
+ linkProgram,
23
+ removeDraw,
24
+ renderTarget,
25
+ setDrawParams,
26
+ } from "@solidrt/core/gpu"
27
+ import type { CreateOptions, DrawId, ProgramId, RenderPipelineId, SamplerOptions, TextureFormatOptions, TextureId } from "@solidrt/core/gpu"
28
+ import { decodeEnvironment, levelRoughness, mipLevels } from "./environment-bake.ts"
29
+
30
+ /**
31
+ * Read a baked environment (`srt tool 3d/environment sky.hdr -o
32
+ * assets/sky.srte`) and upload it: the HDR cube with its GGX-prefiltered
33
+ * mip chain, as an explicit "rgba16f" chain - no generated mipmaps, so it
34
+ * works on every device. The id is what `environment={{ cube }}` and
35
+ * `background={{ cube }}` take (Three's `scene.environment` from an
36
+ * RGBELoader + PMREMGenerator, Unity's convolved reflection probe, Godot's
37
+ * radiance map, all done at build time here). Created after an await, so
38
+ * it is NOT auto-freed: an environment normally lives as long as the app;
39
+ * destroyTexture it otherwise. The same async shape as loadModel (see
40
+ * examples/model-load.tsx for the <Loading> pattern).
41
+ */
42
+ export async function loadEnvironment(path: string, opts?: CreateOptions): Promise<TextureId> {
43
+ let { size, levels } = decodeEnvironment(await file(path).bytes())
44
+ return createCubeTexture(levels, size, { ...opts, format: "rgba16f", mipmap: true, label: opts?.label ?? "environment" })
45
+ }
46
+
47
+ // The GL cube-map table as GLSL (cubeDirection in environment-bake.ts):
48
+ // the world direction whose lookup lands on `face` at (s, t) in 0..1, t =
49
+ // 0 the first row, unnormalized. Every pass that writes a face texel
50
+ // (equirectToCube's, the prefilter's) derives its direction from it, so
51
+ // the face holds what a lookup of that direction returns.
52
+ const CUBE_FACE_DIRECTION = glsl`
53
+ vec3 cubeFaceDirection(int face, vec2 st) {
54
+ float a = 2.0 * st.x - 1.0;
55
+ float b = 2.0 * st.y - 1.0;
56
+ if (face == 0) return vec3(1.0, -b, -a);
57
+ if (face == 1) return vec3(-1.0, -b, a);
58
+ if (face == 2) return vec3(a, 1.0, b);
59
+ if (face == 3) return vec3(a, -1.0, -b);
60
+ if (face == 4) return vec3(a, -b, 1.0);
61
+ return vec3(-a, -b, -1.0);
62
+ }
63
+ `
64
+
65
+ // The vertex stage of every face pass: the attributeless covering
66
+ // triangle emitting vUV with t = 0 at the face's first row, so the
67
+ // fragment's texel is (s, t) of the GL cube table - the same vUV the
68
+ // background and shader-target passes carry.
69
+ const FACE_VERTEX = glsl`
70
+ out vec2 vUV;
71
+ void main() {
72
+ vec2 p = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));
73
+ vUV = p;
74
+ gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
75
+ }
76
+ `
77
+
78
+ // A face pass: FACE_VERTEX over `fragment`, one pipeline owned by the
79
+ // caller (destroyed through `dispose` once its faces are rendered).
80
+ function facePipeline(fragment: string, label: string): { pipeline: RenderPipelineId; program: ProgramId; dispose(): void } {
81
+ let vs = compileShader("vertex", FACE_VERTEX, { header: true })
82
+ let fs = compileShader("fragment", fragment, { header: true })
83
+ let program = linkProgram(vs, fs, { label })
84
+ destroyShader(vs)
85
+ destroyShader(fs)
86
+ let pipeline = createRenderPipeline(program, { label })
87
+ return {
88
+ pipeline,
89
+ program,
90
+ dispose() {
91
+ destroyRenderPipeline(pipeline)
92
+ destroyProgram(program)
93
+ },
94
+ }
95
+ }
96
+
97
+ // Render the six faces of a cube draw target whose one entry reads uFace:
98
+ // the first five into level 0 explicitly (an explicit level regenerates
99
+ // nothing) and the last plainly, so a mipmapped cube regenerates its
100
+ // chain once.
101
+ function renderFaces(cube: TextureId, entry: DrawId): void {
102
+ for (let face = 0; face < 6; face++) {
103
+ setDrawParams(cube, entry, { uFace: face })
104
+ if (face < 5) renderTarget(cube, face, 0)
105
+ else renderTarget(cube, face)
106
+ }
107
+ }
108
+
109
+ // One face of the cube: the texel's sampling direction from the GL
110
+ // cube-map table. The panorama's center column faces -Z (the
111
+ // camera's default forward; Godot's PanoramaSkyMaterial and Unity's
112
+ // Skybox/Panoramic agree; Three centers +X, a quarter turn away), its top
113
+ // row +Y. At the seam column the uv derivative jumps by a full turn;
114
+ // zeroing that component keeps a mipmapped source from drawing a line.
115
+ // An sRGB source samples decoded and the face is written linear; a cube
116
+ // created at the same format encodes it on write and decodes on sample,
117
+ // so the bytes round-trip.
118
+ const EQUIRECT_FACE = glsl`
119
+ in vec2 vUV;
120
+ uniform sampler2D uMap;
121
+ uniform float uFace;
122
+ ${CUBE_FACE_DIRECTION}
123
+ const float PI = 3.14159265358979;
124
+ // A uv derivative larger than this is the seam wrap, not a real step.
125
+ const float SEAM_JUMP = 0.5;
126
+ void main() {
127
+ vec3 d = normalize(cubeFaceDirection(int(uFace + 0.5), vUV));
128
+ vec2 uv = vec2(atan(d.x, -d.z) / (2.0 * PI) + 0.5, acos(clamp(d.y, -1.0, 1.0)) / PI);
129
+ vec2 dx = dFdx(uv);
130
+ vec2 dy = dFdy(uv);
131
+ if (abs(dx.x) > SEAM_JUMP) dx.x = 0.0;
132
+ if (abs(dy.x) > SEAM_JUMP) dy.x = 0.0;
133
+ fragColor = textureGrad(uMap, uv, dx, dy);
134
+ }
135
+ `
136
+
137
+ /**
138
+ * Convert an equirectangular panorama (an uploaded 2D texture: createImage,
139
+ * createTexture) into a cube map of `size` x `size` faces, returned as a
140
+ * cube TextureId ready for a skybox or the environment - Three's
141
+ * `WebGLCubeRenderTarget.fromEquirectangularTexture`, Unity's cube import
142
+ * of a lat-long image. The six faces render on the GPU straight into a
143
+ * cube draw target (no readback, no upload), synchronously. `opts` are
144
+ * createCubeTexture's (`mipmap: true` for an environment shininess can
145
+ * blur; `label`; `autoFree: false` to own it), and `format` names the
146
+ * PANORAMA's format so the cube decodes like it: "rgba8-srgb" for a
147
+ * photographed sky uploaded as such, "rgba8" (default) for data,
148
+ * "rgba16f" for an HDR panorama (the cube keeps the range; renderable on
149
+ * every device with half-float rendering, else it throws - the prefiltered
150
+ * form of that panorama is `srt tool 3d/environment` plus
151
+ * loadEnvironment). The panorama's center column faces -Z and its top
152
+ * row is +Y. Leave its wrap at the default clamp: `repeat` would also
153
+ * wrap vertically and bleed the poles across the top and bottom rows,
154
+ * while the clamped seam column costs at most a texel-wide blend at +Z.
155
+ * Three centers its panoramas on +X: a rotation tuned there differs by a
156
+ * quarter turn here.
157
+ */
158
+ export function equirectToCube(map: TextureId, size: number, opts?: CreateOptions & SamplerOptions & TextureFormatOptions): TextureId {
159
+ if (!Number.isInteger(size) || size < 1) throw new Error("equirectToCube: size must be a positive integer, got " + size)
160
+ let format = opts?.format ?? "rgba8"
161
+ if (format !== "rgba8" && format !== "rgba8-srgb" && format !== "rgba16f") {
162
+ throw new Error('equirectToCube: format must be "rgba8", "rgba8-srgb" or "rgba16f" (the panorama\'s), got ' + format)
163
+ }
164
+ let label = opts?.label ?? "equirect"
165
+ let cube = createCubeDrawTarget(size, null, { ...opts, format, label })
166
+ let pass = facePipeline(EQUIRECT_FACE, label + "-face")
167
+ let entry = addDraw(cube, pass.pipeline, { uFace: 0 }, { vertexCount: 3, textures: { uMap: map } })
168
+ renderFaces(cube, entry)
169
+ // The faces are pixels now; the pass and its binding of the panorama go.
170
+ removeDraw(cube, entry)
171
+ pass.dispose()
172
+ return cube
173
+ }
174
+
175
+ // One face of one level of the prefiltered chain: prefilterCube's GGX
176
+ // convolution (environment-bake.ts) as a fragment. The texel's direction
177
+ // is the normal, view and reflection direction at once (N = V = R, every
178
+ // engine's bake), the GGX lobe at uRoughness is importance sampled with a
179
+ // Hammersley set, each sample reading the sharp source cube at the mip
180
+ // level whose texel covers the sample's solid angle (the source carries a
181
+ // generated chain), weighted by the reflected direction's cosine. A
182
+ // roughness of 0 is the base level: a copy.
183
+ const PREFILTER_FACE = glsl`
184
+ in vec2 vUV;
185
+ uniform samplerCube uSource;
186
+ uniform float uFace;
187
+ uniform float uRoughness;
188
+ ${CUBE_FACE_DIRECTION}
189
+ const float PI = 3.14159265358979;
190
+ // GGX samples per texel: the CPU bake takes 512; reading each sample at
191
+ // the lod of its solid angle lets a fraction of that converge on the GPU.
192
+ const int PREFILTER_SAMPLES = 64;
193
+ // Bias on the source lod a sample reads (Karis: +1 smooths the estimate
194
+ // without visible extra blur).
195
+ const float SOURCE_LOD_BIAS = 1.0;
196
+ // A sample's normal-lobe cosine below this contributes nothing (the lobe
197
+ // mirrored below the horizon).
198
+ const float MIN_NOL = 1e-6;
199
+ // |n.z| above which the tangent frame's helper axis switches to +X.
200
+ const float POLE = 0.999;
201
+ // Van der Corput radical inverse: the second Hammersley coordinate.
202
+ float radicalInverse(uint bits) {
203
+ bits = (bits << 16u) | (bits >> 16u);
204
+ bits = ((bits & 0x55555555u) << 1u) | ((bits & 0xAAAAAAAAu) >> 1u);
205
+ bits = ((bits & 0x33333333u) << 2u) | ((bits & 0xCCCCCCCCu) >> 2u);
206
+ bits = ((bits & 0x0F0F0F0Fu) << 4u) | ((bits & 0xF0F0F0F0u) >> 4u);
207
+ bits = ((bits & 0x00FF00FFu) << 8u) | ((bits & 0xFF00FF00u) >> 8u);
208
+ return float(bits) / 4294967296.0;
209
+ }
210
+ void main() {
211
+ vec3 n = normalize(cubeFaceDirection(int(uFace + 0.5), vUV));
212
+ if (uRoughness <= 0.0) {
213
+ fragColor = vec4(textureLod(uSource, n, 0.0).rgb, 1.0);
214
+ return;
215
+ }
216
+ float alpha = uRoughness * uRoughness;
217
+ float a2 = alpha * alpha;
218
+ float edge = float(textureSize(uSource, 0).x);
219
+ // The solid angle of one base-level source texel.
220
+ float texelAngle = 4.0 * PI / (6.0 * edge * edge);
221
+ vec3 up = abs(n.z) > POLE ? vec3(1.0, 0.0, 0.0) : vec3(0.0, 0.0, 1.0);
222
+ vec3 t = normalize(cross(up, n));
223
+ vec3 b = cross(n, t);
224
+ vec3 acc = vec3(0.0);
225
+ float weight = 0.0;
226
+ for (int i = 0; i < PREFILTER_SAMPLES; i++) {
227
+ float u1 = (float(i) + 0.5) / float(PREFILTER_SAMPLES);
228
+ float u2 = radicalInverse(uint(i));
229
+ float phi = 2.0 * PI * u1;
230
+ float cosT = sqrt((1.0 - u2) / (1.0 + (a2 - 1.0) * u2));
231
+ float sinT = sqrt(max(0.0, 1.0 - cosT * cosT));
232
+ // N = V, so the reflected sample's cosine is 2 cos^2 - 1.
233
+ float nol = 2.0 * cosT * cosT - 1.0;
234
+ if (nol <= MIN_NOL) continue;
235
+ vec3 h = t * (sinT * cos(phi)) + b * (sinT * sin(phi)) + n * cosT;
236
+ vec3 l = 2.0 * cosT * h - n;
237
+ float d = cosT * cosT * (a2 - 1.0) + 1.0;
238
+ float pdf = a2 / (PI * d * d) / 4.0;
239
+ float sampleAngle = 1.0 / (float(PREFILTER_SAMPLES) * pdf);
240
+ float lod = max(0.0, 0.5 * log2(sampleAngle / texelAngle) + SOURCE_LOD_BIAS);
241
+ acc += textureLod(uSource, l, lod).rgb * nol;
242
+ weight += nol;
243
+ }
244
+ fragColor = vec4(acc / weight, 1.0);
245
+ }
246
+ `
247
+
248
+ /** A roughness chain rendered on the GPU from a sharp cube (internal: the
249
+ * reflection probe's and bakeBackground's second half). */
250
+ export type Prefilter = {
251
+ /** The chain: a mipmapped cube draw target of the source's size, what
252
+ * the environment samples. */
253
+ cube: TextureId
254
+ /** Fill every level of every face from the source's current pixels:
255
+ * level 0 a copy, each level below convolved at its levelRoughness. */
256
+ run(): void
257
+ /** Drop the pass (its pipeline, program and binding of the source) and
258
+ * hand over the chain, which keeps its pixels: a one-shot bake's end.
259
+ * The source is free to go afterwards. */
260
+ finish(): TextureId
261
+ /** finish() plus the chain itself. */
262
+ dispose(): void
263
+ }
264
+
265
+ /** The format of a rendered radiance cube (a probe's faces, its chain, a
266
+ * baked sky): half float where the device renders it, so the range of a
267
+ * sun or an emissive survives into reflections as in every engine's HDR
268
+ * probe, else 8-bit and clamped - the picture degrades, the app runs. Not
269
+ * a knob: the renderer decides for all probes (Godot), HDR by default
270
+ * (Unity). */
271
+ export type ProbeFormat = "rgba8" | "rgba16f"
272
+ export function probeFormat(): ProbeFormat {
273
+ return limits.halfFloatRenderable ? "rgba16f" : "rgba8"
274
+ }
275
+
276
+ /**
277
+ * The runtime counterpart of the bake tool's prefilterCube: a mipmapped
278
+ * cube draw target of `size` and `format` whose chain `run()` fills from
279
+ * `source`, a `mipmap: true` cube of the same size and format (a cube
280
+ * draw target rendered sharp), one pass per face per level - 48 small
281
+ * passes at 128. Three's PMREMGenerator, Unity's and Godot's probe
282
+ * convolution, on the same roughness-to-level rule as the .srte chain
283
+ * (levelRoughness), so `standard` reads both alike.
284
+ */
285
+ export function createPrefilter(size: number, source: TextureId, format: ProbeFormat, label: string): Prefilter {
286
+ let pass: ReturnType<typeof facePipeline> | null = facePipeline(PREFILTER_FACE, label + "-prefilter")
287
+ let cube = createCubeDrawTarget(size, null, { mipmap: true, format, autoFree: false, label })
288
+ let entry = addDraw(cube, pass.pipeline, { uFace: 0, uRoughness: 0 }, { vertexCount: 3, textures: { uSource: source } })
289
+ let levels = mipLevels(size)
290
+ let finish = () => {
291
+ if (pass !== null) {
292
+ removeDraw(cube, entry)
293
+ pass.dispose()
294
+ pass = null
295
+ }
296
+ return cube
297
+ }
298
+ return {
299
+ cube,
300
+ run() {
301
+ if (pass === null) return
302
+ for (let level = 0; level < levels; level++) {
303
+ // The base is a copy whatever the size (a chain of 4 or less has
304
+ // no ramp: levelRoughness is 1 everywhere), as the CPU bake's is.
305
+ let roughness = level === 0 ? 0 : levelRoughness(size, level)
306
+ for (let face = 0; face < 6; face++) {
307
+ setDrawParams(cube, entry, { uFace: face, uRoughness: roughness })
308
+ renderTarget(cube, face, level)
309
+ }
310
+ }
311
+ },
312
+ finish,
313
+ dispose() {
314
+ destroyTexture(finish())
315
+ },
316
+ }
317
+ }
318
+
319
+ /** The 1x1 black cube a scene binds as uEnv while it has no environment
320
+ * (uEnvOn 0 makes the term vanish; the binding only keeps the sampler
321
+ * complete). Owned by the scene, which destroys it with itself. */
322
+ export function createEnvironmentPlaceholder(label: string): TextureId {
323
+ let black = new Uint8Array([0, 0, 0, 255])
324
+ return createCubeTexture([black, black, black, black, black, black], 1, { autoFree: false, label })
325
+ }
@@ -0,0 +1,258 @@
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.
11
+ //
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.
21
+ //
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.
28
+ //
29
+ // Collision is deliberately absent: a camera control cannot know the
30
+ // level. Clamp or reject positions through `clampPosition`.
31
+ //
32
+ // Options are read where they apply, not copied out: `fly`, `moveSpeed`,
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.
39
+
40
+ import { untrack } from "@solidjs/signals"
41
+ import { createAxes } from "@solidrt/core/input"
42
+ import type { Axes, Vec2 } from "@solidrt/core/input"
43
+ import type { CameraUpdate } from "./camera.ts"
44
+ import type { Vec3 } from "./math.ts"
45
+
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).
48
+ const DRAG_TURNS = 0.5
49
+ // Look rate at full stick deflection, turns per second.
50
+ const LOOK_RATE = 0.4
51
+ // Default walking speed, world units per second.
52
+ const MOVE_SPEED = 3
53
+ // Pitch clamps stop short of the poles so the look direction never
54
+ // degenerates against world up.
55
+ const PITCH_LIMIT = Math.PI / 2 - 0.01
56
+
57
+ /** What a first-person camera drives: a Scene, or one of its Views. */
58
+ export type FirstPersonTarget = { setCamera(update: CameraUpdate): void }
59
+
60
+ /** The pose fields (position, yaw, pitch) are initial values, copied at
61
+ * creation and changed through set() afterwards. Every other field is
62
+ * live: read from this object where it applies, so a change takes effect
63
+ * on the next input or update. */
64
+ export type FirstPersonCameraOptions = {
65
+ /** Initial eye position (default [0, 1.6, 0]: standing height at the
66
+ * origin). */
67
+ position?: Vec3
68
+ /** Initial look, radians. Yaw 0 faces -z (the camera default), positive
69
+ * turns left; pitch 0 is level, positive looks up. */
70
+ yaw?: number
71
+ pitch?: number
72
+ /** Pitch clamps, radians; the defaults stop just short of the poles. */
73
+ minPitch?: number
74
+ maxPitch?: number
75
+ /** Movement speed in world units per second (default 3). */
76
+ moveSpeed?: number
77
+ /** Multiplier over the built-in look sensitivities (drags, mouse motion
78
+ * and rates alike). */
79
+ lookSpeed?: number
80
+ /** Walk (default) keeps the height fixed and moves along the ground
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. */
84
+ fly?: boolean
85
+ /** Constrain where a move may put the eye: called with the eye the
86
+ * move asks for and the eye it starts from (both fresh arrays), returns
87
+ * the position to use - a level's bounds, a floor height, a collision
88
+ * controller's `moveAndSlide` over the difference. Look does not
89
+ * consult it. */
90
+ clampPosition?: (next: Vec3, current: Vec3) => Vec3
91
+ }
92
+
93
+ export type FirstPersonPose = {
94
+ position?: Vec3
95
+ yaw?: number
96
+ pitch?: number
97
+ }
98
+
99
+ export type FirstPersonAxes = { look: "vec2"; move: "vec2"; rise: "axis" }
100
+
101
+ export type FirstPersonCamera = {
102
+ /** Eye position (a fresh array per call). */
103
+ eye(): Vec3
104
+ /** Unit look direction for the current pose (a fresh array per call). */
105
+ forward(): Vec3
106
+ /** Pose snapshot - the shape debug commands return and set() takes. */
107
+ pose(): { position: Vec3; yaw: number; pitch: number }
108
+ /** Merge a pose in (clamps apply) and push it. */
109
+ set(pose: FirstPersonPose): void
110
+ /** Whether any axis rate reads non-zero - what a frame loop should run
111
+ * on. Reactive. */
112
+ active(): boolean
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). */
116
+ update(dt: number): boolean
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
128
+ }
129
+
130
+ let clampNum = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v))
131
+
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}`)
134
+ }
135
+
136
+ /**
137
+ * Create a first-person camera driving `camera`'s position and target,
138
+ * where `camera` is a Scene or one of its Views (fov, near, and far stay
139
+ * yours via its setCamera). The initial pose applies immediately. In a
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.
143
+ */
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)")
146
+ let position: Vec3 = options.position ? [options.position[0], options.position[1], options.position[2]] : [0, 1.6, 0]
147
+ let yaw = options.yaw ?? 0
148
+ let pitch = options.pitch ?? 0
149
+ // Everything below the pose is read from `options` where it applies.
150
+ let lookSpeed = () => options.lookSpeed ?? 1
151
+ let moveSpeed = () => options.moveSpeed ?? MOVE_SPEED
152
+ let clampPitch = () => {
153
+ pitch = clampNum(pitch, options.minPitch ?? -PITCH_LIMIT, options.maxPitch ?? PITCH_LIMIT)
154
+ }
155
+ let changed = false
156
+
157
+ let forward = (): Vec3 => {
158
+ let cp = Math.cos(pitch)
159
+ return [-Math.sin(yaw) * cp, Math.sin(pitch), -Math.cos(yaw) * cp]
160
+ }
161
+ let push = () => {
162
+ changed = true
163
+ let f = forward()
164
+ camera.setCamera({ position: [position[0], position[1], position[2]], target: [position[0] + f[0], position[1] + f[1], position[2] + f[2]] })
165
+ }
166
+ let look = (dYaw: number, dPitch: number) => {
167
+ yaw += dYaw
168
+ pitch += dPitch
169
+ clampPitch()
170
+ }
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]
184
+ position = options.clampPosition ? options.clampPosition(next, [position[0], position[1], position[2]]) : next
185
+ }
186
+
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
+ },
203
+ },
204
+ )
205
+
206
+ clampPitch()
207
+ push()
208
+ changed = false
209
+
210
+ return {
211
+ eye: () => [position[0], position[1], position[2]],
212
+ forward,
213
+ pose: () => ({ position: [position[0], position[1], position[2]], yaw, pitch }),
214
+ active: axes.active,
215
+ axes,
216
+ set(pose) {
217
+ if (pose.position) position = [pose.position[0], pose.position[1], pose.position[2]]
218
+ if (pose.yaw !== undefined) yaw = pose.yaw
219
+ if (pose.pitch !== undefined) pitch = pose.pitch
220
+ clampPitch()
221
+ push()
222
+ },
223
+ update(dt) {
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
231
+ }
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
238
+ }
239
+ if (moved) push()
240
+ let result = changed
241
+ changed = false
242
+ return result
243
+ },
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()
256
+ },
257
+ }
258
+ }
package/src/geometry.ts CHANGED
@@ -28,7 +28,7 @@ import type { Quat, TransformUpdate, Vec2, Vec3 } from "./math.ts"
28
28
  /** A vertex layout: the named presets, or an explicit attribute list that
29
29
  * must begin with the standard prefix (aPos vec3, aNormal vec3, aUV vec2).
30
30
  * Absent on a Geometry means "standard". */
31
- export type VertexLayout = "standard" | "colored" | VertexAttribute[]
31
+ export type VertexLayout = "standard" | "colored" | "skinned" | VertexAttribute[]
32
32
 
33
33
  const STANDARD_ATTRIBUTES: VertexAttribute[] = [
34
34
  { name: "aPos", format: "vec3" },
@@ -38,9 +38,12 @@ const STANDARD_ATTRIBUTES: VertexAttribute[] = [
38
38
 
39
39
  /** The attribute lists behind the named layouts. Every layout shares the
40
40
  * standard prefix, so one shader vocabulary serves all of them. */
41
- export const VERTEX_LAYOUTS: Record<"standard" | "colored", VertexAttribute[]> = {
41
+ export const VERTEX_LAYOUTS: Record<"standard" | "colored" | "skinned", VertexAttribute[]> = {
42
42
  standard: STANDARD_ATTRIBUTES,
43
43
  colored: [...STANDARD_ATTRIBUTES, { name: "aColor", format: "vec4" }],
44
+ // The rigged-model layout: 4 joint indices (as floats - exact to 2^24)
45
+ // and their weights per vertex, what a skinned vertex stage reads.
46
+ skinned: [...STANDARD_ATTRIBUTES, { name: "aJoints", format: "vec4" }, { name: "aWeights", format: "vec4" }],
44
47
  }
45
48
 
46
49
  /** Floats per vertex in the "standard" layout (the generators' own write
@@ -53,6 +56,7 @@ const FORMAT_FLOATS: Record<VertexAttribute["format"], number> = { f32: 1, vec2:
53
56
  export function layoutAttributes(layout?: VertexLayout): VertexAttribute[] {
54
57
  if (layout === undefined || layout === "standard") return VERTEX_LAYOUTS.standard
55
58
  if (layout === "colored") return VERTEX_LAYOUTS.colored
59
+ if (layout === "skinned") return VERTEX_LAYOUTS.skinned
56
60
  return layout
57
61
  }
58
62