Skip to content
Source
Sign inSign up

deviceMotion.ts

267 lines8.9 KB

1import { Quaternion, Vector3 } from "three";
2​
3/** q and -q represent the same orientation. */
4function rotationVector(rotation: Quaternion) {
5 const q = rotation.clone().normalize();
6 if (q.w < 0) q.set(-q.x, -q.y, -q.z, -q.w);
7 const length = Math.hypot(q.x, q.y, q.z);
8 return length < 1e-8
9 ? new Vector3()
10 : new Vector3(q.x, q.y, q.z).multiplyScalar((2 * Math.atan2(length, q.w)) / length);
12function fromVector(vector: Vector3) {
13 const angle = vector.length();
14 return angle < 1e-8
15 ? new Quaternion()
16 : new Quaternion().setFromAxisAngle(vector.clone().divideScalar(angle), angle);
18​
19const gain = 0.006;
20const response = 0.5;
21const damping = 0.78;
22const frequency = (2 * Math.PI) / response;
23const decay = frequency * damping;
24const stepSeconds = 1 / 120;
25const flickThreshold = 4.5;
26const flickLimit = 22;
27const spinDecay = 1.1;
28const settleFrequency = 2;
29​
30export function createDeviceMotion(options: { choose: (rotation: Quaternion) => Quaternion }) {
31 const rotation = new Quaternion();
32 const target = new Quaternion();
33 const velocity = new Vector3();
34 const gestureVelocity = new Vector3();
35 let spring: { at: number; rotation: Quaternion; velocity: Vector3; steps: number } | null = null;
36 let spin: { at: number; rotation: Quaternion; velocity: Vector3; correction: Vector3 } | null =
37 null;
38 let drag: {
39 start: Quaternion;
40 rest: Quaternion;
41 x: number;
42 y: number;
43 error: Vector3;
44 at: number;
45 } | null = null;
46 let held = false;
47 let interruptedDrag = false;
48 let lastInput = -Infinity;
49 const beginSpring = (next: Quaternion, now: number) => {
50 target.copy(next).normalize();
51 spring = {
52 at: now,
53 rotation: rotation.clone(),
54 velocity: velocity.clone(),
55 steps: 0,
56 };
57 };
58 const release = (now: number) => {
59 if (!drag) return;
60 if (now - lastInput > 100) gestureVelocity.set(0, 0, 0);
61 const prediction = target
62 .clone()
63 .premultiply(fromVector(gestureVelocity.clone().multiplyScalar(0.085)));
64 velocity.multiplyScalar(0.2).addScaledVector(gestureVelocity, 0.8).clampLength(0, flickLimit);
65 const moved = drag.x !== 0 || drag.y !== 0;
66 const rest = drag.rest;
67 drag = null;
68 if (moved && velocity.length() >= flickThreshold) {
69 const projected = rotation
70 .clone()
71 .premultiply(fromVector(velocity.clone().multiplyScalar(1 / spinDecay)));
72 target.copy(options.choose(projected)).normalize();
73 spin = {
74 at: now,
75 rotation: rotation.clone(),
76 velocity: velocity.clone(),
77 correction: rotationVector(target.clone().multiply(projected.invert())),
78 };
79 return;
80 }
81 if (moved) beginSpring(options.choose(prediction), now);
82 else beginSpring(rest, now);
83 };
84 const advance = (now: number, reduced = false) => {
85 if (held || !Number.isFinite(now)) return false;
86 if (spin) {
87 const seconds = Math.max(0, (now - spin.at) / 1000);
88 const coast = Math.exp(-spinDecay * seconds);
89 const settle = Math.exp(-settleFrequency * seconds);
90 const correction = fromVector(
91 spin.correction.clone().multiplyScalar(1 - (1 + settleFrequency * seconds) * settle),
92 );
93 rotation
94 .copy(spin.rotation)
95 .premultiply(fromVector(spin.velocity.clone().multiplyScalar((1 - coast) / spinDecay)))
96 .premultiply(correction)
97 .normalize();
98 velocity
99 .copy(spin.velocity)
100 .multiplyScalar(coast)
101 .applyQuaternion(correction)
102 .addScaledVector(spin.correction, settleFrequency ** 2 * seconds * settle);
103 if (reduced || (rotation.angleTo(target) < 0.01 && velocity.length() < 0.08)) {
104 spin = null;
105 rotation.copy(target);
106 velocity.set(0, 0, 0);
107 }
108 return true;
109 }
110 if (drag) {
111 // Follow the target during the gesture. Retain the logarithm's winding so
112 // a long drag cannot reverse its spring force when crossing a half turn.
113 const seconds = Math.min(0.05, Math.max(0, (now - drag.at) / 1000));
114 drag.at = now;
115 const steps = Math.ceil(seconds * 120);
116 const dt = steps ? seconds / steps : 0;
117 for (let step = 0; step < steps; step++) {
118 const error = rotationVector(target.clone().multiply(rotation.clone().invert()));
119 if (error.lengthSq() > 1e-10) {
120 const axis = error.clone().normalize();
121 const turns = Math.round((drag.error.dot(axis) - error.length()) / (2 * Math.PI));
122 error.addScaledVector(axis, turns * 2 * Math.PI);
123 }
124 drag.error.copy(error);
125 const omega = (2 * Math.PI) / 0.68;
126 const acceleration = error
127 .clampLength(0, 1.2)
128 .multiplyScalar(omega * omega)
129 .addScaledVector(velocity, -2 * 1.12 * omega);
130 velocity.addScaledVector(acceleration, dt).clampLength(0, 9);
131 rotation.premultiply(fromVector(velocity.clone().multiplyScalar(dt))).normalize();
132 }
133 if (reduced) {
134 rotation.copy(target);
135 velocity.set(0, 0, 0);
136 }
137 return steps > 0 || reduced;
138 }
139 if (!spring) return false;
140 const seconds = Math.max(0, (now - spring.at) / 1000);
141 const steps = Math.min(240, Math.floor(seconds / stepSeconds));
142 const integrate = (q: Quaternion, speed: Vector3, dt: number) => {
143 const error = rotationVector(target.clone().multiply(q.clone().invert()));
144 const acceleration = error
145 .multiplyScalar(frequency * frequency)
146 .addScaledVector(speed, -2 * decay);
147 speed.addScaledVector(acceleration, dt).clampLength(0, 9);
148 q.premultiply(fromVector(speed.clone().multiplyScalar(dt))).normalize();
149 };
150 // Fixed ticks make release independent of display refresh rate. The final
151 // partial tick interpolates the next tick without committing it.
152 for (; spring.steps < steps; spring.steps++)
153 integrate(spring.rotation, spring.velocity, stepSeconds);
154 rotation.copy(spring.rotation);
155 velocity.copy(spring.velocity);
156 const nextRotation = spring.rotation.clone();
157 const nextVelocity = spring.velocity.clone();
158 integrate(nextRotation, nextVelocity, stepSeconds);
159 const fraction = Math.min(1, (seconds - steps * stepSeconds) / stepSeconds);
160 rotation.slerp(nextRotation, fraction);
161 velocity.lerp(nextVelocity, fraction);
162 if (
163 reduced ||
164 steps === 240 ||
165 (rotation.angleTo(target) < 0.0005 && velocity.length() < 0.005)
166 ) {
167 rotation.copy(target);
168 velocity.set(0, 0, 0);
169 spring = null;
170 }
171 return true;
172 };
173 const beginDrag = (now: number) => {
174 if (held || drag) return;
175 advance(now);
176 drag = {
177 start: rotation.clone(),
178 rest: target.clone(),
179 x: 0,
180 y: 0,
181 error: new Vector3(),
182 at: now,
183 };
184 target.copy(rotation);
185 gestureVelocity.set(0, 0, 0);
186 lastInput = -Infinity;
187 spring = null;
188 spin = null;
189 };
190 return {
191 rotation,
192 setPose(next: Quaternion, now: number, immediate = false) {
193 advance(now);
194 drag = null;
195 spin = null;
196 beginSpring(next, now);
197 if (immediate) {
198 rotation.copy(target);
199 velocity.set(0, 0, 0);
200 spring = null;
201 }
202 },
203 dragActive(active: boolean, now: number) {
204 if (active) {
205 if (drag || spin) {
206 advance(now);
207 drag = null;
208 spin = null;
209 }
210 beginDrag(now);
211 } else {
212 advance(now);
213 release(now);
214 }
215 },
216 orbit(x: number, y: number, now: number) {
217 if (held || ![x, y, now].every(Number.isFinite) || (!x && !y)) return;
218 advance(now);
219 beginDrag(now);
220 if (!drag) return;
221 const previous = target.clone();
222 drag.x += x;
223 drag.y += y;
224 target
225 .copy(fromVector(new Vector3(drag.y * gain, drag.x * gain, 0)))
226 .multiply(drag.start)
227 .normalize();
228 const seconds = (now - lastInput) / 1000;
229 gestureVelocity.copy(rotationVector(target.clone().multiply(previous.invert())));
230 if (seconds > 0 && seconds < 0.1)
231 gestureVelocity.divideScalar(seconds).clampLength(0, flickLimit);
232 else gestureVelocity.set(0, 0, 0);
233 lastInput = now;
234 },
235 hold(active: boolean, now: number) {
236 if (held === active) return;
237 if (active) {
238 interruptedDrag = drag !== null || spin !== null;
239 drag = null;
240 spin = null;
241 spring = null;
242 velocity.set(0, 0, 0);
243 }
244 held = active;
245 if (!active) {
246 if (interruptedDrag) beginSpring(options.choose(rotation.clone()), now);
247 else if (rotation.angleTo(target) > 0.0005) beginSpring(target, now);
248 interruptedDrag = false;
249 }
250 },
251 advance,
252 needsFrame() {
253 return (
254 !held &&
255 (spring !== null ||
256 spin !== null ||
257 (drag !== null && (rotation.angleTo(target) > 0.0005 || velocity.length() > 0.005)))
258 );
259 },
260 reset(next: Quaternion, now: number) {
261 advance(now);
262 drag = null;
263 spin = null;
264 beginSpring(next, now);
265 },
266 };