@solidrt/3d 0.0.56 → 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 (40) hide show
  1. package/AGENTS.md +199 -117
  2. package/README.md +2 -1
  3. package/examples/README.md +13 -8
  4. package/examples/collision.tsx +17 -4
  5. package/examples/first-person.tsx +20 -10
  6. package/examples/fleet.tsx +11 -5
  7. package/examples/model.tsx +3 -3
  8. package/examples/pick.tsx +144 -34
  9. package/examples/probe.tsx +10 -4
  10. package/examples/scene-views.tsx +49 -58
  11. package/examples/sky-lit.tsx +10 -4
  12. package/examples/skybox.tsx +10 -4
  13. package/examples/standard.tsx +10 -4
  14. package/package.json +2 -2
  15. package/src/collision.ts +14 -193
  16. package/src/components/context.tsx +35 -0
  17. package/src/components/directional-light.tsx +48 -0
  18. package/src/components/first-person-camera.tsx +72 -0
  19. package/src/components/group.tsx +18 -0
  20. package/src/components/hemisphere-light.tsx +24 -0
  21. package/src/components/index.ts +46 -0
  22. package/src/components/instanced-mesh.tsx +105 -0
  23. package/src/components/mesh.tsx +92 -0
  24. package/src/components/node-props.ts +90 -0
  25. package/src/components/orbit-camera.tsx +75 -0
  26. package/src/components/perspective-camera.tsx +32 -0
  27. package/src/components/point-light.tsx +52 -0
  28. package/src/components/record-mesh.tsx +68 -0
  29. package/src/components/scene.tsx +281 -0
  30. package/src/components/spot-light.tsx +65 -0
  31. package/src/components/sprite.tsx +38 -0
  32. package/src/components/view3d.tsx +152 -0
  33. package/src/first-person.ts +140 -188
  34. package/src/index.ts +13 -9
  35. package/src/input.ts +70 -0
  36. package/src/node.ts +98 -25
  37. package/src/orbit.ts +266 -235
  38. package/src/scene-pointer.ts +229 -101
  39. package/src/scene.ts +250 -76
  40. package/src/components.tsx +0 -1101
@@ -0,0 +1,38 @@
1
+ import { onCleanup, untrack, useContext } from "@solidrt/core"
2
+ import type { VoidComponent } from "@solidrt/core"
3
+ import { SceneContext } from "./context.tsx"
4
+ import type { TransformProps, PointerEventProps } from "./node-props.ts"
5
+ import { syncMesh } from "./mesh.tsx"
6
+ import { add, remove } from "../node.ts"
7
+ import { createSprite } from "../mesh.ts"
8
+ import type { Mesh as MeshNode } from "../mesh.ts"
9
+ import type { ShaderParams } from "@solidrt/core/gpu"
10
+ import type { Material } from "../material.ts"
11
+
12
+ export type SpriteProps = TransformProps & PointerEventProps & {
13
+ /** A `sprite()` material (any material draws, only a sprite one turns). */
14
+ material: Material
15
+ /** Per-mesh uniforms, merge semantics - as on Mesh. */
16
+ params?: ShaderParams
17
+ /** Explicit draw-order key (setRenderOrder as a prop); default 0. */
18
+ renderOrder?: number
19
+ /** Layer membership bitmask (setLayers as a prop; default 1). */
20
+ layers?: number
21
+ /** Frustum culling switch (setCulling as a prop; default true). */
22
+ frustumCulled?: boolean
23
+ /** World units the culled box grows by (setCulling as a prop; default 0). */
24
+ cullMargin?: number
25
+ ref?: (mesh: MeshNode) => void
26
+ }
27
+
28
+ /** A camera-facing unit quad (createSprite as a component): no geometry
29
+ * prop, `scale` is its world size, rotation is ignored. */
30
+ export let Sprite: VoidComponent<SpriteProps> = props => {
31
+ let ctx = useContext(SceneContext)
32
+ let mesh = untrack(() => createSprite(props.material))
33
+ add(ctx.parent, mesh)
34
+ syncMesh(mesh, props)
35
+ untrack(() => props.ref)?.(mesh)
36
+ onCleanup(() => remove(mesh))
37
+ return null
38
+ }
@@ -0,0 +1,152 @@
1
+ import { createEffect, onCleanup, untrack, useContext } from "@solidrt/core"
2
+ import type { Element, ParentComponent, PointerFeed, TextureId } from "@solidrt/core"
3
+ import { SceneContext } from "./context.tsx"
4
+ import { feedPointer } from "../scene-pointer.ts"
5
+ import type { ViewHandle, ViewOptions } from "../scene.ts"
6
+ import type { CameraUpdate } from "../camera.ts"
7
+ import type { ScenePointerProps } from "./scene.tsx"
8
+
9
+ export type View3dProps = Pick<ViewOptions, "clearColor" | "label" | "overrideMaterial" | "fog" | "depth" | "samples" | "filter" | "wrap" | "into"> &
10
+ ScenePointerProps & {
11
+ /** Target pixels, live: the view resizes, or its tile moves. Fixed
12
+ * only, for now: a fill mode like Scene's is a later, additive step. */
13
+ width: number
14
+ height: number
15
+ /** The tile's top-left in `into` (view.setRect as props), live; without
16
+ * `into` they mean nothing. */
17
+ x?: number
18
+ y?: number
19
+ /** The view's layer mask (view.setLayers as a prop; default 1). Live. */
20
+ layers?: number
21
+ /** Partial camera update on the view's own camera (view.setCamera):
22
+ * absent keys keep their values. Live. The `<PerspectiveCamera>` child
23
+ * writes the same state - use one form, not both. */
24
+ camera?: CameraUpdate
25
+ /**
26
+ * Compose the leaf yourself: called once (untracked) with view.texture,
27
+ * and its return renders in place of the built-in `<texture>` leaf.
28
+ * Pointer events through the view then need its handlers on your leaf:
29
+ * `<texture src={texture} {...view.handlers} />` (the view from `ref`);
30
+ * the view's pointer feed listens at its root. A tiled view's id is a
31
+ * draw target, not a texture: show `into` with srcX/srcY, as the
32
+ * built-in leaf does.
33
+ */
34
+ output?: (texture: TextureId) => Element
35
+ /** Pointer events (default on): the built-in leaf carries the view's
36
+ * handlers. `false` detaches them. */
37
+ events?: boolean
38
+ /** The pointer feed of this view's leaf (see Scene's `pointer`), fed
39
+ * from the view's root - the events the nodes let through - so a map
40
+ * bound to it drives the camera controls inside this view and no other;
41
+ * inside, `useScene().pointer` is this feed. Fixed at creation. */
42
+ pointer?: PointerFeed
43
+ ref?: (view: ViewHandle) => void
44
+ }
45
+
46
+ /**
47
+ * A second rendering of the enclosing scene from a camera of its own
48
+ * (scene.createView as a component): the same meshes, materials, lights
49
+ * and shadow maps, another target and one entry per mesh, so it costs no
50
+ * per-frame JS. Composites as an ordinary `<texture>` leaf at the target
51
+ * size (a tile of `into` shown through srcX/srcY), or through `output`.
52
+ * Camera-control children (`<OrbitCamera>`, `<FirstPersonCamera>`,
53
+ * `<PerspectiveCamera>`) drive the VIEW's camera: inside, `useScene()`
54
+ * reports the view as `viewport` and the view leaf's feed as `pointer`,
55
+ * so a map over that feed moves this view alone. Node children (`<Mesh>`) mount
56
+ * to the scene as they would outside - a view mirrors the scene's meshes,
57
+ * it has none of its own. Nodes under the view's leaf get their ordinary
58
+ * pointer handlers, picked through the view's camera; the view's own
59
+ * `onPointer*`/`onWheel`/`onTap` are the last stop of that walk
60
+ * (`event.mesh` null over empty space).
61
+ */
62
+ export let View3d: ParentComponent<View3dProps> = props => {
63
+ let ctx = useContext(SceneContext)
64
+ // A tile of `into` or a target of its own is decided at mount, like
65
+ // Scene's fill/fixed: `into` cannot appear or disappear.
66
+ let tiled = untrack(() => props.into !== undefined)
67
+ // The initial props seed createView, so `ref` hands out a configured
68
+ // view and the first frame draws it whole; the effects follow changes
69
+ // only (`defer`).
70
+ let view = untrack(() => {
71
+ let v = ctx.scene.createView({
72
+ width: props.width,
73
+ height: props.height,
74
+ x: props.x,
75
+ y: props.y,
76
+ layers: props.layers,
77
+ clearColor: props.clearColor,
78
+ label: props.label,
79
+ overrideMaterial: props.overrideMaterial,
80
+ fog: props.fog,
81
+ depth: props.depth,
82
+ samples: props.samples,
83
+ filter: props.filter,
84
+ wrap: props.wrap,
85
+ into: props.into,
86
+ })
87
+ if (props.camera) v.setCamera(props.camera)
88
+ return v
89
+ })
90
+ createEffect(
91
+ () => props.camera,
92
+ camera => {
93
+ if (camera) view.setCamera(camera)
94
+ },
95
+ { defer: true },
96
+ )
97
+ createEffect(
98
+ () => [props.x, props.y, props.width, props.height] as const,
99
+ ([x, y, width, height]) => {
100
+ if (tiled) view.setRect({ x: x ?? 0, y: y ?? 0, width, height })
101
+ else view.setSize(width, height)
102
+ },
103
+ { defer: true },
104
+ )
105
+ createEffect(
106
+ () => props.layers,
107
+ l => view.setLayers(l ?? 1),
108
+ { defer: true },
109
+ )
110
+ untrack(() => props.ref)?.(view)
111
+ onCleanup(() => view.dispose())
112
+ // The pointer feed the app handed in (see Scene) listens at the view's
113
+ // root; children reach it through useScene().pointer.
114
+ let pointer = untrack(() => props.pointer) ?? null
115
+ if (pointer) onCleanup(feedPointer(view, pointer))
116
+ // The view's own handlers at the root of the walk; the props are read
117
+ // per event, so a handler prop may change without re-registering.
118
+ onCleanup(
119
+ view.listen({
120
+ onPointerDown: e => props.onPointerDown?.(e),
121
+ onPointerMove: e => props.onPointerMove?.(e),
122
+ onPointerUp: e => props.onPointerUp?.(e),
123
+ onWheel: e => props.onWheel?.(e),
124
+ onTap: e => props.onTap?.(e),
125
+ }),
126
+ )
127
+ let output = untrack(() => props.output)
128
+ let events = untrack(() => props.events) !== false
129
+ return (
130
+ <SceneContext value={{ scene: ctx.scene, parent: ctx.parent, viewport: view, pointer }}>
131
+ {output ? (
132
+ untrack(() => output(view.texture))
133
+ ) : (
134
+ <texture
135
+ src={tiled ? props.into : view.texture}
136
+ width={props.width}
137
+ height={props.height}
138
+ srcX={tiled ? (props.x ?? 0) : undefined}
139
+ srcY={tiled ? (props.y ?? 0) : undefined}
140
+ srcW={tiled ? props.width : undefined}
141
+ srcH={tiled ? props.height : undefined}
142
+ onPointerDown={events ? view.handlers.onPointerDown : undefined}
143
+ onPointerMove={events ? view.handlers.onPointerMove : undefined}
144
+ onPointerUp={events ? view.handlers.onPointerUp : undefined}
145
+ onPointerLeave={events ? view.handlers.onPointerLeave : undefined}
146
+ onWheel={events ? view.handlers.onWheel : undefined}
147
+ />
148
+ )}
149
+ {props.children}
150
+ </SceneContext>
151
+ )
152
+ }
@@ -1,59 +1,53 @@
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.
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.
6
11
  //
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.
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.
20
21
  //
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.
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.
29
28
  //
30
29
  // Collision is deliberately absent: a camera control cannot know the
31
30
  // level. Clamp or reject positions through `clampPosition`.
32
31
  //
33
32
  // 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().
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.
40
39
 
41
- import { createMemo, createSignal } from "@solidjs/signals"
42
- import { createTransform, gamepads, pointerLocked } from "@solidrt/core"
43
- import type { KeyEvent, PointerEvent } from "@solidrt/core"
40
+ import { untrack } from "@solidjs/signals"
41
+ import { createAxes } from "@solidrt/core/input"
42
+ import type { Axes, Vec2 } from "@solidrt/core/input"
44
43
  import type { CameraUpdate } from "./camera.ts"
45
44
  import type { Vec3 } from "./math.ts"
46
45
 
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
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).
54
48
  const DRAG_TURNS = 0.5
55
- const LOOK_STICK = 2.5
56
- const STICK_DEADZONE = 0.15
49
+ // Look rate at full stick deflection, turns per second.
50
+ const LOOK_RATE = 0.4
57
51
  // Default walking speed, world units per second.
58
52
  const MOVE_SPEED = 3
59
53
  // Pitch clamps stop short of the poles so the look direction never
@@ -80,17 +74,14 @@ export type FirstPersonCameraOptions = {
80
74
  maxPitch?: number
81
75
  /** Movement speed in world units per second (default 3). */
82
76
  moveSpeed?: number
83
- /** Multiplier over the built-in mouse, drag and stick look rates. */
77
+ /** Multiplier over the built-in look sensitivities (drags, mouse motion
78
+ * and rates alike). */
84
79
  lookSpeed?: number
85
80
  /** 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. */
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. */
89
84
  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
85
  /** Constrain where a move may put the eye: called with the eye the
95
86
  * move asks for and the eye it starts from (both fresh arrays), returns
96
87
  * the position to use - a level's bounds, a floor height, a collision
@@ -105,6 +96,8 @@ export type FirstPersonPose = {
105
96
  pitch?: number
106
97
  }
107
98
 
99
+ export type FirstPersonAxes = { look: "vec2"; move: "vec2"; rise: "axis" }
100
+
108
101
  export type FirstPersonCamera = {
109
102
  /** Eye position (a fresh array per call). */
110
103
  eye(): Vec3
@@ -112,195 +105,154 @@ export type FirstPersonCamera = {
112
105
  forward(): Vec3
113
106
  /** Pose snapshot - the shape debug commands return and set() takes. */
114
107
  pose(): { position: Vec3; yaw: number; pitch: number }
115
- /** Merge a pose in (clamps apply); reaches the scene at the next
116
- * update(). */
108
+ /** Merge a pose in (clamps apply) and push it. */
117
109
  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). */
110
+ /** Whether any axis rate reads non-zero - what a frame loop should run
111
+ * on. Reactive. */
120
112
  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. */
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). */
123
116
  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
- }
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
134
128
  }
135
129
 
136
- type Move = "forward" | "back" | "left" | "right" | "up" | "down"
130
+ let clampNum = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v))
137
131
 
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",
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}`)
147
134
  }
148
135
 
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
136
  /**
153
137
  * Create a first-person camera driving `camera`'s position and target,
154
138
  * where `camera` is a Scene or one of its Views (fov, near, and far stay
155
139
  * 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.
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.
159
143
  */
160
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)")
161
146
  let position: Vec3 = options.position ? [options.position[0], options.position[1], options.position[2]] : [0, 1.6, 0]
162
147
  let yaw = options.yaw ?? 0
163
148
  let pitch = options.pitch ?? 0
164
149
  // Everything below the pose is read from `options` where it applies.
165
150
  let lookSpeed = () => options.lookSpeed ?? 1
151
+ let moveSpeed = () => options.moveSpeed ?? MOVE_SPEED
166
152
  let clampPitch = () => {
167
153
  pitch = clampNum(pitch, options.minPitch ?? -PITCH_LIMIT, options.maxPitch ?? PITCH_LIMIT)
168
154
  }
169
-
170
- let held = new Set<Move>()
171
- let [heldCount, setHeldCount] = createSignal(0)
172
- let dirty = false
155
+ let changed = false
173
156
 
174
157
  let forward = (): Vec3 => {
175
158
  let cp = Math.cos(pitch)
176
159
  return [-Math.sin(yaw) * cp, Math.sin(pitch), -Math.cos(yaw) * cp]
177
160
  }
178
- let apply = () => {
161
+ let push = () => {
162
+ changed = true
179
163
  let f = forward()
180
164
  camera.setCamera({ position: [position[0], position[1], position[2]], target: [position[0] + f[0], position[1] + f[1], position[2] + f[2]] })
181
165
  }
182
- let look = (dx: number, dy: number) => {
183
- yaw -= dx
184
- pitch -= dy
166
+ let look = (dYaw: number, dPitch: number) => {
167
+ yaw += dYaw
168
+ pitch += dPitch
185
169
  clampPitch()
186
- dirty = true
187
170
  }
188
- let moveBy = (dx: number, dy: number, dz: number) => {
189
- let next: Vec3 = [position[0] + dx, position[1] + dy, position[2] + dz]
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]
190
184
  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
185
  }
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
186
 
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
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
+ },
221
203
  },
222
- })
204
+ )
223
205
 
224
206
  clampPitch()
225
- apply()
207
+ push()
208
+ changed = false
226
209
 
227
210
  return {
228
211
  eye: () => [position[0], position[1], position[2]],
229
212
  forward,
230
213
  pose: () => ({ position: [position[0], position[1], position[2]], yaw, pitch }),
231
- active,
214
+ active: axes.active,
215
+ axes,
232
216
  set(pose) {
233
217
  if (pose.position) position = [pose.position[0], pose.position[1], pose.position[2]]
234
218
  if (pose.yaw !== undefined) yaw = pose.yaw
235
219
  if (pose.pitch !== undefined) pitch = pose.pitch
236
220
  clampPitch()
237
- dirty = true
221
+ push()
238
222
  },
239
223
  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)
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
244
231
  }
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)
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
265
238
  }
266
- if (!dirty) return false
267
- dirty = false
268
- apply()
269
- return true
239
+ if (moved) push()
240
+ let result = changed
241
+ changed = false
242
+ return result
270
243
  },
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
- },
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()
304
256
  },
305
257
  }
306
258
  }