Replace addons with conventional handles
Provide the single-loadout Scene API, SAB-backed meshes, cameras, materials, lights, workers, post effects, and tutorial playgrounds. Batch matching row growth so active GPU loadouts refresh once. Amp-Thread-ID: https://ampcode.com/threads/T-01a01380-b478-77d0-84a0-102880a5c5ae Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
import type { Node } from "../Node";
|
||||
import type { Scene } from "../Scene";
|
||||
import { Camera, type CameraOptions } from "./Camera";
|
||||
|
||||
export type ArcRotateControls = {
|
||||
element: HTMLElement;
|
||||
pointer?: boolean;
|
||||
controller?: boolean;
|
||||
orbitSpeed?: number;
|
||||
panSpeed?: number;
|
||||
zoomSpeed?: number;
|
||||
};
|
||||
export type ArcRotateCameraOptions = CameraOptions & {
|
||||
target?: Node;
|
||||
targetPosition?: ArrayLike<number>;
|
||||
alpha?: number;
|
||||
beta?: number;
|
||||
radius?: number;
|
||||
controls?: ArcRotateControls;
|
||||
};
|
||||
|
||||
/** Orbit/pan/zoom camera whose controls mutate only its transform and camera SAB rows. */
|
||||
export class ArcRotateCamera extends Camera {
|
||||
#target?: Node;
|
||||
#controls?: ArcRotateControls;
|
||||
#pointer?: { x: number; y: number; button: number };
|
||||
#frame = 0;
|
||||
|
||||
constructor(scene: Scene, options: ArcRotateCameraOptions = {}) {
|
||||
super(scene, options);
|
||||
this.#target = options.target;
|
||||
const cameraReady = this.ready;
|
||||
this.ready = cameraReady.then(async () => {
|
||||
if (this.#target) await this.#target.ready;
|
||||
const row = this.cameraRow();
|
||||
row[12] = this.#target ? this.#target.id + 1 : 0;
|
||||
row[13] = options.alpha ?? 0;
|
||||
row[14] = options.beta ?? Math.PI / 3;
|
||||
row[15] = options.radius ?? 5;
|
||||
row.set(Array.from(options.targetPosition ?? [0, 0, 0]), 16);
|
||||
row[19] = 1;
|
||||
this.#updateTransform();
|
||||
if (options.controls) this.attachControls(options.controls);
|
||||
return this;
|
||||
});
|
||||
}
|
||||
|
||||
get alpha() { return this.cameraRow()[13]; }
|
||||
set alpha(value: number) { this.cameraRow()[13] = value; this.#updateTransform(); }
|
||||
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(); }
|
||||
get radius() { return this.cameraRow()[15]; }
|
||||
set radius(value: number) { this.cameraRow()[15] = Math.max(0.01, value); this.#updateTransform(); }
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
attachControls(controls: ArcRotateControls) {
|
||||
this.detachControls();
|
||||
this.#controls = controls;
|
||||
if (controls.pointer !== false) {
|
||||
controls.element.addEventListener("pointerdown", this.#down);
|
||||
controls.element.addEventListener("pointermove", this.#move);
|
||||
controls.element.addEventListener("pointerup", this.#up);
|
||||
controls.element.addEventListener("wheel", this.#wheel, { passive: false });
|
||||
}
|
||||
if (controls.controller) this.#frame = requestAnimationFrame(this.#pollController);
|
||||
return this;
|
||||
}
|
||||
|
||||
detachControls() {
|
||||
const element = this.#controls?.element;
|
||||
if (element) {
|
||||
element.removeEventListener("pointerdown", this.#down);
|
||||
element.removeEventListener("pointermove", this.#move);
|
||||
element.removeEventListener("pointerup", this.#up);
|
||||
element.removeEventListener("wheel", this.#wheel);
|
||||
}
|
||||
cancelAnimationFrame(this.#frame);
|
||||
this.#frame = 0;
|
||||
this.#pointer = undefined;
|
||||
this.#controls = undefined;
|
||||
}
|
||||
|
||||
#targetPosition() {
|
||||
return this.#target ? this.#target.position : this.cameraRow().subarray(16, 19);
|
||||
}
|
||||
|
||||
#updateTransform() {
|
||||
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);
|
||||
}
|
||||
|
||||
#down = (event: PointerEvent) => {
|
||||
this.#pointer = { x: event.clientX, y: event.clientY, button: event.button };
|
||||
this.#controls?.element.setPointerCapture?.(event.pointerId);
|
||||
};
|
||||
#up = () => { this.#pointer = undefined; };
|
||||
#move = (event: PointerEvent) => {
|
||||
if (!this.#pointer || !this.#controls) return;
|
||||
const x = event.clientX - this.#pointer.x;
|
||||
const y = event.clientY - this.#pointer.y;
|
||||
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();
|
||||
} 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();
|
||||
}
|
||||
};
|
||||
#wheel = (event: WheelEvent) => {
|
||||
event.preventDefault();
|
||||
this.radius *= 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();
|
||||
}
|
||||
this.#frame = requestAnimationFrame(this.#pollController);
|
||||
};
|
||||
|
||||
override async dispose() {
|
||||
this.detachControls();
|
||||
await super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Node, type NodeOptions } from "../Node";
|
||||
import type { Scene } from "../Scene";
|
||||
|
||||
export type CameraOptions = NodeOptions & {
|
||||
fov?: number;
|
||||
near?: number;
|
||||
far?: number;
|
||||
aspect?: number;
|
||||
projection?: "perspective" | "orthographic";
|
||||
orthoSize?: number;
|
||||
focalLength?: number;
|
||||
aperture?: number;
|
||||
focusDistance?: number;
|
||||
sensorWidth?: number;
|
||||
};
|
||||
|
||||
/** Conventional camera data; core only sees another generically allocated shared row. */
|
||||
export class Camera extends Node {
|
||||
cameraId = -1;
|
||||
#cameraDisposed = false;
|
||||
|
||||
constructor(scene: Scene, options: CameraOptions = {}) {
|
||||
super(scene, options);
|
||||
const nodeReady = this.ready;
|
||||
this.ready = nodeReady.then(async () => {
|
||||
this.cameraId = await scene.core.allocateObject("cameras");
|
||||
const row = scene.array("cameras").row(this.cameraId);
|
||||
row.set([
|
||||
options.fov ?? Math.PI / 3,
|
||||
options.aspect ?? 1,
|
||||
options.near ?? 0.1,
|
||||
options.far ?? 1000,
|
||||
this.id,
|
||||
options.projection === "orthographic" ? 1 : 0,
|
||||
options.orthoSize ?? 10,
|
||||
options.focalLength ?? 50,
|
||||
options.aperture ?? 2.8,
|
||||
options.focusDistance ?? 10,
|
||||
options.sensorWidth ?? 36,
|
||||
1,
|
||||
]);
|
||||
return this;
|
||||
});
|
||||
}
|
||||
|
||||
protected cameraRow() {
|
||||
if (this.cameraId < 0) throw new Error("Await camera.ready before reading or writing it");
|
||||
return this.scene.array("cameras").row(this.cameraId);
|
||||
}
|
||||
|
||||
get fov() { return this.cameraRow()[0]; }
|
||||
set fov(value: number) { this.cameraRow()[0] = value; }
|
||||
get aspect() { return this.cameraRow()[1]; }
|
||||
set aspect(value: number) { this.cameraRow()[1] = value; }
|
||||
get near() { return this.cameraRow()[2]; }
|
||||
set near(value: number) { this.cameraRow()[2] = value; }
|
||||
get far() { return this.cameraRow()[3]; }
|
||||
set far(value: number) { this.cameraRow()[3] = value; }
|
||||
get projection() { return this.cameraRow()[5] === 1 ? "orthographic" : "perspective"; }
|
||||
set projection(value: "perspective" | "orthographic") { this.cameraRow()[5] = value === "orthographic" ? 1 : 0; }
|
||||
get orthoSize() { return this.cameraRow()[6]; }
|
||||
set orthoSize(value: number) { this.cameraRow()[6] = value; }
|
||||
get focalLength() { return this.cameraRow()[7]; }
|
||||
set focalLength(value: number) { this.cameraRow()[7] = value; }
|
||||
get aperture() { return this.cameraRow()[8]; }
|
||||
set aperture(value: number) { this.cameraRow()[8] = value; }
|
||||
get focusDistance() { return this.cameraRow()[9]; }
|
||||
set focusDistance(value: number) { this.cameraRow()[9] = value; }
|
||||
get sensorWidth() { return this.cameraRow()[10]; }
|
||||
set sensorWidth(value: number) { this.cameraRow()[10] = value; }
|
||||
|
||||
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 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];
|
||||
else {
|
||||
const q = [direction[1], -direction[0], 0, 1 - direction[2]];
|
||||
const qLength = Math.hypot(...q);
|
||||
this.quaternion = q.map((lane) => lane / qLength);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
override async dispose() {
|
||||
await this.ready;
|
||||
if (this.#cameraDisposed) return;
|
||||
this.#cameraDisposed = true;
|
||||
await this.scene.core.deleteObject("cameras", this.cameraId);
|
||||
await super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { Node } from "../Node";
|
||||
import type { Scene } from "../Scene";
|
||||
import { Camera, type CameraOptions } from "./Camera";
|
||||
|
||||
export type FollowCameraOptions = CameraOptions & {
|
||||
target: Node;
|
||||
distance?: number;
|
||||
height?: number;
|
||||
smoothing?: number;
|
||||
running?: boolean;
|
||||
};
|
||||
|
||||
/** Third-person camera that follows a target Node by direct shared-row reads and writes. */
|
||||
export class FollowCamera extends Camera {
|
||||
#target: Node;
|
||||
#frame = 0;
|
||||
#smoothing: number;
|
||||
|
||||
constructor(scene: Scene, options: FollowCameraOptions) {
|
||||
if (!options?.target) throw new TypeError("FollowCamera target is required");
|
||||
super(scene, options);
|
||||
this.#target = options.target;
|
||||
this.#smoothing = options.smoothing ?? 0.12;
|
||||
const cameraReady = this.ready;
|
||||
this.ready = cameraReady.then(async () => {
|
||||
await this.#target.ready;
|
||||
if (this.#target.scene !== scene) throw new Error("Target must belong to this Scene");
|
||||
const row = this.cameraRow();
|
||||
row[12] = this.#target.id + 1;
|
||||
row[15] = options.distance ?? 6;
|
||||
row[16] = options.height ?? 2;
|
||||
row[19] = 3;
|
||||
this.#snap();
|
||||
if (options.running !== false) this.start();
|
||||
return this;
|
||||
});
|
||||
}
|
||||
|
||||
get target() { return this.#target; }
|
||||
set target(value: Node) {
|
||||
if (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.id + 1;
|
||||
}
|
||||
get distance() { return this.cameraRow()[15]; }
|
||||
set distance(value: number) { this.cameraRow()[15] = Math.max(0, value); }
|
||||
get height() { return this.cameraRow()[16]; }
|
||||
set height(value: number) { this.cameraRow()[16] = value; }
|
||||
get smoothing() { return this.#smoothing; }
|
||||
set smoothing(value: number) { this.#smoothing = Math.min(1, Math.max(0, value)); }
|
||||
|
||||
start() {
|
||||
if (!this.#frame) this.#frame = requestAnimationFrame(this.#follow);
|
||||
return this;
|
||||
}
|
||||
|
||||
stop() {
|
||||
cancelAnimationFrame(this.#frame);
|
||||
this.#frame = 0;
|
||||
return this;
|
||||
}
|
||||
|
||||
#snap() {
|
||||
const target = this.#target.position;
|
||||
this.position = [target[0], target[1] + this.height, target[2] + 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.lookAt(target);
|
||||
this.#frame = requestAnimationFrame(this.#follow);
|
||||
};
|
||||
|
||||
override async dispose() {
|
||||
this.stop();
|
||||
await super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { Scene } from "../Scene";
|
||||
import { Camera, type CameraOptions } from "./Camera";
|
||||
|
||||
export type FreeCameraControls = {
|
||||
element: HTMLElement;
|
||||
keyboard?: boolean;
|
||||
pointer?: boolean;
|
||||
controller?: boolean;
|
||||
speed?: number;
|
||||
sensitivity?: number;
|
||||
};
|
||||
export type FreeCameraOptions = CameraOptions & { controls?: FreeCameraControls };
|
||||
|
||||
/** Spectator-style WASD/mouse/gamepad camera backed entirely by shared camera and transform rows. */
|
||||
export class FreeCamera extends Camera {
|
||||
#controls?: FreeCameraControls;
|
||||
#keys = new Set<string>();
|
||||
#frame = 0;
|
||||
#last = 0;
|
||||
|
||||
constructor(scene: Scene, options: FreeCameraOptions = {}) {
|
||||
super(scene, options);
|
||||
const cameraReady = this.ready;
|
||||
this.ready = cameraReady.then(() => {
|
||||
this.cameraRow()[19] = 2;
|
||||
if (options.controls) this.attachControls(options.controls);
|
||||
return this;
|
||||
});
|
||||
}
|
||||
|
||||
attachControls(controls: FreeCameraControls) {
|
||||
this.detachControls();
|
||||
this.#controls = controls;
|
||||
if (controls.keyboard !== false) {
|
||||
addEventListener("keydown", this.#keyDown);
|
||||
addEventListener("keyup", this.#keyUp);
|
||||
}
|
||||
if (controls.pointer !== false) {
|
||||
controls.element.addEventListener("click", this.#lock);
|
||||
addEventListener("mousemove", this.#mouse);
|
||||
}
|
||||
this.#last = performance.now();
|
||||
this.#frame = requestAnimationFrame(this.#update);
|
||||
return this;
|
||||
}
|
||||
|
||||
detachControls() {
|
||||
const element = this.#controls?.element;
|
||||
if (element) element.removeEventListener("click", this.#lock);
|
||||
removeEventListener("keydown", this.#keyDown);
|
||||
removeEventListener("keyup", this.#keyUp);
|
||||
removeEventListener("mousemove", this.#mouse);
|
||||
cancelAnimationFrame(this.#frame);
|
||||
this.#frame = 0;
|
||||
this.#keys.clear();
|
||||
this.#controls = undefined;
|
||||
}
|
||||
|
||||
#keyDown = (event: KeyboardEvent) => this.#keys.add(event.code);
|
||||
#keyUp = (event: KeyboardEvent) => this.#keys.delete(event.code);
|
||||
#lock = () => this.#controls?.element.requestPointerLock?.();
|
||||
#mouse = (event: MouseEvent) => {
|
||||
if (!this.#controls || document.pointerLockElement !== this.#controls.element) return;
|
||||
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();
|
||||
};
|
||||
|
||||
#writeQuaternion() {
|
||||
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];
|
||||
}
|
||||
|
||||
#update = (time: number) => {
|
||||
if (!this.#controls) return;
|
||||
const delta = Math.min(0.1, (time - this.#last) / 1000);
|
||||
this.#last = time;
|
||||
let x = Number(this.#keys.has("KeyD")) - Number(this.#keys.has("KeyA"));
|
||||
let y = Number(this.#keys.has("Space")) - Number(this.#keys.has("ControlLeft"));
|
||||
let z = Number(this.#keys.has("KeyW")) - Number(this.#keys.has("KeyS"));
|
||||
if (this.#controls.controller) {
|
||||
const pad = navigator.getGamepads?.().find(Boolean);
|
||||
if (pad) { x += pad.axes[0] ?? 0; y -= pad.axes[3] ?? 0; z -= pad.axes[1] ?? 0; }
|
||||
}
|
||||
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.#frame = requestAnimationFrame(this.#update);
|
||||
};
|
||||
|
||||
override async dispose() {
|
||||
this.detachControls();
|
||||
await super.dispose();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user