@solidrt/3d 0.0.55 → 0.0.56

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 (54) hide show
  1. package/AGENTS.md +1083 -183
  2. package/README.md +15 -7
  3. package/examples/README.md +79 -5
  4. package/examples/aim.tsx +2 -3
  5. package/examples/cascades.tsx +1 -4
  6. package/examples/collision.tsx +250 -0
  7. package/examples/first-person.tsx +119 -0
  8. package/examples/fleet.tsx +194 -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 +32 -6
  17. package/examples/pick.tsx +2 -4
  18. package/examples/probe.tsx +101 -0
  19. package/examples/scene-background.tsx +2 -4
  20. package/examples/scene-basic.tsx +2 -4
  21. package/examples/scene-views.tsx +32 -3
  22. package/examples/shadows.tsx +2 -4
  23. package/examples/sky-lit.tsx +86 -0
  24. package/examples/skybox.tsx +128 -0
  25. package/examples/sprites.tsx +4 -6
  26. package/examples/standard.tsx +132 -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 +199 -0
  32. package/src/color.ts +32 -0
  33. package/src/components.tsx +763 -82
  34. package/src/environment-bake.ts +492 -0
  35. package/src/environment.ts +325 -0
  36. package/src/first-person.ts +306 -0
  37. package/src/geometry.ts +6 -2
  38. package/src/glsl.ts +1343 -82
  39. package/src/gltf.ts +432 -112
  40. package/src/index.ts +57 -9
  41. package/src/light.ts +383 -0
  42. package/src/material.ts +667 -232
  43. package/src/math.ts +110 -0
  44. package/src/mesh.ts +728 -0
  45. package/src/mixer.ts +476 -0
  46. package/src/model-file.ts +80 -15
  47. package/src/model.ts +204 -35
  48. package/src/node.ts +506 -0
  49. package/src/orbit.ts +97 -48
  50. package/src/scene-pointer.ts +166 -0
  51. package/src/scene-shadows.ts +471 -0
  52. package/src/scene.ts +1242 -1557
  53. package/tools/environment.ts +69 -0
  54. package/tools/model.ts +4 -3
@@ -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,306 @@
1
+ // A first-person camera for a scene: a position plus yaw/pitch, with mouse
2
+ // look under pointer lock, drag-to-look without it (the touch path), and
3
+ // walk (or fly) from keys and gamepad sticks - Unity's FirstPersonController
4
+ // shape, look and move in one control, rather than Three's split into
5
+ // PointerLockControls (look only) and a hand-written key loop.
6
+ //
7
+ // Look input has three sources, all summed into the same yaw/pitch:
8
+ // - pointer-move deltas (movementX/movementY) while the pointer is locked,
9
+ // the desktop mouse-look path; the control never engages the lock itself
10
+ // (click-to-lock and Escape-to-release are window-level decisions that
11
+ // stay app code, see lockPointer in @solidrt/core);
12
+ // - a one-finger drag through core's transform recognizer while NOT locked,
13
+ // so a touch viewport looks around with the arena arbitration the orbit
14
+ // camera has, and viewport-relative like it;
15
+ // - the right stick, read from core's gamepads() at update time.
16
+ // Move input: WASD/arrows (physical codes AND logical keys, so a layout
17
+ // or a synthetic event without a code both work), Q/E for down/up in fly
18
+ // mode, and the left stick. Walking flattens the forward vector onto the
19
+ // ground plane at fixed height; flying moves along the view direction.
20
+ //
21
+ // Pose is plain mutable state advanced by update(dt) from the app's own
22
+ // onFrame, as in orbit.ts: only `active` is reactive - whether anything is
23
+ // held or deflected, what a frame loop gates on; it derives from the held
24
+ // count and core's gamepads() so a stick moved while the loop is off wakes
25
+ // it - while the pose moves at frame rate and bypasses reactivity. Held
26
+ // keys cannot be polled (there is no key-state accessor in core), so the
27
+ // control tracks them from the down/up pair; an `onBlur` handler drops
28
+ // them all, because the up never arrives once focus has left.
29
+ //
30
+ // Collision is deliberately absent: a camera control cannot know the
31
+ // level. Clamp or reject positions through `clampPosition`.
32
+ //
33
+ // Options are read where they apply, not copied out: `fly`, `moveSpeed`,
34
+ // `lookSpeed`, the pitch clamps, `viewport` and `clampPosition` are
35
+ // re-read from the options object on every input or update, so a caller
36
+ // may change a field (or hand in an object of getters, which is what
37
+ // `<FirstPersonCamera>` does with its props) and the next move sees it -
38
+ // walk and fly are one control, not two mounts. Only the initial pose is
39
+ // copied at creation; later pose changes go through set().
40
+
41
+ import { createMemo, createSignal } from "@solidjs/signals"
42
+ import { createTransform, gamepads, pointerLocked } from "@solidrt/core"
43
+ import type { KeyEvent, PointerEvent } from "@solidrt/core"
44
+ import type { CameraUpdate } from "./camera.ts"
45
+ import type { Vec3 } from "./math.ts"
46
+
47
+ // Baseline sensitivities at lookSpeed 1. Mouse look is radians per moved
48
+ // logical pixel under lock (Three's PointerLockControls figure); with a
49
+ // `viewport` the drag is viewport-relative, one viewport height sweeping
50
+ // DRAG_TURNS turns, and without one it falls back to the mouse figure.
51
+ // The stick turns at LOOK_STICK radians/second at full deflection, with
52
+ // a dead zone below which a resting stick reads as zero.
53
+ const LOOK_MOUSE = 0.002
54
+ const DRAG_TURNS = 0.5
55
+ const LOOK_STICK = 2.5
56
+ const STICK_DEADZONE = 0.15
57
+ // Default walking speed, world units per second.
58
+ const MOVE_SPEED = 3
59
+ // Pitch clamps stop short of the poles so the look direction never
60
+ // degenerates against world up.
61
+ const PITCH_LIMIT = Math.PI / 2 - 0.01
62
+
63
+ /** What a first-person camera drives: a Scene, or one of its Views. */
64
+ export type FirstPersonTarget = { setCamera(update: CameraUpdate): void }
65
+
66
+ /** The pose fields (position, yaw, pitch) are initial values, copied at
67
+ * creation and changed through set() afterwards. Every other field is
68
+ * live: read from this object where it applies, so a change takes effect
69
+ * on the next input or update. */
70
+ export type FirstPersonCameraOptions = {
71
+ /** Initial eye position (default [0, 1.6, 0]: standing height at the
72
+ * origin). */
73
+ position?: Vec3
74
+ /** Initial look, radians. Yaw 0 faces -z (the camera default), positive
75
+ * turns left; pitch 0 is level, positive looks up. */
76
+ yaw?: number
77
+ pitch?: number
78
+ /** Pitch clamps, radians; the defaults stop just short of the poles. */
79
+ minPitch?: number
80
+ maxPitch?: number
81
+ /** Movement speed in world units per second (default 3). */
82
+ moveSpeed?: number
83
+ /** Multiplier over the built-in mouse, drag and stick look rates. */
84
+ lookSpeed?: number
85
+ /** Walk (default) keeps the height fixed and moves along the ground
86
+ * projection of the view; fly moves along the view itself and arms Q/E
87
+ * for down/up (bound either way, inert while walking). Toggling it on a
88
+ * running control keeps the pose and the held keys. */
89
+ fly?: boolean
90
+ /** The viewport a drag lives in: the input element's own laid-out
91
+ * height (the frame the recognizer's deltas arrive in); makes the drag
92
+ * viewport-relative. Null while unknown. */
93
+ viewport?: () => { height: number } | null
94
+ /** Constrain where a move may put the eye: called with the eye the
95
+ * move asks for and the eye it starts from (both fresh arrays), returns
96
+ * the position to use - a level's bounds, a floor height, a collision
97
+ * controller's `moveAndSlide` over the difference. Look does not
98
+ * consult it. */
99
+ clampPosition?: (next: Vec3, current: Vec3) => Vec3
100
+ }
101
+
102
+ export type FirstPersonPose = {
103
+ position?: Vec3
104
+ yaw?: number
105
+ pitch?: number
106
+ }
107
+
108
+ export type FirstPersonCamera = {
109
+ /** Eye position (a fresh array per call). */
110
+ eye(): Vec3
111
+ /** Unit look direction for the current pose (a fresh array per call). */
112
+ forward(): Vec3
113
+ /** Pose snapshot - the shape debug commands return and set() takes. */
114
+ pose(): { position: Vec3; yaw: number; pitch: number }
115
+ /** Merge a pose in (clamps apply); reaches the scene at the next
116
+ * update(). */
117
+ set(pose: FirstPersonPose): void
118
+ /** Whether a movement key is held or a stick deflected - what a frame
119
+ * loop should run on. Reactive (signal-backed). */
120
+ active(): boolean
121
+ /** Integrate held keys and sticks over dt seconds and push any pose
122
+ * change to the driven camera; returns whether the pose changed. */
123
+ update(dt: number): boolean
124
+ /** Spread onto the element that receives input. Keys route through the
125
+ * focused node, so that element must hold focus (or be the window). */
126
+ handlers: {
127
+ onPointerDown(e: PointerEvent): void
128
+ onPointerMove(e: PointerEvent): void
129
+ onPointerUp(e: PointerEvent): void
130
+ onKeyDown(e: KeyEvent): void
131
+ onKeyUp(e: KeyEvent): void
132
+ onBlur(): void
133
+ }
134
+ }
135
+
136
+ type Move = "forward" | "back" | "left" | "right" | "up" | "down"
137
+
138
+ // Physical codes first (layout-independent), then the logical keys a
139
+ // synthetic event or an odd layout reports.
140
+ const KEY_MOVES: Record<string, Move> = {
141
+ KeyW: "forward", ArrowUp: "forward", w: "forward", W: "forward",
142
+ KeyS: "back", ArrowDown: "back", s: "back", S: "back",
143
+ KeyA: "left", ArrowLeft: "left", a: "left", A: "left",
144
+ KeyD: "right", ArrowRight: "right", d: "right", D: "right",
145
+ KeyE: "up", e: "up", E: "up",
146
+ KeyQ: "down", q: "down", Q: "down",
147
+ }
148
+
149
+ let clampNum = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v))
150
+ let deadzone = (v: number) => (Math.abs(v) < STICK_DEADZONE ? 0 : v)
151
+
152
+ /**
153
+ * Create a first-person camera driving `camera`'s position and target,
154
+ * where `camera` is a Scene or one of its Views (fov, near, and far stay
155
+ * yours via its setCamera). The initial pose applies immediately. In a
156
+ * component tree, prefer the `<FirstPersonCamera>` component: it wires
157
+ * scene, input, focus, viewport and the frame loop through the Scene
158
+ * context.
159
+ */
160
+ export function createFirstPersonCamera(camera: FirstPersonTarget, options: FirstPersonCameraOptions = {}): FirstPersonCamera {
161
+ let position: Vec3 = options.position ? [options.position[0], options.position[1], options.position[2]] : [0, 1.6, 0]
162
+ let yaw = options.yaw ?? 0
163
+ let pitch = options.pitch ?? 0
164
+ // Everything below the pose is read from `options` where it applies.
165
+ let lookSpeed = () => options.lookSpeed ?? 1
166
+ let clampPitch = () => {
167
+ pitch = clampNum(pitch, options.minPitch ?? -PITCH_LIMIT, options.maxPitch ?? PITCH_LIMIT)
168
+ }
169
+
170
+ let held = new Set<Move>()
171
+ let [heldCount, setHeldCount] = createSignal(0)
172
+ let dirty = false
173
+
174
+ let forward = (): Vec3 => {
175
+ let cp = Math.cos(pitch)
176
+ return [-Math.sin(yaw) * cp, Math.sin(pitch), -Math.cos(yaw) * cp]
177
+ }
178
+ let apply = () => {
179
+ let f = forward()
180
+ camera.setCamera({ position: [position[0], position[1], position[2]], target: [position[0] + f[0], position[1] + f[1], position[2] + f[2]] })
181
+ }
182
+ let look = (dx: number, dy: number) => {
183
+ yaw -= dx
184
+ pitch -= dy
185
+ clampPitch()
186
+ dirty = true
187
+ }
188
+ let moveBy = (dx: number, dy: number, dz: number) => {
189
+ let next: Vec3 = [position[0] + dx, position[1] + dy, position[2] + dz]
190
+ position = options.clampPosition ? options.clampPosition(next, [position[0], position[1], position[2]]) : next
191
+ dirty = true
192
+ }
193
+
194
+ // The stick axes of every connected pad, summed: a game with one player
195
+ // sees its one pad, and a pad at rest contributes nothing.
196
+ let sticks = () => {
197
+ let s = { moveX: 0, moveY: 0, lookX: 0, lookY: 0 }
198
+ for (let pad of gamepads()) {
199
+ if (!pad) continue
200
+ s.moveX += deadzone(pad.axes.leftX ?? 0)
201
+ s.moveY += deadzone(pad.axes.leftY ?? 0)
202
+ s.lookX += deadzone(pad.axes.rightX ?? 0)
203
+ s.lookY += deadzone(pad.axes.rightY ?? 0)
204
+ }
205
+ return s
206
+ }
207
+ let active = createMemo(() => {
208
+ let s = sticks()
209
+ return heldCount() > 0 || s.moveX !== 0 || s.moveY !== 0 || s.lookX !== 0 || s.lookY !== 0
210
+ })
211
+
212
+ // The unlocked look: a one-finger drag, arena-arbitrated. Two fingers
213
+ // have no first-person meaning and keep looking.
214
+ let transform = createTransform({
215
+ onTransformMove: t => {
216
+ let vp = options.viewport?.() ?? null
217
+ let rate = (vp !== null ? (DRAG_TURNS * 2 * Math.PI) / vp.height : LOOK_MOUSE) * lookSpeed()
218
+ look(t.dx * rate, t.dy * rate)
219
+ apply()
220
+ dirty = false
221
+ },
222
+ })
223
+
224
+ clampPitch()
225
+ apply()
226
+
227
+ return {
228
+ eye: () => [position[0], position[1], position[2]],
229
+ forward,
230
+ pose: () => ({ position: [position[0], position[1], position[2]], yaw, pitch }),
231
+ active,
232
+ set(pose) {
233
+ if (pose.position) position = [pose.position[0], pose.position[1], pose.position[2]]
234
+ if (pose.yaw !== undefined) yaw = pose.yaw
235
+ if (pose.pitch !== undefined) pitch = pose.pitch
236
+ clampPitch()
237
+ dirty = true
238
+ },
239
+ update(dt) {
240
+ let s = sticks()
241
+ if (s.lookX !== 0 || s.lookY !== 0) {
242
+ let rate = LOOK_STICK * lookSpeed() * dt
243
+ look(s.lookX * rate, s.lookY * rate)
244
+ }
245
+ // Key axes: -1..1 per axis, the stick added on top (stick up is -y,
246
+ // the web convention, so forward is -leftY), then clamped so a key
247
+ // and a stick together do not exceed full speed.
248
+ let ahead = clampNum((held.has("forward") ? 1 : 0) - (held.has("back") ? 1 : 0) - s.moveY, -1, 1)
249
+ let side = clampNum((held.has("right") ? 1 : 0) - (held.has("left") ? 1 : 0) + s.moveX, -1, 1)
250
+ // One read per update, so a mode toggle lands between steps, never
251
+ // between the rise and the heading of the same step.
252
+ let fly = options.fly ?? false
253
+ let rise = fly ? (held.has("up") ? 1 : 0) - (held.has("down") ? 1 : 0) : 0
254
+ if (ahead !== 0 || side !== 0 || rise !== 0) {
255
+ let step = (options.moveSpeed ?? MOVE_SPEED) * dt
256
+ let f = forward()
257
+ // Walking projects the view onto the ground plane, so looking down
258
+ // does not slow the walk; right is always horizontal.
259
+ let fx = fly ? f[0] : -Math.sin(yaw)
260
+ let fy = fly ? f[1] : 0
261
+ let fz = fly ? f[2] : -Math.cos(yaw)
262
+ let rx = Math.cos(yaw)
263
+ let rz = -Math.sin(yaw)
264
+ moveBy((fx * ahead + rx * side) * step, (fy * ahead + rise) * step, (fz * ahead + rz * side) * step)
265
+ }
266
+ if (!dirty) return false
267
+ dirty = false
268
+ apply()
269
+ return true
270
+ },
271
+ handlers: {
272
+ onPointerDown(e) {
273
+ if (!pointerLocked()) transform.handlers.onPointerDown(e)
274
+ },
275
+ onPointerMove(e) {
276
+ if (pointerLocked()) {
277
+ let rate = LOOK_MOUSE * lookSpeed()
278
+ look(e.movementX * rate, e.movementY * rate)
279
+ apply()
280
+ dirty = false
281
+ } else {
282
+ transform.handlers.onPointerMove(e)
283
+ }
284
+ },
285
+ onPointerUp(e) {
286
+ transform.handlers.onPointerUp(e)
287
+ },
288
+ onKeyDown(e) {
289
+ let move = KEY_MOVES[e.code] ?? KEY_MOVES[e.key]
290
+ if (!move) return
291
+ held.add(move)
292
+ setHeldCount(held.size)
293
+ },
294
+ onKeyUp(e) {
295
+ let move = KEY_MOVES[e.code] ?? KEY_MOVES[e.key]
296
+ if (!move) return
297
+ held.delete(move)
298
+ setHeldCount(held.size)
299
+ },
300
+ onBlur() {
301
+ held.clear()
302
+ setHeldCount(0)
303
+ },
304
+ },
305
+ }
306
+ }
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