Add SAB-backed rotor transforms and smooth camera controls

Amp-Thread-ID: https://ampcode.com/threads/T-01a01ff8-b91f-724f-8952-f07c6b5042fd
Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
This commit is contained in:
Amp
2026-08-21 05:30:09 +00:00
co-authored by heaust
parent 0e6d7e367c
commit 9ca12c237e
17 changed files with 411 additions and 141 deletions
+80 -27
View File
@@ -46,18 +46,29 @@ export class ArcRotateCamera extends Camera {
}
get alpha() { return this.cameraRow()[13]; }
set alpha(value: number) { this.cameraRow()[13] = value; this.#updateTransform(); }
set alpha(value: number) {
const row = this.cameraRow();
this.#updateTransform(() => { row[13] = value; });
}
get beta() { return this.cameraRow()[14]; }
set beta(value: number) { this.cameraRow()[14] = Math.min(Math.PI - 0.001, Math.max(0.001, value)); this.#updateTransform(); }
set beta(value: number) {
const row = this.cameraRow();
this.#updateTransform(() => {
row[14] = Math.min(Math.PI - 0.001, Math.max(0.001, value));
});
}
get radius() { return this.cameraRow()[15]; }
set radius(value: number) { this.cameraRow()[15] = Math.max(0.01, value); this.#updateTransform(); }
set radius(value: number) {
const row = this.cameraRow();
this.#updateTransform(() => { row[15] = Math.max(0.01, value); });
}
get target() { return this.#target; }
set target(value: Node | undefined) {
if (value && (value.scene !== this.scene || value.id < 0)) throw new Error("Target must be a ready Node in this Scene");
this.#target = value;
this.cameraRow()[12] = value ? value.id + 1 : 0;
this.#updateTransform();
const row = this.cameraRow();
this.#updateTransform(() => { row[12] = value ? value.id + 1 : 0; });
}
attachControls(controls: ArcRotateControls) {
@@ -67,6 +78,8 @@ export class ArcRotateCamera extends Camera {
controls.element.addEventListener("pointerdown", this.#down);
controls.element.addEventListener("pointermove", this.#move);
controls.element.addEventListener("pointerup", this.#up);
controls.element.addEventListener("pointercancel", this.#up);
controls.element.addEventListener("contextmenu", this.#contextMenu);
controls.element.addEventListener("wheel", this.#wheel, { passive: false });
}
if (controls.controller) this.#frame = requestAnimationFrame(this.#pollController);
@@ -79,6 +92,8 @@ export class ArcRotateCamera extends Camera {
element.removeEventListener("pointerdown", this.#down);
element.removeEventListener("pointermove", this.#move);
element.removeEventListener("pointerup", this.#up);
element.removeEventListener("pointercancel", this.#up);
element.removeEventListener("contextmenu", this.#contextMenu);
element.removeEventListener("wheel", this.#wheel);
}
cancelAnimationFrame(this.#frame);
@@ -88,26 +103,38 @@ export class ArcRotateCamera extends Camera {
}
#targetPosition() {
return this.#target ? this.#target.position : this.cameraRow().subarray(16, 19);
const row = this.cameraRow();
if (!this.#target) return row.subarray(16, 19);
const position = this.#target.position;
return [
position.x + row[16],
position.y + row[17],
position.z + row[18],
];
}
#updateTransform() {
#updateTransform(change?: () => void) {
if (this.id < 0 || this.cameraId < 0) return;
const target = this.#targetPosition();
const sinBeta = Math.sin(this.beta);
this.position = [
target[0] + this.radius * sinBeta * Math.sin(this.alpha),
target[1] + this.radius * Math.cos(this.beta),
target[2] + this.radius * sinBeta * Math.cos(this.alpha),
];
this.lookAt(target);
this.batchTransformChanges(() => {
change?.();
const target = this.#targetPosition();
const sinBeta = Math.sin(this.beta);
this.setPosition([
target[0] + this.radius * sinBeta * Math.sin(this.alpha),
target[1] + this.radius * Math.cos(this.beta),
target[2] + this.radius * sinBeta * Math.cos(this.alpha),
]);
this.lookAt(target);
});
}
#down = (event: PointerEvent) => {
event.preventDefault();
this.#pointer = { x: event.clientX, y: event.clientY, button: event.button };
this.#controls?.element.setPointerCapture?.(event.pointerId);
};
#up = () => { this.#pointer = undefined; };
#contextMenu = (event: Event) => event.preventDefault();
#move = (event: PointerEvent) => {
if (!this.#pointer || !this.#controls) return;
const x = event.clientX - this.#pointer.x;
@@ -115,28 +142,54 @@ export class ArcRotateCamera extends Camera {
this.#pointer.x = event.clientX;
this.#pointer.y = event.clientY;
if (this.#pointer.button === 2) {
const target = this.#targetPosition();
target[0] -= x * (this.#controls.panSpeed ?? 0.005);
target[1] += y * (this.#controls.panSpeed ?? 0.005);
this.#updateTransform();
const row = this.cameraRow();
this.#updateTransform(() => {
row[16] -= x * (this.#controls?.panSpeed ?? 0.005);
row[17] += y * (this.#controls?.panSpeed ?? 0.005);
});
} else {
const speed = this.#controls.orbitSpeed ?? 0.005;
this.cameraRow()[13] -= x * speed;
this.cameraRow()[14] = Math.min(Math.PI - 0.001, Math.max(0.001, this.beta + y * speed));
this.#updateTransform();
const row = this.cameraRow();
this.#updateTransform(() => {
row[13] -= x * speed;
row[14] = Math.min(
Math.PI - 0.001,
Math.max(0.001, row[14] + y * speed),
);
});
}
};
#wheel = (event: WheelEvent) => {
event.preventDefault();
this.radius *= Math.exp(event.deltaY * (this.#controls?.zoomSpeed ?? 0.001));
const row = this.cameraRow();
this.#updateTransform(() => {
row[15] = Math.max(
0.01,
row[15] * Math.exp(event.deltaY * (this.#controls?.zoomSpeed ?? 0.001)),
);
});
};
#pollController = () => {
const pad = navigator.getGamepads?.().find(Boolean);
if (pad) {
this.cameraRow()[13] += (pad.axes[2] ?? pad.axes[0] ?? 0) * 0.03;
this.cameraRow()[14] = Math.min(Math.PI - 0.001, Math.max(0.001, this.beta + (pad.axes[3] ?? pad.axes[1] ?? 0) * 0.03));
this.radius += ((pad.buttons[6]?.value ?? 0) - (pad.buttons[7]?.value ?? 0)) * 0.1;
this.#updateTransform();
const row = this.cameraRow();
this.#updateTransform(() => {
row[13] += (pad.axes[2] ?? pad.axes[0] ?? 0) * 0.03;
row[14] = Math.min(
Math.PI - 0.001,
Math.max(
0.001,
row[14] + (pad.axes[3] ?? pad.axes[1] ?? 0) * 0.03,
),
);
row[15] = Math.max(
0.01,
row[15] +
((pad.buttons[6]?.value ?? 0) -
(pad.buttons[7]?.value ?? 0)) *
0.1,
);
});
}
this.#frame = requestAnimationFrame(this.#pollController);
};
+44 -30
View File
@@ -1,15 +1,15 @@
import { Node, type NodeOptions } from "../Node";
import type { Scene } from "../Scene";
function rotate(q: ArrayLike<number>, value: number[]) {
function rotate(rotor: ArrayLike<number>, value: number[]) {
const [x, y, z] = value;
const tx = 2 * (q[1] * z - q[2] * y);
const ty = 2 * (q[2] * x - q[0] * z);
const tz = 2 * (q[0] * y - q[1] * x);
const tx = 2 * (rotor[1] * z - rotor[2] * y);
const ty = 2 * (rotor[2] * x - rotor[0] * z);
const tz = 2 * (rotor[0] * y - rotor[1] * x);
return [
x + q[3] * tx + q[1] * tz - q[2] * ty,
y + q[3] * ty + q[2] * tx - q[0] * tz,
z + q[3] * tz + q[0] * ty - q[1] * tx,
x + rotor[3] * tx + rotor[1] * tz - rotor[2] * ty,
y + rotor[3] * ty + rotor[2] * tx - rotor[0] * tz,
z + rotor[3] * tz + rotor[0] * ty - rotor[1] * tx,
];
}
@@ -34,6 +34,8 @@ export type CameraOptions = NodeOptions & {
export class Camera extends Node {
cameraId = -1;
#cameraDisposed = false;
#transformBatchDepth = 0;
#transformBatchDirty = false;
constructor(scene: Scene, options: CameraOptions = {}) {
super(scene, options);
@@ -90,43 +92,55 @@ export class Camera extends Node {
get sensorWidth() { return this.cameraRow()[10]; }
set sensorWidth(value: number) { this.cameraRow()[10] = value; }
override get position(): Float32Array { return super.position; }
override set position(value: ArrayLike<number>) {
super.position = value;
this.refreshMatrix();
}
override get quaternion(): Float32Array { return super.quaternion; }
override set quaternion(value: ArrayLike<number>) {
super.quaternion = value;
this.refreshMatrix();
}
lookAt(target: ArrayLike<number>) {
if (target.length !== 3) throw new RangeError("camera target");
const position = this.position;
const x = target[0] - position[0];
const y = target[1] - position[1];
const z = target[2] - position[2];
const x = target[0] - position.x;
const y = target[1] - position.y;
const z = target[2] - position.z;
const length = Math.hypot(x, y, z) || 1;
const direction = [x / length, y / length, z / length];
if (direction[2] > 0.999999) this.quaternion = [0, 1, 0, 0];
if (direction[2] > 0.999999) this.setRotor([0, 1, 0, 0]);
else {
const q = [direction[1], -direction[0], 0, 1 - direction[2]];
const qLength = Math.hypot(...q);
this.quaternion = q.map((lane) => lane / qLength);
const rotor = [direction[1], -direction[0], 0, 1 - direction[2]];
const rotorLength = Math.hypot(...rotor);
this.setRotor(rotor.map((lane) => lane / rotorLength));
}
return this;
}
protected override transformChanged() {
if (this.#transformBatchDepth) {
this.#transformBatchDirty = true;
return;
}
this.refreshMatrix();
}
/** Publishes one final camera matrix after a synchronous group of transform writes. */
protected batchTransformChanges<T>(operation: () => T) {
return this.scene.batchWrites(() => {
this.#transformBatchDepth++;
try {
return operation();
} finally {
this.#transformBatchDepth--;
if (!this.#transformBatchDepth && this.#transformBatchDirty) {
this.#transformBatchDirty = false;
this.refreshMatrix();
}
}
});
}
protected refreshMatrix() {
if (this.id < 0 || this.cameraId < 0) return;
const camera = this.cameraRow();
const position = this.position;
const quaternion = this.quaternion;
const right = rotate(quaternion, [1, 0, 0]);
const up = rotate(quaternion, [0, 1, 0]);
const forward = rotate(quaternion, [0, 0, 1]);
const rotor = this.rotor;
const right = rotate(rotor, [1, 0, 0]);
const up = rotate(rotor, [0, 1, 0]);
const forward = rotate(rotor, [0, 0, 1]);
let rows: number[][];
if (camera[5] === 1) {
const size = Math.max(camera[6], 0.0001);
+6 -3
View File
@@ -62,15 +62,18 @@ export class FollowCamera extends Camera {
#snap() {
const target = this.#target.position;
this.position = [target[0], target[1] + this.height, target[2] + this.distance];
this.setPosition([target.x, target.y + this.height, target.z + this.distance]);
this.lookAt(target);
}
#follow = () => {
const target = this.#target.position;
const desired = [target[0], target[1] + this.height, target[2] + this.distance];
const position = this.position;
for (let lane = 0; lane < 3; lane++) position[lane] += (desired[lane] - position[lane]) * this.#smoothing;
this.setPosition([
position.x + (target.x - position.x) * this.#smoothing,
position.y + (target.y + this.height - position.y) * this.#smoothing,
position.z + (target.z + this.distance - position.z) * this.#smoothing,
]);
this.lookAt(target);
this.#frame = requestAnimationFrame(this.#follow);
};
+9 -7
View File
@@ -64,15 +64,15 @@ export class FreeCamera extends Camera {
const sensitivity = this.#controls.sensitivity ?? 0.002;
this.cameraRow()[13] -= event.movementX * sensitivity;
this.cameraRow()[14] = Math.min(1.55, Math.max(-1.55, this.cameraRow()[14] - event.movementY * sensitivity));
this.#writeQuaternion();
this.#writeRotor();
};
#writeQuaternion() {
#writeRotor() {
const yaw = this.cameraRow()[13];
const pitch = this.cameraRow()[14];
const sy = Math.sin(yaw / 2), cy = Math.cos(yaw / 2);
const sx = Math.sin(pitch / 2), cx = Math.cos(pitch / 2);
this.quaternion = [sx * cy, cx * sy, -sx * sy, cx * cy];
this.setRotor([sx * cy, cx * sy, -sx * sy, cx * cy]);
}
#update = (time: number) => {
@@ -88,10 +88,12 @@ export class FreeCamera extends Camera {
}
const speed = (this.#controls.speed ?? 4) * delta;
const yaw = this.cameraRow()[13];
this.position[0] += (x * Math.cos(yaw) + z * Math.sin(yaw)) * speed;
this.position[1] += y * speed;
this.position[2] += (x * -Math.sin(yaw) + z * Math.cos(yaw)) * speed;
this.refreshMatrix();
const position = this.position;
this.setPosition([
position.x + (x * Math.cos(yaw) + z * Math.sin(yaw)) * speed,
position.y + y * speed,
position.z + (x * -Math.sin(yaw) + z * Math.cos(yaw)) * speed,
]);
this.#frame = requestAnimationFrame(this.#update);
};