diff --git a/addons/handles/src/Node.ts b/addons/handles/src/Node.ts index 93c1c5d..373391f 100644 --- a/addons/handles/src/Node.ts +++ b/addons/handles/src/Node.ts @@ -2,15 +2,85 @@ import type { Scene } from "./Scene"; export type NodeOptions = { position?: ArrayLike; - quaternion?: ArrayLike; + rotor?: ArrayLike; scale?: ArrayLike; parent?: Node; }; function vector(value: ArrayLike, 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 { + [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 { + [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; 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) { this.#row("nodePositions").set(vector(value, 3, "position")); } + get position(): Vector3 { return this.#position; } + set position(value: ArrayLike) { this.setPosition(value); } - get quaternion(): Float32Array { return this.#row("nodeQuaternions").subarray(0, 4); } - set quaternion(value: ArrayLike) { this.#row("nodeQuaternions").set(vector(value, 4, "quaternion")); } + get rotor(): Rotor { return this.#rotor; } + set rotor(value: ArrayLike) { this.setRotor(value); } - get scale(): Float32Array { return this.#row("nodeScales").subarray(0, 3); } - set scale(value: ArrayLike) { this.#row("nodeScales").set(vector(value, 3, "scale")); } + get scale(): Vector3 { return this.#scale; } + set scale(value: ArrayLike) { this.setScale(value); } + + /** Replaces all position components with one SAB row write. */ + setPosition(value: ArrayLike) { + this.#row("nodePositions").set(vector(value, 3, "position")); + this.transformChanged(); + return this; + } + + /** Replaces all rotor components with one SAB row write. */ + setRotor(value: ArrayLike) { + this.#row("nodeRotors").set(vector(value, 4, "rotor")); + this.transformChanged(); + return this; + } + + /** Replaces all scale components with one SAB row write. */ + setScale(value: ArrayLike) { + this.#row("nodeScales").set(vector(value, 3, "scale")); + this.transformChanged(); + return this; + } + + /** Adds an offset to the shared position. */ + translate(offset: ArrayLike) { + 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): this; + rotate(axis: ArrayLike, radians: number): this; + rotate(value: ArrayLike, 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; } diff --git a/addons/handles/src/Scene.ts b/addons/handles/src/Scene.ts index 929c58f..797f5e6 100644 --- a/addons/handles/src/Scene.ts +++ b/addons/handles/src/Scene.ts @@ -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 clusters: array; @group(0) @binding(1) var accent: Accent; @group(0) @binding(2) var positions: array>; -@group(0) @binding(3) var quaternions: array>; +@group(0) @binding(3) var rotors: array>; @group(0) @binding(4) var scales: array>; @group(0) @binding(5) var meshInfo: array; @group(0) @binding(6) var materials: array>; @group(0) @binding(7) var cameraMatrices: array>; -fn rotate(q: vec4, value: vec3) -> vec3 { - return value + 2.0 * cross(q.xyz, cross(q.xyz, value) + q.w * value); +fn rotate(rotor: vec4, value: vec3) -> vec3 { + return value + 2.0 * cross(rotor.xyz, cross(rotor.xyz, value) + rotor.w * value); } @vertex fn vertex(@location(0) point: vec3, @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(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, @builtin(instance_index) packed: u32) - } var output: VertexOutput; output.position = select(vec4(2.0, 2.0, 2.0, 1.0), clip, visible != 0u); - output.normal = normalize(rotate(quaternions[instance], vec3(0.0, 0.0, 1.0))); + output.normal = normalize(rotate(rotors[instance], vec3(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 clusters: array; @group(0) @binding(1) var accent: vec4; @group(0) @binding(2) var positions: array>; -@group(0) @binding(3) var quaternions: array>; +@group(0) @binding(3) var rotors: array>; @group(0) @binding(4) var scales: array>; @group(0) @binding(5) var meshInfo: array; @group(0) @binding(6) var materials: array>; @@ -178,8 +178,8 @@ ${baseTexture ? "@group(1) @binding(1) var baseTexture: texture_2d;" : ""} ${materialTexture ? "@group(1) @binding(2) var materialTexture: texture_2d;" : ""} ${normalTexture ? "@group(1) @binding(3) var normalTexture: texture_2d;" : ""} -fn rotate(q: vec4, value: vec3) -> vec3 { - return value + 2.0 * cross(q.xyz, cross(q.xyz, value) + q.w * value); +fn rotate(rotor: vec4, value: vec3) -> vec3 { + 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(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(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(); #views = new WeakMap(); @@ -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(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", diff --git a/addons/handles/src/bvh/picking.ts b/addons/handles/src/bvh/picking.ts index b6ca5f4..3de37ba 100644 --- a/addons/handles/src/bvh/picking.ts +++ b/addons/handles/src/bvh/picking.ts @@ -38,7 +38,7 @@ export class Picking { "signals", "nodes", "nodePositions", - "nodeQuaternions", + "nodeRotors", "nodeScales", "meshInfo", "bounds", diff --git a/addons/handles/src/bvh/worker.ts b/addons/handles/src/bvh/worker.ts index be84624..f22f716 100644 --- a/addons/handles/src/bvh/worker.ts +++ b/addons/handles/src/bvh/worker.ts @@ -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); diff --git a/addons/handles/src/camera/ArcRotateCamera.ts b/addons/handles/src/camera/ArcRotateCamera.ts index 05b1415..e5e1835 100644 --- a/addons/handles/src/camera/ArcRotateCamera.ts +++ b/addons/handles/src/camera/ArcRotateCamera.ts @@ -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); }; diff --git a/addons/handles/src/camera/Camera.ts b/addons/handles/src/camera/Camera.ts index 07c79c5..501cbe5 100644 --- a/addons/handles/src/camera/Camera.ts +++ b/addons/handles/src/camera/Camera.ts @@ -1,15 +1,15 @@ import { Node, type NodeOptions } from "../Node"; import type { Scene } from "../Scene"; -function rotate(q: ArrayLike, value: number[]) { +function rotate(rotor: ArrayLike, 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) { - super.position = value; - this.refreshMatrix(); - } - - override get quaternion(): Float32Array { return super.quaternion; } - override set quaternion(value: ArrayLike) { - super.quaternion = value; - this.refreshMatrix(); - } - lookAt(target: ArrayLike) { 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(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); diff --git a/addons/handles/src/camera/FollowCamera.ts b/addons/handles/src/camera/FollowCamera.ts index c112898..264b2a0 100644 --- a/addons/handles/src/camera/FollowCamera.ts +++ b/addons/handles/src/camera/FollowCamera.ts @@ -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); }; diff --git a/addons/handles/src/camera/FreeCamera.ts b/addons/handles/src/camera/FreeCamera.ts index 5a551ab..4ec9ca5 100644 --- a/addons/handles/src/camera/FreeCamera.ts +++ b/addons/handles/src/camera/FreeCamera.ts @@ -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); }; diff --git a/addons/handles/src/importers/gltf.ts b/addons/handles/src/importers/gltf.ts index 520865a..1e66324 100644 --- a/addons/handles/src/importers/gltf.ts +++ b/addons/handles/src/importers/gltf.ts @@ -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: { diff --git a/addons/handles/src/importers/worker.ts b/addons/handles/src/importers/worker.ts index 5987c9e..b6ebcbb 100644 --- a/addons/handles/src/importers/worker.ts +++ b/addons/handles/src/importers/worker.ts @@ -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], }, ) => { diff --git a/docs/.vitepress/Playground.vue b/docs/.vitepress/Playground.vue index 8a80fdf..394bd53 100644 --- a/docs/.vitepress/Playground.vue +++ b/docs/.vitepress/Playground.vue @@ -496,6 +496,7 @@ canvas { height: 100%; display: block; object-fit: contain; + touch-action: none; } .output { position: absolute; diff --git a/docs/.vitepress/playgrounds.js b/docs/.vitepress/playgrounds.js index 2927a0a..71b30a6 100644 --- a/docs/.vitepress/playgrounds.js +++ b/docs/.vitepress/playgrounds.js @@ -61,8 +61,8 @@ log("Pointer movement mutates nodePositions; no worker message is sent."); const move = (event) => { const bounds = canvas.getBoundingClientRect(); - mesh.position[0] = ((event.clientX - bounds.left) / bounds.width - 0.5) * 1.4; - mesh.position[1] = (0.5 - (event.clientY - bounds.top) / bounds.height) * 1.2; + mesh.position.x = ((event.clientX - bounds.left) / bounds.width - 0.5) * 1.4; + mesh.position.y = (0.5 - (event.clientY - bounds.top) / bounds.height) * 1.2; }; canvas.addEventListener("pointermove", move); @@ -270,13 +270,35 @@ log("HDR → exposure → color grading → FXAA → canvas"); return { scene, mesh, exposure, grade, fxaa, dispose: () => scene.dispose() };`; -const importing = `import { ArcRotateCamera, Picking, Scene, importGltf } from "@yawn/handles"; +const importing = `import { AmbientLight, ArcRotateCamera, PointLight, Picking, Scene, importGltf } from "@yawn/handles"; const scene = new Scene(canvas, { hdr: true, arenaBytes: 384 * 1024 * 1024 }); await scene.ready; +scene.array("sceneAccent").row(0).set([1, 1, 1, 1]); log("Importing /models/sponza.glb in the importer worker…"); const meshes = await importGltf(scene, "/models/sponza.glb"); +// Add a warm ambient/key/fill setup without changing the authored transforms. +const lights = [ + new AmbientLight(scene, { + color: [1, 0.93, 0.82], + intensity: 0.18, + }), + new PointLight(scene, { + position: [-10, 14, -4], + color: [1, 0.72, 0.5], + intensity: 2, + range: 28, + }), + new PointLight(scene, { + position: [9, 10, 6], + color: [1, 0.9, 0.75], + intensity: 1.5, + range: 24, + }), +]; +await Promise.all(lights.map((light) => light.ready)); + const target = [0, 8, 0]; const camera = new ArcRotateCamera(scene, { targetPosition: target, @@ -305,6 +327,7 @@ await pick(); return { scene, meshes, + lights, camera, picking, async dispose() { diff --git a/docs/guide/cameras.md b/docs/guide/cameras.md index e431315..2a3b75d 100644 --- a/docs/guide/cameras.md +++ b/docs/guide/cameras.md @@ -72,7 +72,7 @@ follow.stop(); follow.start(); ``` -The input and follow loops never post camera updates to core: they mutate the position, quaternion, camera, and derived matrix rows directly in shared memory. +The input and follow loops never post camera updates to core: they mutate the position, rotor, camera, and derived matrix rows directly in shared memory. diff --git a/docs/guide/importing-and-picking.md b/docs/guide/importing-and-picking.md index 0a1c19e..7caf0e6 100644 --- a/docs/guide/importing-and-picking.md +++ b/docs/guide/importing-and-picking.md @@ -8,7 +8,7 @@ The shared importer worker fetches and parses glTF/GLB, then the response hydrat import { importGltf } from "@yawn/handles"; const meshes = await importGltf(scene, "/models/sponza.glb"); -meshes[0].position[1] = 0.5; +meshes[0].position.y = 0.5; ``` The importer handles triangle primitives, external/data buffers, standard vertex attributes, indices, node transforms, and metallic-roughness values. Application-specific extensions remain application policy. @@ -34,7 +34,7 @@ If row allocations relocated since `Picking` was created, refresh its shared des await picking.refresh(); ``` -The playground below imports the repository's full LFS-backed `sponza.glb` in the importer worker, preserves its authored transforms, places an arc camera in its coordinate system, and sends a real ray to the BVH worker. Click the preview to pick again. +The playground below imports the repository's full LFS-backed `sponza.glb` in the importer worker, preserves its authored transforms, adds neutral warm lighting, places an arc camera in its coordinate system, and sends a real ray to the BVH worker. Click the preview to pick again. diff --git a/docs/guide/lights.md b/docs/guide/lights.md index 1908661..d3d86ea 100644 --- a/docs/guide/lights.md +++ b/docs/guide/lights.md @@ -11,7 +11,7 @@ const point = new PointLight(scene, { }); const sun = new DirectionalLight(scene, { - quaternion: [0.2, 0, 0, 0.98], + rotor: [0.2, 0, 0, 0.98], color: [1, 0.95, 0.8], intensity: 3, }); diff --git a/docs/guide/scene-and-sab.md b/docs/guide/scene-and-sab.md index e0e85c0..4e891ae 100644 --- a/docs/guide/scene-and-sab.md +++ b/docs/guide/scene-and-sab.md @@ -1,6 +1,6 @@ # Scene and shared data -Think of every handle as an array index, not an object mirrored into core. `Node.position`, `Node.quaternion`, and `Node.scale` are views into separate flat SOA arrays. +Think of every handle as an array index, not an object mirrored into core. `Node.position`, `Node.rotor`, and `Node.scale` are component views into separate flat SOA arrays. ## Direct transform movement @@ -11,12 +11,12 @@ const pivot = new Node(scene, { position: [0, 1, 0] }); await pivot.ready; canvas.addEventListener("pointermove", (event) => { - pivot.position[0] += event.movementX * 0.002; - pivot.position[1] -= event.movementY * 0.002; + pivot.position.x += event.movementX * 0.002; + pivot.position.y -= event.movementY * 0.002; }); ``` -The pointer handler sends no messages. The typed-array view points directly into the arena shared with the render worker. The camera helpers use this same pattern; see [Cameras and controls](/guide/cameras). +The pointer handler sends no messages. Each component property reads or writes its lane in the arena shared with the render worker. Replace complete transforms with `setPosition([x, y, z])`, `setRotor([x, y, z, w])`, and `setScale([x, y, z])`. `translate(...)`, `rotate(...)`, and `rotateX/Y/Z(...)` are convenience methods over those same SAB lanes. The camera helpers use this same pattern; see [Cameras and controls](/guide/cameras). diff --git a/docs/index.md b/docs/index.md index f3b922f..b4ab8c4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -39,7 +39,7 @@ const mesh = new Mesh(scene, { }); await mesh.ready; -mesh.position[0] = 0.25; // direct SAB mutation +mesh.position.x = 0.25; // direct SAB mutation ``` `Scene` installs one HDR clustered-forward loadout. Adding compute, custom shaders, textures, or post effects rebuilds that same loadout; changing values already present in shared rows does not send a message.