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
+162 -12
View File
@@ -2,15 +2,85 @@ import type { Scene } from "./Scene";
export type NodeOptions = {
position?: ArrayLike<number>;
quaternion?: ArrayLike<number>;
rotor?: ArrayLike<number>;
scale?: ArrayLike<number>;
parent?: Node;
};
function vector(value: ArrayLike<number>, width: number, name: string) {
if (value.length !== width || Array.from(value).some((lane) => !Number.isFinite(lane)))
const lanes = Array.from(value);
if (lanes.length !== width || lanes.some((lane) => !Number.isFinite(lane)))
throw new TypeError(name);
return value;
return lanes;
}
/** Three shared transform components. Component writes go straight to the backing SAB row. */
export interface Vector3 extends Iterable<number> {
[lane: number]: number;
readonly length: 3;
x: number;
y: number;
z: number;
toArray(): [number, number, number];
}
/** Four shared rotor components in `[x, y, z, w]` order. */
export interface Rotor extends Iterable<number> {
[lane: number]: number;
readonly length: 4;
x: number;
y: number;
z: number;
w: number;
toArray(): [number, number, number, number];
}
abstract class XYZView {
[lane: number]: number;
abstract readonly length: 3 | 4;
constructor(
private readonly row: () => Float32Array,
private readonly changed: () => void,
) {}
protected read(lane: number) { return this.row()[lane]; }
protected write(lane: number, value: number) {
this.row()[lane] = value;
this.changed();
}
get 0() { return this.read(0); }
set 0(value: number) { this.write(0, value); }
get 1() { return this.read(1); }
set 1(value: number) { this.write(1, value); }
get 2() { return this.read(2); }
set 2(value: number) { this.write(2, value); }
get x() { return this.read(0); }
set x(value: number) { this.write(0, value); }
get y() { return this.read(1); }
set y(value: number) { this.write(1, value); }
get z() { return this.read(2); }
set z(value: number) { this.write(2, value); }
abstract toArray(): number[];
[Symbol.iterator]() { return this.toArray().values(); }
}
class Vector3View extends XYZView implements Vector3 {
readonly length = 3;
toArray(): [number, number, number] { return [this.x, this.y, this.z]; }
}
class RotorView extends XYZView implements Rotor {
readonly length = 4;
get 3() { return this.read(3); }
set 3(value: number) { this.write(3, value); }
get w() { return this.read(3); }
set w(value: number) { this.write(3, value); }
toArray(): [number, number, number, number] {
return [this.x, this.y, this.z, this.w];
}
}
/** A thin index into transform SOA rows; transform changes never post a worker message. */
@@ -19,14 +89,26 @@ export class Node {
id = -1;
ready: Promise<this>;
protected disposed = false;
readonly #position = new Vector3View(
() => this.#row("nodePositions").subarray(0, 3),
() => this.transformChanged(),
);
readonly #rotor = new RotorView(
() => this.#row("nodeRotors").subarray(0, 4),
() => this.transformChanged(),
);
readonly #scale = new Vector3View(
() => this.#row("nodeScales").subarray(0, 3),
() => this.transformChanged(),
);
constructor(scene: Scene, options: NodeOptions = {}) {
this.scene = scene;
this.ready = scene.allocateNode().then((id) => {
this.id = id;
if (options.position) this.position = options.position;
if (options.quaternion) this.quaternion = options.quaternion;
if (options.scale) this.scale = options.scale;
if (options.position) this.setPosition(options.position);
if (options.rotor) this.setRotor(options.rotor);
if (options.scale) this.setScale(options.scale);
if (options.parent) this.parent = options.parent;
return this;
});
@@ -37,14 +119,82 @@ export class Node {
return this.scene.array(name).row(this.id);
}
get position(): Float32Array { return this.#row("nodePositions").subarray(0, 3); }
set position(value: ArrayLike<number>) { this.#row("nodePositions").set(vector(value, 3, "position")); }
get position(): Vector3 { return this.#position; }
set position(value: ArrayLike<number>) { this.setPosition(value); }
get quaternion(): Float32Array { return this.#row("nodeQuaternions").subarray(0, 4); }
set quaternion(value: ArrayLike<number>) { this.#row("nodeQuaternions").set(vector(value, 4, "quaternion")); }
get rotor(): Rotor { return this.#rotor; }
set rotor(value: ArrayLike<number>) { this.setRotor(value); }
get scale(): Float32Array { return this.#row("nodeScales").subarray(0, 3); }
set scale(value: ArrayLike<number>) { this.#row("nodeScales").set(vector(value, 3, "scale")); }
get scale(): Vector3 { return this.#scale; }
set scale(value: ArrayLike<number>) { this.setScale(value); }
/** Replaces all position components with one SAB row write. */
setPosition(value: ArrayLike<number>) {
this.#row("nodePositions").set(vector(value, 3, "position"));
this.transformChanged();
return this;
}
/** Replaces all rotor components with one SAB row write. */
setRotor(value: ArrayLike<number>) {
this.#row("nodeRotors").set(vector(value, 4, "rotor"));
this.transformChanged();
return this;
}
/** Replaces all scale components with one SAB row write. */
setScale(value: ArrayLike<number>) {
this.#row("nodeScales").set(vector(value, 3, "scale"));
this.transformChanged();
return this;
}
/** Adds an offset to the shared position. */
translate(offset: ArrayLike<number>) {
const [x, y, z] = vector(offset, 3, "translation");
return this.setPosition([
this.position.x + x,
this.position.y + y,
this.position.z + z,
]);
}
/** Composes a rotor, or an axis and angle in radians, onto the current rotor. */
rotate(rotor: ArrayLike<number>): this;
rotate(axis: ArrayLike<number>, radians: number): this;
rotate(value: ArrayLike<number>, radians?: number) {
let rotation: number[];
if (radians === undefined) {
rotation = vector(value, 4, "rotor");
} else {
if (!Number.isFinite(radians)) throw new TypeError("radians");
const [x, y, z] = vector(value, 3, "axis");
const length = Math.hypot(x, y, z);
if (!length) throw new RangeError("axis");
const sine = Math.sin(radians / 2) / length;
rotation = [x * sine, y * sine, z * sine, Math.cos(radians / 2)];
}
const [ax, ay, az, aw] = this.rotor;
const [bx, by, bz, bw] = rotation;
const result = [
aw * bx + ax * bw + ay * bz - az * by,
aw * by - ax * bz + ay * bw + az * bx,
aw * bz + ax * by - ay * bx + az * bw,
aw * bw - ax * bx - ay * by - az * bz,
];
const length = Math.hypot(...result);
if (!length) throw new RangeError("rotor");
return this.setRotor(result.map((lane) => lane / length));
}
/** Rotates around the node's local X axis. */
rotateX(radians: number) { return this.rotate([1, 0, 0], radians); }
/** Rotates around the node's local Y axis. */
rotateY(radians: number) { return this.rotate([0, 1, 0], radians); }
/** Rotates around the node's local Z axis. */
rotateZ(radians: number) { return this.rotate([0, 0, 1], radians); }
protected transformChanged() {}
get enabled() { return this.#row("nodes")[0] !== 0; }
set enabled(value: boolean) { this.#row("nodes")[0] = value ? 1 : 0; }
+41 -17
View File
@@ -33,7 +33,7 @@ type TextureState = GraphTexture & {
const rows = [
["nodes", 16, "u32"],
["nodePositions", 16, "f32"],
["nodeQuaternions", 16, "f32"],
["nodeRotors", 16, "f32"],
["nodeScales", 16, "f32"],
["meshInfo", 16, "u32"],
["bounds", 32, "f32"],
@@ -104,21 +104,21 @@ struct VertexOutput {
@group(0) @binding(0) var<storage, read> clusters: array<u32>;
@group(0) @binding(1) var<uniform> accent: Accent;
@group(0) @binding(2) var<storage, read> positions: array<vec4<f32>>;
@group(0) @binding(3) var<storage, read> quaternions: array<vec4<f32>>;
@group(0) @binding(3) var<storage, read> rotors: array<vec4<f32>>;
@group(0) @binding(4) var<storage, read> scales: array<vec4<f32>>;
@group(0) @binding(5) var<storage, read> meshInfo: array<u32>;
@group(0) @binding(6) var<storage, read> materials: array<vec4<f32>>;
@group(0) @binding(7) var<storage, read> cameraMatrices: array<vec4<f32>>;
fn rotate(q: vec4<f32>, value: vec3<f32>) -> vec3<f32> {
return value + 2.0 * cross(q.xyz, cross(q.xyz, value) + q.w * value);
fn rotate(rotor: vec4<f32>, value: vec3<f32>) -> vec3<f32> {
return value + 2.0 * cross(rotor.xyz, cross(rotor.xyz, value) + rotor.w * value);
}
@vertex
fn vertex(@location(0) point: vec3<f32>, @builtin(instance_index) packed: u32) -> VertexOutput {
let instance = packed & 65535u;
let visible = meshInfo[instance * 4u + 2u];
let transformed = rotate(quaternions[instance], point * scales[instance].xyz) + positions[instance].xyz;
let transformed = rotate(rotors[instance], point * scales[instance].xyz) + positions[instance].xyz;
var clip = vec4<f32>(transformed, 1.0);
if (cameraMatrices[4].w != 0.0) {
let world = vec4(transformed, 1.0);
@@ -131,7 +131,7 @@ fn vertex(@location(0) point: vec3<f32>, @builtin(instance_index) packed: u32) -
}
var output: VertexOutput;
output.position = select(vec4<f32>(2.0, 2.0, 2.0, 1.0), clip, visible != 0u);
output.normal = normalize(rotate(quaternions[instance], vec3<f32>(0.0, 0.0, 1.0)));
output.normal = normalize(rotate(rotors[instance], vec3<f32>(0.0, 0.0, 1.0)));
output.mesh = instance;
output.material = packed >> 16u;
return output;
@@ -168,7 +168,7 @@ struct VertexOutput {
@group(0) @binding(0) var<storage, read> clusters: array<u32>;
@group(0) @binding(1) var<uniform> accent: vec4<f32>;
@group(0) @binding(2) var<storage, read> positions: array<vec4<f32>>;
@group(0) @binding(3) var<storage, read> quaternions: array<vec4<f32>>;
@group(0) @binding(3) var<storage, read> rotors: array<vec4<f32>>;
@group(0) @binding(4) var<storage, read> scales: array<vec4<f32>>;
@group(0) @binding(5) var<storage, read> meshInfo: array<u32>;
@group(0) @binding(6) var<storage, read> materials: array<vec4<f32>>;
@@ -178,8 +178,8 @@ ${baseTexture ? "@group(1) @binding(1) var baseTexture: texture_2d<f32>;" : ""}
${materialTexture ? "@group(1) @binding(2) var materialTexture: texture_2d<f32>;" : ""}
${normalTexture ? "@group(1) @binding(3) var normalTexture: texture_2d<f32>;" : ""}
fn rotate(q: vec4<f32>, value: vec3<f32>) -> vec3<f32> {
return value + 2.0 * cross(q.xyz, cross(q.xyz, value) + q.w * value);
fn rotate(rotor: vec4<f32>, value: vec3<f32>) -> vec3<f32> {
return value + 2.0 * cross(rotor.xyz, cross(rotor.xyz, value) + rotor.w * value);
}
@vertex
@@ -192,7 +192,7 @@ fn vertex(
) -> VertexOutput {
let instance = packed & 65535u;
let scale = scales[instance].xyz;
let world = rotate(quaternions[instance], point * scale) + positions[instance].xyz;
let world = rotate(rotors[instance], point * scale) + positions[instance].xyz;
var clip = vec4<f32>(world, 1.0);
if (cameraMatrices[4].w != 0.0) {
let homogeneous = vec4(world, 1.0);
@@ -206,9 +206,9 @@ fn vertex(
var output: VertexOutput;
output.position = select(vec4<f32>(2.0, 2.0, 2.0, 1.0), clip, meshInfo[instance * 4u + 2u] != 0u);
output.world = world;
output.normal = normalize(rotate(quaternions[instance], localNormal / scale));
output.normal = normalize(rotate(rotors[instance], localNormal / scale));
output.uv = uv;
output.tangent = ${normalTexture ? "vec4(normalize(rotate(quaternions[instance], localTangent.xyz * scale)), localTangent.w)" : "vec4(1.0, 0.0, 0.0, 1.0)"};
output.tangent = ${normalTexture ? "vec4(normalize(rotate(rotors[instance], localTangent.xyz * scale)), localTangent.w)" : "vec4(1.0, 0.0, 0.0, 1.0)"};
output.mesh = instance;
output.material = packed >> 16u;
return output;
@@ -351,6 +351,9 @@ export class Scene {
#nextTexture = 0;
#graphBatchDepth = 0;
#graphBatchDirty = false;
#writeBatchDepth = 0;
#writeBatchDirty = false;
#writeBatchBundleDirty = false;
#signals?: Float32Array;
#arrays = new WeakMap<object, object>();
#views = new WeakMap<object, object>();
@@ -378,7 +381,7 @@ export class Scene {
stride: 16,
format: "u32",
});
this.core.array("nodeQuaternions").write(0, [0, 0, 0, 1]);
this.core.array("nodeRotors").write(0, [0, 0, 0, 1]);
this.core.array("nodeScales").write(0, [1, 1, 1, 0]);
this.core.array("sceneAccent").write(0, [0.28, 0.72, 1, 1]);
const material = await this.core.allocateObject("materials");
@@ -444,11 +447,32 @@ export class Scene {
}
markDirty(bundle = false) {
if (this.#writeBatchDepth) {
this.#writeBatchDirty = true;
this.#writeBatchBundleDirty ||= bundle;
return;
}
if (!this.#signals) return;
this.#signals[5] = 1;
if (bundle) this.#signals[6] = 1;
}
/** Defers the dirty signal until a synchronous group of SAB writes is complete. */
batchWrites<T>(operation: () => T) {
this.#writeBatchDepth++;
try {
return operation();
} finally {
this.#writeBatchDepth--;
if (!this.#writeBatchDepth && this.#writeBatchDirty) {
const bundle = this.#writeBatchBundleDirty;
this.#writeBatchDirty = false;
this.#writeBatchBundleDirty = false;
this.markDirty(bundle);
}
}
}
async ensureRows(
name: string,
rowCount: number,
@@ -535,7 +559,7 @@ export class Scene {
: [];
});
if (growth.length) await this.core.createRowsBatch(growth);
this.array("nodeQuaternions").write(id, [0, 0, 0, 1]);
this.array("nodeRotors").write(id, [0, 0, 0, 1]);
this.array("nodeScales").write(id, [1, 1, 1, 0]);
this.array("nodes").write(id, [1, 0, 0, 0]);
return id;
@@ -545,7 +569,7 @@ export class Scene {
for (const name of [
"nodes",
"nodePositions",
"nodeQuaternions",
"nodeRotors",
"nodeScales",
"meshInfo",
"bounds",
@@ -835,7 +859,7 @@ export class Scene {
} else {
for (const [id, array] of [
["node-positions", "nodePositions"],
["node-quaternions", "nodeQuaternions"],
["node-rotors", "nodeRotors"],
["node-scales", "nodeScales"],
["mesh-info", "meshInfo"],
["materials", "materials"],
@@ -1014,7 +1038,7 @@ export class Scene {
"clusters",
"accent",
"node-positions",
"node-quaternions",
"node-rotors",
"node-scales",
"mesh-info",
"materials",
+1 -1
View File
@@ -38,7 +38,7 @@ export class Picking {
"signals",
"nodes",
"nodePositions",
"nodeQuaternions",
"nodeRotors",
"nodeScales",
"meshInfo",
"bounds",
+13 -13
View File
@@ -52,27 +52,27 @@ function build(boxes: Box[]): Branch | undefined {
};
}
function rotate(quaternion: number[], value: number[]) {
const [qx, qy, qz, qw] = quaternion;
function rotate(rotor: number[], value: number[]) {
const [rx, ry, rz, rw] = rotor;
const [x, y, z] = value;
const tx = 2 * (qy * z - qz * y);
const ty = 2 * (qz * x - qx * z);
const tz = 2 * (qx * y - qy * x);
const tx = 2 * (ry * z - rz * y);
const ty = 2 * (rz * x - rx * z);
const tz = 2 * (rx * y - ry * x);
return [
x + qw * tx + qy * tz - qz * ty,
y + qw * ty + qz * tx - qx * tz,
z + qw * tz + qx * ty - qy * tx,
x + rw * tx + ry * tz - rz * ty,
y + rw * ty + rz * tx - rx * tz,
z + rw * tz + rx * ty - ry * tx,
];
}
function rebuild() {
const bounds = view("bounds");
const positions = view("nodePositions");
const quaternions = view("nodeQuaternions");
const rotors = view("nodeRotors");
const scales = view("nodeScales");
const meshes = view("meshInfo");
const nodes = view("nodes");
if (!bounds || !positions || !quaternions || !scales || !meshes || !nodes)
if (!bounds || !positions || !rotors || !scales || !meshes || !nodes)
return;
const count = shares.bounds.descriptor.rows;
const boxes: Box[] = [];
@@ -82,8 +82,8 @@ function rebuild() {
const transform = id * 4;
const min = [Infinity, Infinity, Infinity];
const max = [-Infinity, -Infinity, -Infinity];
const quaternion = [0, 1, 2, 3].map((lane) =>
Number(quaternions[transform + lane]),
const rotor = [0, 1, 2, 3].map((lane) =>
Number(rotors[transform + lane]),
);
for (let corner = 0; corner < 8; corner++) {
const local = [0, 1, 2].map(
@@ -91,7 +91,7 @@ function rebuild() {
Number(bounds[offset + (corner & (1 << lane) ? 4 : 0) + lane]) *
Number(scales[transform + lane]),
);
const rotated = rotate(quaternion, local);
const rotated = rotate(rotor, local);
for (let lane = 0; lane < 3; lane++) {
const value = rotated[lane] + Number(positions[transform + lane]);
min[lane] = Math.min(min[lane], value);
+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);
};
+1 -1
View File
@@ -74,7 +74,7 @@ export async function importGltf(scene: Scene, url: string | URL) {
(primitive: any) =>
new Mesh(scene, {
position: primitive.position,
quaternion: primitive.quaternion,
rotor: primitive.rotor,
scale: primitive.scale,
material: materials[primitive.material],
vertexData: {
+18 -18
View File
@@ -29,16 +29,16 @@ function component(view: DataView, offset: number, type: number) {
throw new Error("GLTF_COMPONENT");
}
function rotate(quaternion: number[], value: number[]) {
const [qx, qy, qz, qw] = quaternion;
function rotate(rotor: number[], value: number[]) {
const [rx, ry, rz, rw] = rotor;
const [x, y, z] = value;
const tx = 2 * (qy * z - qz * y);
const ty = 2 * (qz * x - qx * z);
const tz = 2 * (qx * y - qy * x);
const tx = 2 * (ry * z - rz * y);
const ty = 2 * (rz * x - rx * z);
const tz = 2 * (rx * y - ry * x);
return [
x + qw * tx + qy * tz - qz * ty,
y + qw * ty + qz * tx - qx * tz,
z + qw * tz + qx * ty - qy * tx,
x + rw * tx + ry * tz - rz * ty,
y + rw * ty + rz * tx - rx * tz,
z + rw * tz + rx * ty - ry * tx,
];
}
@@ -61,21 +61,21 @@ function decompose(matrix: number[]) {
const [m00, m01, m02] = [matrix[0] / sx, matrix[4] / sy, matrix[8] / sz];
const [m10, m11, m12] = [matrix[1] / sx, matrix[5] / sy, matrix[9] / sz];
const [m20, m21, m22] = [matrix[2] / sx, matrix[6] / sy, matrix[10] / sz];
let quaternion: number[];
let rotor: number[];
if (m00 + m11 + m22 > 0) {
const s = Math.sqrt(1 + m00 + m11 + m22) * 2;
quaternion = [(m21 - m12) / s, (m02 - m20) / s, (m10 - m01) / s, s / 4];
rotor = [(m21 - m12) / s, (m02 - m20) / s, (m10 - m01) / s, s / 4];
} else if (m00 > m11 && m00 > m22) {
const s = Math.sqrt(1 + m00 - m11 - m22) * 2;
quaternion = [s / 4, (m01 + m10) / s, (m02 + m20) / s, (m21 - m12) / s];
rotor = [s / 4, (m01 + m10) / s, (m02 + m20) / s, (m21 - m12) / s];
} else if (m11 > m22) {
const s = Math.sqrt(1 + m11 - m00 - m22) * 2;
quaternion = [(m01 + m10) / s, s / 4, (m12 + m21) / s, (m02 - m20) / s];
rotor = [(m01 + m10) / s, s / 4, (m12 + m21) / s, (m02 - m20) / s];
} else {
const s = Math.sqrt(1 + m22 - m00 - m11) * 2;
quaternion = [(m02 + m20) / s, (m12 + m21) / s, s / 4, (m10 - m01) / s];
rotor = [(m02 + m20) / s, (m12 + m21) / s, s / 4, (m10 - m01) / s];
}
return { position: matrix.slice(12, 15), quaternion, scale };
return { position: matrix.slice(12, 15), rotor, scale };
}
function transform(node: any) {
@@ -83,21 +83,21 @@ function transform(node: any) {
? decompose(node.matrix)
: {
position: node.translation ?? [0, 0, 0],
quaternion: node.rotation ?? [0, 0, 0, 1],
rotor: node.rotation ?? [0, 0, 0, 1],
scale: node.scale ?? [1, 1, 1],
};
}
function compose(parent: any, local: any) {
const position = rotate(
parent.quaternion,
parent.rotor,
local.position.map(
(value: number, lane: number) => value * parent.scale[lane],
),
);
return {
position: position.map((value, lane) => value + parent.position[lane]),
quaternion: multiply(parent.quaternion, local.quaternion),
rotor: multiply(parent.rotor, local.rotor),
scale: local.scale.map(
(value: number, lane: number) => value * parent.scale[lane],
),
@@ -275,7 +275,7 @@ async function load(url: string) {
id: number,
parent = {
position: [0, 0, 0],
quaternion: [0, 0, 0, 1],
rotor: [0, 0, 0, 1],
scale: [1, 1, 1],
},
) => {