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:
Amp
2026-08-20 06:39:30 +00:00
co-authored by heaust
parent 239b1d25b0
commit 9768765d74
62 changed files with 2749 additions and 798 deletions
@@ -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();
}
}
+96
View File
@@ -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();
}
}
+82
View File
@@ -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();
}
}
+101
View File
@@ -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();
}
}