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,10 @@
|
||||
{
|
||||
"name": "@yawn/handles",
|
||||
"version": "0.1.0",
|
||||
"description": "Conventional scene handles over Yawn's worker and shared render data",
|
||||
"type": "module",
|
||||
"exports": "./src/index.ts",
|
||||
"dependencies": {
|
||||
"@yawn/core": "0.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { Scene } from "./Scene";
|
||||
|
||||
export type GraphBuffer = {
|
||||
id: string;
|
||||
array: string;
|
||||
usage?: string[];
|
||||
};
|
||||
|
||||
export type GraphTexture = {
|
||||
id: string;
|
||||
format?: string;
|
||||
size?: [number | "canvas", number | "canvas", number?];
|
||||
usage?: string[];
|
||||
transient?: boolean;
|
||||
};
|
||||
|
||||
export type GraphSampler = {
|
||||
id: string;
|
||||
magFilter?: "nearest" | "linear";
|
||||
minFilter?: "nearest" | "linear";
|
||||
};
|
||||
|
||||
export type GraphBinding = {
|
||||
group: number;
|
||||
binding: number;
|
||||
resource: string;
|
||||
};
|
||||
|
||||
export type ComputePassOptions = {
|
||||
id?: string;
|
||||
code: string;
|
||||
entry?: string;
|
||||
dispatch?: [number, number?, number?];
|
||||
after?: string[];
|
||||
bindings?: GraphBinding[];
|
||||
buffers?: GraphBuffer[];
|
||||
textures?: GraphTexture[];
|
||||
samplers?: GraphSampler[];
|
||||
};
|
||||
|
||||
let nextComputePass = 1;
|
||||
|
||||
/** A graph-authored compute pass; attaching or updating it rebuilds the Scene loadout. */
|
||||
export class ComputePass {
|
||||
readonly id: string;
|
||||
code: string;
|
||||
entry: string;
|
||||
dispatch: [number, number, number];
|
||||
after: string[];
|
||||
bindings: GraphBinding[];
|
||||
buffers: GraphBuffer[];
|
||||
textures: GraphTexture[];
|
||||
samplers: GraphSampler[];
|
||||
#scene?: Scene;
|
||||
|
||||
constructor(options: ComputePassOptions) {
|
||||
if (!options?.code) throw new TypeError("ComputePass code is required");
|
||||
this.id = options.id ?? `compute-${nextComputePass++}`;
|
||||
this.code = options.code;
|
||||
this.entry = options.entry ?? "main";
|
||||
this.dispatch = [
|
||||
options.dispatch?.[0] ?? 1,
|
||||
options.dispatch?.[1] ?? 1,
|
||||
options.dispatch?.[2] ?? 1,
|
||||
];
|
||||
this.after = [...(options.after ?? [])];
|
||||
this.bindings = [...(options.bindings ?? [])];
|
||||
this.buffers = [...(options.buffers ?? [])];
|
||||
this.textures = [...(options.textures ?? [])];
|
||||
this.samplers = [...(options.samplers ?? [])];
|
||||
}
|
||||
|
||||
update(options: Partial<Omit<ComputePassOptions, "id">>) {
|
||||
if (options.code !== undefined) this.code = options.code;
|
||||
if (options.entry !== undefined) this.entry = options.entry;
|
||||
if (options.dispatch !== undefined) this.dispatch = [
|
||||
options.dispatch[0],
|
||||
options.dispatch[1] ?? 1,
|
||||
options.dispatch[2] ?? 1,
|
||||
];
|
||||
if (options.after !== undefined) this.after = [...options.after];
|
||||
if (options.bindings !== undefined) this.bindings = [...options.bindings];
|
||||
if (options.buffers !== undefined) this.buffers = [...options.buffers];
|
||||
if (options.textures !== undefined) this.textures = [...options.textures];
|
||||
if (options.samplers !== undefined) this.samplers = [...options.samplers];
|
||||
return this.#scene?.updateRenderGraph() ?? Promise.resolve();
|
||||
}
|
||||
|
||||
attach(scene?: Scene) {
|
||||
this.#scene = scene;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { Node, type NodeOptions } from "./Node";
|
||||
import type { Scene } from "./Scene";
|
||||
import type { PBRMaterial } from "./materials/PBRMaterial";
|
||||
|
||||
export const VertexKinds = Object.freeze(["positions", "normals", "tangents", "uvs", "colors", "indices"] as const);
|
||||
export type VertexKind = (typeof VertexKinds)[number];
|
||||
export type MeshOptions = NodeOptions & {
|
||||
geometryId?: number;
|
||||
material?: PBRMaterial;
|
||||
vertexData?: Partial<Record<VertexKind, ArrayLike<number>>>;
|
||||
visible?: boolean;
|
||||
};
|
||||
|
||||
/** A renderable Node; clones share geometry until either clone mutates vertex data. */
|
||||
export class Mesh extends Node {
|
||||
geometryId: number;
|
||||
vertexCount = 0;
|
||||
indexCount = 0;
|
||||
instanceOf = -1;
|
||||
readonly faceMaterials = new Map<number, number>();
|
||||
#registered = false;
|
||||
|
||||
constructor(scene: Scene, options: MeshOptions = {}) {
|
||||
super(scene, options);
|
||||
this.geometryId = options.geometryId ?? scene.createGeometry();
|
||||
const nodeReady = this.ready;
|
||||
this.ready = nodeReady.then(async () => {
|
||||
if (options.material) await options.material.ready;
|
||||
await scene.ensureRows(`mesh.${this.id}.faceMaterials`, 1, 16, "u32");
|
||||
this.vertexCount = Number(scene.geometryData(this.geometryId, "positions")?.length ?? 0) / 3;
|
||||
this.indexCount = Number(scene.geometryData(this.geometryId, "indices")?.length ?? 0);
|
||||
this.instanceOf = options.geometryId === undefined ? this.id : this.geometryId;
|
||||
scene.array("meshInfo").write(this.id, [
|
||||
this.geometryId,
|
||||
options.material?.id ?? 0,
|
||||
options.visible === false ? 0 : 1,
|
||||
this.instanceOf,
|
||||
]);
|
||||
for (const [kind, data] of Object.entries(options.vertexData ?? {}))
|
||||
await this.#setVertexData(kind as VertexKind, data!, false);
|
||||
this.#writeBounds();
|
||||
this.#registered = true;
|
||||
await scene.registerMesh(this);
|
||||
return this;
|
||||
});
|
||||
}
|
||||
|
||||
get isVisible() {
|
||||
if (this.id < 0) throw new Error("Await mesh.ready before reading it");
|
||||
return this.scene.array("meshInfo").row(this.id)[2] !== 0;
|
||||
}
|
||||
|
||||
set isVisible(value: boolean) {
|
||||
if (this.id < 0) throw new Error("Await mesh.ready before writing it");
|
||||
this.scene.array("meshInfo").row(this.id)[2] = value ? 1 : 0;
|
||||
}
|
||||
|
||||
get materialId() {
|
||||
return this.scene.array("meshInfo").row(this.id)[1];
|
||||
}
|
||||
|
||||
set material(value: PBRMaterial) {
|
||||
if (value.scene !== this.scene || value.id < 0) throw new Error("Await a material from the same Scene");
|
||||
this.scene.array("meshInfo").row(this.id)[1] = value.id;
|
||||
}
|
||||
|
||||
clone(options: Omit<MeshOptions, "geometryId" | "vertexData"> = {}) {
|
||||
if (this.id < 0) throw new Error("Await mesh.ready before cloning it");
|
||||
return new Mesh(this.scene, { ...options, geometryId: this.geometryId });
|
||||
}
|
||||
|
||||
async setVertexData(kind: VertexKind, data: ArrayLike<number>) {
|
||||
if (!VertexKinds.includes(kind)) throw new TypeError(`Unknown vertex kind: ${kind}`);
|
||||
await this.ready;
|
||||
await this.#setVertexData(kind, data, true);
|
||||
return this;
|
||||
}
|
||||
|
||||
async #setVertexData(kind: VertexKind, data: ArrayLike<number>, makeUnique: boolean) {
|
||||
if (makeUnique && this.scene.geometryReferences(this.geometryId) > 1) {
|
||||
const original = this.geometryId;
|
||||
this.geometryId = await this.scene.cloneGeometry(original);
|
||||
this.scene.releaseGeometry(original);
|
||||
this.scene.referenceGeometry(this.geometryId);
|
||||
this.instanceOf = this.id;
|
||||
this.scene.array("meshInfo").row(this.id).set([this.geometryId, this.materialId, this.isVisible ? 1 : 0, this.id]);
|
||||
}
|
||||
if (kind === "positions") this.vertexCount = data.length / 3;
|
||||
if (kind === "indices") this.indexCount = data.length;
|
||||
await this.scene.setVertexData(this.geometryId, kind, data, makeUnique);
|
||||
if (kind === "positions") this.#writeBounds();
|
||||
}
|
||||
|
||||
async setMaterialForFaces(material: PBRMaterial, faces: number | number[]) {
|
||||
await Promise.all([this.ready, material.ready]);
|
||||
if (material.scene !== this.scene) throw new Error("Material must belong to the same Scene");
|
||||
const list = Array.isArray(faces) ? faces : [faces];
|
||||
const maximum = Math.max(...list);
|
||||
const array = await this.scene.ensureRows(`mesh.${this.id}.faceMaterials`, maximum + 1, 16, "u32");
|
||||
for (const face of list) {
|
||||
if (!Number.isInteger(face) || face < 0) throw new RangeError("face");
|
||||
array.row(face)[0] = material.id + 1;
|
||||
this.faceMaterials.set(face, material.id);
|
||||
}
|
||||
await this.scene.updateRenderGraph();
|
||||
return this;
|
||||
}
|
||||
|
||||
#writeBounds() {
|
||||
const positions = this.scene.geometryData(this.geometryId, "positions");
|
||||
if (!positions?.length || this.id < 0) return;
|
||||
const minimum = [Infinity, Infinity, Infinity];
|
||||
const maximum = [-Infinity, -Infinity, -Infinity];
|
||||
for (let index = 0; index < positions.length; index += 3)
|
||||
for (let lane = 0; lane < 3; lane++) {
|
||||
minimum[lane] = Math.min(minimum[lane], positions[index + lane]);
|
||||
maximum[lane] = Math.max(maximum[lane], positions[index + lane]);
|
||||
}
|
||||
this.scene.array("bounds").row(this.id).set([...minimum, 0, ...maximum, 0]);
|
||||
}
|
||||
|
||||
override async dispose() {
|
||||
await this.ready;
|
||||
if (this.disposed) return;
|
||||
if (this.#registered) {
|
||||
this.#registered = false;
|
||||
await this.scene.unregisterMesh(this);
|
||||
}
|
||||
await this.scene.core.deleteRows(`mesh.${this.id}.faceMaterials`);
|
||||
await super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { Scene } from "./Scene";
|
||||
|
||||
export type NodeOptions = {
|
||||
position?: ArrayLike<number>;
|
||||
quaternion?: 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)))
|
||||
throw new TypeError(name);
|
||||
return value;
|
||||
}
|
||||
|
||||
/** A thin index into transform SOA rows; transform changes never post a worker message. */
|
||||
export class Node {
|
||||
readonly scene: Scene;
|
||||
id = -1;
|
||||
ready: Promise<this>;
|
||||
protected disposed = false;
|
||||
|
||||
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.parent) this.parent = options.parent;
|
||||
return this;
|
||||
});
|
||||
}
|
||||
|
||||
#row(name: string) {
|
||||
if (this.id < 0) throw new Error("Await node.ready before reading or writing it");
|
||||
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 quaternion(): Float32Array { return this.#row("nodeQuaternions").subarray(0, 4); }
|
||||
set quaternion(value: ArrayLike<number>) { this.#row("nodeQuaternions").set(vector(value, 4, "quaternion")); }
|
||||
|
||||
get scale(): Float32Array { return this.#row("nodeScales").subarray(0, 3); }
|
||||
set scale(value: ArrayLike<number>) { this.#row("nodeScales").set(vector(value, 3, "scale")); }
|
||||
|
||||
get enabled() { return this.#row("nodes")[0] !== 0; }
|
||||
set enabled(value: boolean) { this.#row("nodes")[0] = value ? 1 : 0; }
|
||||
|
||||
get parentId() {
|
||||
const pointer = this.#row("nodes")[1];
|
||||
return pointer ? pointer - 1 : null;
|
||||
}
|
||||
|
||||
set parent(value: Node | null) {
|
||||
if (value && value.scene !== this.scene) throw new Error("Nodes must belong to the same Scene");
|
||||
if (value && value.id < 0) throw new Error("Await parent.ready before assigning it");
|
||||
this.#row("nodes")[1] = value ? value.id + 1 : 0;
|
||||
}
|
||||
|
||||
async dispose() {
|
||||
await this.ready;
|
||||
if (this.disposed) return;
|
||||
this.disposed = true;
|
||||
await this.scene.releaseNode(this.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { PostProcess } from "./PostProcess";
|
||||
import type { Scene } from "../Scene";
|
||||
|
||||
export type ToneMap = "aces" | "reinhard" | "linear";
|
||||
export class ColorGrading extends PostProcess {
|
||||
constructor(scene: Scene, options: { amount?: number; toneMap?: ToneMap; enabled?: boolean } = {}) {
|
||||
super(scene, "colorGrading", options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { PostProcess } from "./PostProcess";
|
||||
import type { Scene } from "../Scene";
|
||||
|
||||
export class DynamicExposure extends PostProcess {
|
||||
constructor(scene: Scene, options: { exposure?: number; enabled?: boolean } = {}) { super(scene, "dynamicExposure", options); }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { PostProcess } from "./PostProcess";
|
||||
import type { Scene } from "../Scene";
|
||||
|
||||
export class Edges extends PostProcess {
|
||||
constructor(scene: Scene, options: { amount?: number; enabled?: boolean } = {}) { super(scene, "edges", options); }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { PostProcess } from "./PostProcess";
|
||||
import type { Scene } from "../Scene";
|
||||
|
||||
export class FXAA extends PostProcess {
|
||||
constructor(scene: Scene, options: { enabled?: boolean } = {}) { super(scene, "fxaa", options); }
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Scene } from "../Scene";
|
||||
|
||||
let nextPostProcess = 1;
|
||||
|
||||
/** Shared graph-membership behavior for the small post-process handles. */
|
||||
export class PostProcess {
|
||||
readonly scene: Scene;
|
||||
readonly id: string;
|
||||
readonly kind: string;
|
||||
options: Record<string, unknown>;
|
||||
enabled: boolean;
|
||||
ready: Promise<void>;
|
||||
|
||||
protected constructor(scene: Scene, kind: string, options: Record<string, unknown> = {}) {
|
||||
this.scene = scene;
|
||||
this.kind = kind;
|
||||
this.id = `${kind}-${nextPostProcess++}`;
|
||||
this.enabled = options.enabled !== false;
|
||||
const { enabled: _, ...effectOptions } = options;
|
||||
this.options = effectOptions;
|
||||
this.ready = scene.setPostProcess(this, this.enabled);
|
||||
}
|
||||
|
||||
setEnabled(enabled: boolean) {
|
||||
this.enabled = enabled;
|
||||
this.ready = this.scene.setPostProcess(this, enabled);
|
||||
return this.ready;
|
||||
}
|
||||
|
||||
update(options: Record<string, unknown>) {
|
||||
this.options = { ...this.options, ...options };
|
||||
this.ready = this.scene.setPostProcess(this, this.enabled);
|
||||
return this.ready;
|
||||
}
|
||||
|
||||
dispose() { return this.setEnabled(false); }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { PostProcess } from "./PostProcess";
|
||||
import type { Scene } from "../Scene";
|
||||
|
||||
export class SSAO extends PostProcess {
|
||||
constructor(scene: Scene, options: { amount?: number; enabled?: boolean } = {}) { super(scene, "ssao", options); }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { PostProcess } from "./PostProcess";
|
||||
import type { Scene } from "../Scene";
|
||||
|
||||
export class Silhouette extends PostProcess {
|
||||
constructor(scene: Scene, options: { amount?: number; enabled?: boolean } = {}) { super(scene, "silhouette", options); }
|
||||
}
|
||||
@@ -0,0 +1,586 @@
|
||||
import { YawnCore } from "@yawn/core";
|
||||
import type {
|
||||
ComputePass,
|
||||
GraphBuffer,
|
||||
GraphSampler,
|
||||
GraphTexture,
|
||||
} from "./ComputePass";
|
||||
|
||||
type RowFormat = "f32" | "u32" | "i32";
|
||||
type MeshLike = {
|
||||
id: number;
|
||||
geometryId: number;
|
||||
indexCount: number;
|
||||
vertexCount: number;
|
||||
faceMaterials: ReadonlyMap<number, number>;
|
||||
};
|
||||
type ShaderLike = {
|
||||
id: number;
|
||||
code: string;
|
||||
vertexEntry: string;
|
||||
fragmentEntry: string;
|
||||
};
|
||||
type PostProcessState = { id: string; kind: string; options: Record<string, unknown> };
|
||||
type TextureState = GraphTexture & { number: number; source?: string | ImageBitmap };
|
||||
|
||||
const rows = [
|
||||
["nodes", 16, "u32"],
|
||||
["nodePositions", 16, "f32"],
|
||||
["nodeQuaternions", 16, "f32"],
|
||||
["nodeScales", 16, "f32"],
|
||||
["meshInfo", 16, "u32"],
|
||||
["bounds", 32, "f32"],
|
||||
["cameras", 80, "f32"],
|
||||
["materials", 48, "f32"],
|
||||
["materialTextures", 32, "u32"],
|
||||
["pointLights", 32, "f32"],
|
||||
["rectAreaLights", 48, "f32"],
|
||||
["spotLights", 32, "f32"],
|
||||
["directionalLights", 32, "f32"],
|
||||
["ambientLights", 16, "f32"],
|
||||
["sceneAccent", 16, "f32"],
|
||||
] as const;
|
||||
|
||||
const clusterShader = /* wgsl */ `
|
||||
@group(0) @binding(0) var<storage, read> pointLights: array<vec4<f32>>;
|
||||
@group(0) @binding(1) var<storage, read> rectLights: array<vec4<f32>>;
|
||||
@group(0) @binding(2) var<storage, read> spotLights: array<vec4<f32>>;
|
||||
@group(0) @binding(3) var<storage, read> directionalLights: array<vec4<f32>>;
|
||||
@group(0) @binding(4) var<storage, read> ambientLights: array<vec4<f32>>;
|
||||
@group(0) @binding(5) var<storage, read_write> clusters: array<u32>;
|
||||
|
||||
@compute @workgroup_size(64)
|
||||
fn main(@builtin(global_invocation_id) id: vec3<u32>) {
|
||||
if (id.x != 0u) { return; }
|
||||
var count = 0u;
|
||||
var light = vec3<f32>(0.0);
|
||||
for (var i = 0u; i < arrayLength(&pointLights) / 2u; i++) {
|
||||
let enabled = pointLights[i * 2u + 1u].y;
|
||||
count += select(0u, 1u, enabled != 0.0);
|
||||
light += pointLights[i * 2u].rgb * pointLights[i * 2u].a * enabled * 0.02;
|
||||
}
|
||||
for (var i = 0u; i < arrayLength(&rectLights) / 3u; i++) {
|
||||
let enabled = rectLights[i * 3u + 1u].z;
|
||||
count += select(0u, 1u, enabled != 0.0);
|
||||
light += rectLights[i * 3u].rgb * rectLights[i * 3u].a * enabled * 0.02;
|
||||
}
|
||||
for (var i = 0u; i < arrayLength(&spotLights) / 2u; i++) {
|
||||
let enabled = spotLights[i * 2u + 1u].w;
|
||||
count += select(0u, 1u, enabled != 0.0);
|
||||
light += spotLights[i * 2u].rgb * spotLights[i * 2u].a * enabled * 0.02;
|
||||
}
|
||||
for (var i = 0u; i < arrayLength(&directionalLights) / 2u; i++) {
|
||||
let enabled = directionalLights[i * 2u + 1u].x;
|
||||
count += select(0u, 1u, enabled != 0.0);
|
||||
light += directionalLights[i * 2u].rgb * directionalLights[i * 2u].a * enabled * 0.1;
|
||||
}
|
||||
for (var i = 0u; i < arrayLength(&ambientLights); i++) {
|
||||
count += select(0u, 1u, ambientLights[i].a != 0.0);
|
||||
light += ambientLights[i].rgb * ambientLights[i].a;
|
||||
}
|
||||
clusters[0] = count;
|
||||
clusters[1] = bitcast<u32>(light.r);
|
||||
clusters[2] = bitcast<u32>(light.g);
|
||||
clusters[3] = bitcast<u32>(light.b);
|
||||
}`;
|
||||
|
||||
const forwardShader = /* wgsl */ `
|
||||
struct Accent { color: vec4<f32> }
|
||||
struct VertexOutput {
|
||||
@builtin(position) position: vec4<f32>,
|
||||
@location(0) normal: vec3<f32>,
|
||||
@location(1) @interpolate(flat) mesh: u32,
|
||||
@location(2) @interpolate(flat) material: u32,
|
||||
}
|
||||
|
||||
@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(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> cameras: 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);
|
||||
}
|
||||
|
||||
@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;
|
||||
var clip = vec4<f32>(transformed, 1.0);
|
||||
if (cameras[2].w != 0.0) {
|
||||
let cameraNode = u32(cameras[1].x);
|
||||
let inverse = vec4<f32>(-quaternions[cameraNode].xyz, quaternions[cameraNode].w);
|
||||
let view = rotate(inverse, transformed - positions[cameraNode].xyz);
|
||||
if (cameras[1].y == 1.0) {
|
||||
let size = max(cameras[1].z, 0.0001);
|
||||
clip = vec4<f32>(view.x / (size * cameras[0].y * 0.5), view.y / (size * 0.5), -view.z / cameras[0].w, 1.0);
|
||||
} else {
|
||||
let focal = 1.0 / tan(cameras[0].x * 0.5);
|
||||
let depth = (-view.z * cameras[0].w - cameras[0].z * cameras[0].w) / (cameras[0].w - cameras[0].z);
|
||||
clip = vec4<f32>(view.x * focal / cameras[0].y, view.y * focal, depth, -view.z);
|
||||
}
|
||||
}
|
||||
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.mesh = instance;
|
||||
output.material = packed >> 16u;
|
||||
return output;
|
||||
}
|
||||
|
||||
@fragment
|
||||
fn fragment(input: VertexOutput) -> @location(0) vec4<f32> {
|
||||
let fallback = meshInfo[input.mesh * 4u + 1u];
|
||||
let material = select(fallback, input.material - 1u, input.material != 0u);
|
||||
let base = materials[material * 3u];
|
||||
let properties = materials[material * 3u + 1u];
|
||||
let clusterLight = vec3<f32>(bitcast<f32>(clusters[1]), bitcast<f32>(clusters[2]), bitcast<f32>(clusters[3]));
|
||||
let light = vec3<f32>(0.12 + max(dot(input.normal, normalize(vec3<f32>(0.4, 0.7, 0.6))), 0.0) * 0.75) + clusterLight;
|
||||
let clustered = min(f32(clusters[0]) * 0.002, 0.05);
|
||||
let color = base.rgb * (light + clustered) * accent.color.rgb * mix(1.0, 1.1, properties.x);
|
||||
return vec4<f32>(color, base.a);
|
||||
}`;
|
||||
|
||||
const emptyForwardShader = /* wgsl */ `
|
||||
struct Accent { color: vec4<f32> }
|
||||
@group(0) @binding(0) var<uniform> accent: Accent;
|
||||
struct VertexOutput { @builtin(position) position: vec4<f32> }
|
||||
@vertex fn vertex(@builtin(vertex_index) index: u32) -> VertexOutput {
|
||||
let points = array(vec2(-0.72, -0.6), vec2(0.72, -0.6), vec2(0.0, 0.72));
|
||||
var output: VertexOutput;
|
||||
output.position = vec4(points[index], 0.0, 1.0);
|
||||
return output;
|
||||
}
|
||||
@fragment fn fragment() -> @location(0) vec4<f32> {
|
||||
return vec4(accent.color.rgb, 1.0);
|
||||
}`;
|
||||
|
||||
const fullscreenVertex = /* wgsl */ `
|
||||
struct VertexOutput {
|
||||
@builtin(position) position: vec4<f32>,
|
||||
@location(0) uv: vec2<f32>,
|
||||
}
|
||||
@vertex fn vertex(@builtin(vertex_index) index: u32) -> VertexOutput {
|
||||
let points = array(vec2(-1.0, -3.0), vec2(3.0, 1.0), vec2(-1.0, 1.0));
|
||||
var output: VertexOutput;
|
||||
output.position = vec4(points[index], 0.0, 1.0);
|
||||
output.uv = points[index] * vec2(0.5, -0.5) + vec2(0.5);
|
||||
return output;
|
||||
}`;
|
||||
|
||||
function effectFragment(kind: string, options: Record<string, unknown>) {
|
||||
const amount = Number(options.amount ?? options.exposure ?? 1);
|
||||
const safeAmount = Number.isFinite(amount) ? amount : 1;
|
||||
const body: Record<string, string> = {
|
||||
ssao: `let value = textureSample(source, sourceSampler, input.uv); return vec4(value.rgb * ${Math.max(0, 1 - safeAmount * 0.2)}, value.a);`,
|
||||
fxaa: `let size = vec2<f32>(textureDimensions(source)); let pixel = 1.0 / size; let center = textureSample(source, sourceSampler, input.uv); let around = textureSample(source, sourceSampler, input.uv + vec2(pixel.x, 0.0)) + textureSample(source, sourceSampler, input.uv - vec2(pixel.x, 0.0)) + textureSample(source, sourceSampler, input.uv + vec2(0.0, pixel.y)) + textureSample(source, sourceSampler, input.uv - vec2(0.0, pixel.y)); return mix(center, around * 0.25, 0.35);`,
|
||||
colorGrading: `let value = textureSample(source, sourceSampler, input.uv); return vec4(pow(max(value.rgb * ${safeAmount}, vec3(0.0)), vec3(1.0 / 2.2)), value.a);`,
|
||||
dynamicExposure: `let value = textureSample(source, sourceSampler, input.uv); return vec4(value.rgb * ${safeAmount}, value.a);`,
|
||||
silhouette: `let size = vec2<f32>(textureDimensions(source)); let pixel = 1.0 / size; let value = textureSample(source, sourceSampler, input.uv); let edge = length(value.rgb - textureSample(source, sourceSampler, input.uv + pixel).rgb); return vec4(mix(value.rgb, vec3(0.0), smoothstep(0.08, 0.2, edge)), value.a);`,
|
||||
edges: `let size = vec2<f32>(textureDimensions(source)); let pixel = 1.0 / size; let value = textureSample(source, sourceSampler, input.uv); let dx = length(value.rgb - textureSample(source, sourceSampler, input.uv + vec2(pixel.x, 0.0)).rgb); let dy = length(value.rgb - textureSample(source, sourceSampler, input.uv + vec2(0.0, pixel.y)).rgb); return vec4(vec3(max(dx, dy) * ${safeAmount}), value.a);`,
|
||||
};
|
||||
return `${fullscreenVertex}
|
||||
@group(0) @binding(0) var source: texture_2d<f32>;
|
||||
@group(0) @binding(1) var sourceSampler: sampler;
|
||||
@fragment fn fragment(input: VertexOutput) -> @location(0) vec4<f32> {
|
||||
${body[kind] ?? "return textureSample(source, sourceSampler, input.uv);"}
|
||||
}`;
|
||||
}
|
||||
|
||||
function presentShader(toneMap: string) {
|
||||
const tone = toneMap === "reinhard"
|
||||
? "color / (color + vec3(1.0))"
|
||||
: toneMap === "linear"
|
||||
? "clamp(color, vec3(0.0), vec3(1.0))"
|
||||
: "clamp((color * (2.51 * color + vec3(0.03))) / (color * (2.43 * color + vec3(0.59)) + vec3(0.14)), vec3(0.0), vec3(1.0))";
|
||||
return `${fullscreenVertex}
|
||||
@group(0) @binding(0) var source: texture_2d<f32>;
|
||||
@group(0) @binding(1) var sourceSampler: sampler;
|
||||
@fragment fn fragment(input: VertexOutput) -> @location(0) vec4<f32> {
|
||||
let value = textureSample(source, sourceSampler, input.uv);
|
||||
let color = value.rgb;
|
||||
return vec4(${tone}, value.a);
|
||||
}`;
|
||||
}
|
||||
|
||||
function encode(value: unknown): string {
|
||||
if (value === null || typeof value === "boolean" || typeof value === "number") return String(value);
|
||||
if (typeof value === "string") return JSON.stringify(value);
|
||||
if (Array.isArray(value)) return `(array${value.map((item) => ` ${encode(item)}`).join("")})`;
|
||||
if (value && value.constructor === Object) return `(object${Object.keys(value as object).sort().map((key) =>
|
||||
` (field ${JSON.stringify(key)} ${encode((value as Record<string, unknown>)[key])})`).join("")})`;
|
||||
throw new TypeError("Render graph values must be plain data");
|
||||
}
|
||||
|
||||
function serialize(graph: object) {
|
||||
return `(yawn-graph 1 ${encode(graph)})`;
|
||||
}
|
||||
|
||||
/** The conventional single-loadout scene layer; hot values always remain direct SAB writes. */
|
||||
export class Scene {
|
||||
readonly core: YawnCore;
|
||||
readonly ready: Promise<this>;
|
||||
readonly hdr: boolean;
|
||||
#graphUpdates = Promise.resolve();
|
||||
#computePasses = new Map<string, ComputePass>();
|
||||
#meshes = new Map<number, MeshLike>();
|
||||
#shaders = new Map<number, ShaderLike>();
|
||||
#effects = new Map<string, PostProcessState>();
|
||||
#textures = new Map<number, TextureState>();
|
||||
#geometry = new Map<number, Map<string, Float32Array | Uint32Array>>();
|
||||
#geometryRefs = new Map<number, number>();
|
||||
#nextGeometry = 1;
|
||||
#nextTexture = 0;
|
||||
|
||||
constructor(canvas: HTMLCanvasElement, options: { arenaBytes?: number; fps?: number; hdr?: boolean } = {}) {
|
||||
this.hdr = options.hdr ?? true;
|
||||
this.core = new YawnCore(canvas, { arenaBytes: options.arenaBytes });
|
||||
this.ready = this.#initialize(options.fps ?? 60);
|
||||
}
|
||||
|
||||
async #initialize(fps: number) {
|
||||
await this.core.ready;
|
||||
for (const [name, stride, format] of rows)
|
||||
await this.core.createRows({ name, rows: 1, stride, format });
|
||||
await this.core.createRows({ name: "clusters", rows: 256, stride: 16, format: "u32" });
|
||||
this.core.array("nodeQuaternions").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");
|
||||
this.core.array("materials").write(material, [1, 1, 1, 1, 0, 0.7, 0, 0, 0, 0, 1, 0.5]);
|
||||
await this.core.setFps(fps);
|
||||
await this.#compileRenderGraph();
|
||||
return this;
|
||||
}
|
||||
|
||||
array(name: string) {
|
||||
return this.core.array(name);
|
||||
}
|
||||
|
||||
async ensureRows(name: string, rowCount: number, stride: number, format: RowFormat) {
|
||||
await this.core.ready;
|
||||
try {
|
||||
const current = this.core.array(name);
|
||||
if (current.stride !== stride || current.format !== format) throw new Error(`ROW_LAYOUT: ${name}`);
|
||||
if (current.rows >= rowCount) return current;
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error) || !error.message.startsWith("UNKNOWN_ARRAY")) throw error;
|
||||
}
|
||||
return this.core.createRows({ name, rows: Math.max(1, rowCount), stride, format });
|
||||
}
|
||||
|
||||
async allocateNode() {
|
||||
await this.ready;
|
||||
const id = await this.core.allocateObject("nodes");
|
||||
const growth = rows.slice(1, 6).flatMap(([name, stride, format]) => {
|
||||
const current = this.array(name);
|
||||
return current.rows < id + 1 ? [{ name, rows: id + 1, stride, format }] : [];
|
||||
});
|
||||
if (growth.length) await this.core.createRowsBatch(growth);
|
||||
this.array("nodeQuaternions").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;
|
||||
}
|
||||
|
||||
async releaseNode(id: number) {
|
||||
for (const name of ["nodes", "nodePositions", "nodeQuaternions", "nodeScales", "meshInfo", "bounds"])
|
||||
this.array(name).row(id).fill(0);
|
||||
await this.core.deleteObject("nodes", id);
|
||||
}
|
||||
|
||||
async allocateMaterial() {
|
||||
await this.ready;
|
||||
const id = await this.core.allocateObject("materials");
|
||||
await this.ensureRows("materialTextures", id + 1, 32, "u32");
|
||||
return id;
|
||||
}
|
||||
|
||||
addComputePass(pass: ComputePass) {
|
||||
if (this.#computePasses.has(pass.id)) throw new Error(`COMPUTE_PASS_EXISTS: ${pass.id}`);
|
||||
this.#computePasses.set(pass.id, pass);
|
||||
pass.attach(this);
|
||||
return this.updateRenderGraph();
|
||||
}
|
||||
|
||||
removeComputePass(pass: ComputePass | string) {
|
||||
const id = typeof pass === "string" ? pass : pass.id;
|
||||
const existing = this.#computePasses.get(id);
|
||||
existing?.attach(undefined);
|
||||
this.#computePasses.delete(id);
|
||||
return this.updateRenderGraph();
|
||||
}
|
||||
|
||||
registerMesh(mesh: MeshLike) {
|
||||
this.#meshes.set(mesh.id, mesh);
|
||||
this.#geometryRefs.set(mesh.geometryId, (this.#geometryRefs.get(mesh.geometryId) ?? 0) + 1);
|
||||
return this.updateRenderGraph();
|
||||
}
|
||||
|
||||
async unregisterMesh(mesh: MeshLike) {
|
||||
this.#meshes.delete(mesh.id);
|
||||
const references = Math.max(0, (this.#geometryRefs.get(mesh.geometryId) ?? 1) - 1);
|
||||
this.#geometryRefs.set(mesh.geometryId, references);
|
||||
await this.updateRenderGraph();
|
||||
if (!references) {
|
||||
for (const kind of this.#geometry.get(mesh.geometryId)?.keys() ?? [])
|
||||
await this.core.deleteRows(`geometry.${mesh.geometryId}.${kind}`);
|
||||
this.#geometry.delete(mesh.geometryId);
|
||||
this.#geometryRefs.delete(mesh.geometryId);
|
||||
}
|
||||
}
|
||||
|
||||
createGeometry() {
|
||||
const id = this.#nextGeometry++;
|
||||
this.#geometry.set(id, new Map());
|
||||
this.#geometryRefs.set(id, 0);
|
||||
return id;
|
||||
}
|
||||
|
||||
geometryReferences(id: number) {
|
||||
return this.#geometryRefs.get(id) ?? 0;
|
||||
}
|
||||
|
||||
referenceGeometry(id: number) {
|
||||
this.#geometryRefs.set(id, (this.#geometryRefs.get(id) ?? 0) + 1);
|
||||
}
|
||||
|
||||
releaseGeometry(id: number) {
|
||||
this.#geometryRefs.set(id, Math.max(0, (this.#geometryRefs.get(id) ?? 1) - 1));
|
||||
}
|
||||
|
||||
async cloneGeometry(id: number) {
|
||||
const clone = this.createGeometry();
|
||||
for (const [kind, data] of this.#geometry.get(id) ?? [])
|
||||
await this.setVertexData(clone, kind, data.slice() as Float32Array | Uint32Array, false);
|
||||
return clone;
|
||||
}
|
||||
|
||||
async setVertexData(geometry: number, kind: string, source: ArrayLike<number>, updateGraph = true) {
|
||||
const components: Record<string, number> = { positions: 3, normals: 3, tangents: 4, uvs: 2, colors: 4, indices: 1 };
|
||||
const width = components[kind];
|
||||
if (!width || source.length % width) throw new RangeError(`VERTEX_DATA: ${kind}`);
|
||||
const integer = kind === "indices";
|
||||
const data = integer ? Uint32Array.from(source) : Float32Array.from(source);
|
||||
const name = `geometry.${geometry}.${kind}`;
|
||||
const rowCount = integer ? Math.ceil(data.length / 4) : data.length / width;
|
||||
const target = await this.ensureRows(name, rowCount, 16, integer ? "u32" : "f32");
|
||||
target.view.fill(0);
|
||||
if (integer) target.view.set(data);
|
||||
else for (let row = 0; row < rowCount; row++)
|
||||
target.row(row).set(data.subarray(row * width, (row + 1) * width));
|
||||
(this.#geometry.get(geometry) ?? this.#geometry.set(geometry, new Map()).get(geometry)!)
|
||||
.set(kind, data);
|
||||
if (updateGraph) await this.updateRenderGraph();
|
||||
}
|
||||
|
||||
geometryData(id: number, kind: string) {
|
||||
return this.#geometry.get(id)?.get(kind);
|
||||
}
|
||||
|
||||
registerShader(material: ShaderLike) {
|
||||
this.#shaders.set(material.id, material);
|
||||
return this.updateRenderGraph();
|
||||
}
|
||||
|
||||
unregisterShader(id: number) {
|
||||
this.#shaders.delete(id);
|
||||
return this.updateRenderGraph();
|
||||
}
|
||||
|
||||
registerTexture(texture: Omit<TextureState, "number">) {
|
||||
const number = this.#nextTexture++;
|
||||
this.#textures.set(number, { ...texture, number });
|
||||
return { number, ready: this.updateRenderGraph() };
|
||||
}
|
||||
|
||||
unregisterTexture(number: number) {
|
||||
this.#textures.delete(number);
|
||||
return this.updateRenderGraph();
|
||||
}
|
||||
|
||||
setPostProcess(effect: PostProcessState, enabled: boolean) {
|
||||
if (enabled) this.#effects.set(effect.id, effect);
|
||||
else this.#effects.delete(effect.id);
|
||||
return this.updateRenderGraph();
|
||||
}
|
||||
|
||||
updateRenderGraph() {
|
||||
const update = this.#graphUpdates.then(async () => {
|
||||
await this.ready;
|
||||
await this.#compileRenderGraph();
|
||||
});
|
||||
this.#graphUpdates = update.catch(() => undefined);
|
||||
return update;
|
||||
}
|
||||
|
||||
async #compileRenderGraph() {
|
||||
const buffers = new Map<string, GraphBuffer>();
|
||||
const textures = new Map<string, GraphTexture>();
|
||||
const samplers = new Map<string, GraphSampler>();
|
||||
const computePipelines: object[] = [];
|
||||
const renderPipelines: object[] = [];
|
||||
const passes: object[] = [];
|
||||
const addBuffer = (value: GraphBuffer) => buffers.set(value.id, value);
|
||||
const addTexture = (value: GraphTexture) => textures.set(value.id, value);
|
||||
const addSampler = (value: GraphSampler) => samplers.set(value.id, value);
|
||||
|
||||
for (const [id, array] of [
|
||||
["point-lights", "pointLights"], ["rect-lights", "rectAreaLights"],
|
||||
["spot-lights", "spotLights"], ["directional-lights", "directionalLights"],
|
||||
["ambient-lights", "ambientLights"], ["clusters", "clusters"],
|
||||
]) addBuffer({ id, array, usage: ["storage"] });
|
||||
addBuffer({ id: "accent", array: "sceneAccent", usage: ["uniform"] });
|
||||
|
||||
computePipelines.push({ id: "cluster-lights", code: clusterShader, entry: "main" });
|
||||
passes.push({
|
||||
id: "cluster-lights", type: "compute", pipeline: "cluster-lights", dispatch: [4, 1, 1],
|
||||
bindings: ["point-lights", "rect-lights", "spot-lights", "directional-lights", "ambient-lights", "clusters"]
|
||||
.map((resource, binding) => ({ group: 0, binding, resource })),
|
||||
});
|
||||
|
||||
for (const pass of this.#computePasses.values()) {
|
||||
pass.buffers.forEach(addBuffer);
|
||||
pass.textures.forEach(addTexture);
|
||||
pass.samplers.forEach(addSampler);
|
||||
computePipelines.push({ id: pass.id, code: pass.code, entry: pass.entry });
|
||||
passes.push({
|
||||
id: pass.id,
|
||||
type: "compute",
|
||||
pipeline: pass.id,
|
||||
after: pass.after.length ? pass.after : ["cluster-lights"],
|
||||
bindings: pass.bindings,
|
||||
dispatch: pass.dispatch,
|
||||
});
|
||||
}
|
||||
|
||||
const computeIds = [...this.#computePasses.keys()];
|
||||
const renderedMeshes = [...this.#meshes.values()].filter((mesh) => mesh.vertexCount > 0);
|
||||
const hdrFormat = this.hdr ? "rgba16float" : "rgba8unorm";
|
||||
addTexture({ id: "hdr", format: hdrFormat, size: ["canvas", "canvas", 1], usage: ["render", "sampled"], transient: false });
|
||||
addSampler({ id: "linear", magFilter: "linear", minFilter: "linear" });
|
||||
|
||||
let previous = computeIds.length ? computeIds : ["cluster-lights"];
|
||||
if (!renderedMeshes.length) {
|
||||
renderPipelines.push({ id: "empty-forward", code: emptyForwardShader, vertex: { entry: "vertex" }, fragment: { entry: "fragment", targets: [{ format: hdrFormat }] } });
|
||||
passes.push({
|
||||
id: "forward-empty", type: "render", pipeline: "empty-forward", after: previous,
|
||||
bindings: [{ group: 0, binding: 0, resource: "accent" }],
|
||||
color: [{ resource: "hdr", clear: [0.015, 0.025, 0.05, 1] }], draw: { vertices: 3 },
|
||||
});
|
||||
previous = ["forward-empty"];
|
||||
} else {
|
||||
for (const [id, array] of [
|
||||
["node-positions", "nodePositions"], ["node-quaternions", "nodeQuaternions"],
|
||||
["node-scales", "nodeScales"], ["mesh-info", "meshInfo"], ["materials", "materials"], ["cameras", "cameras"],
|
||||
]) addBuffer({ id, array, usage: ["storage"] });
|
||||
renderPipelines.push({
|
||||
id: "forward-pbr", code: forwardShader,
|
||||
vertex: { entry: "vertex", buffers: [{ arrayStride: 16, attributes: [{ format: "float32x3", offset: 0, shaderLocation: 0 }] }] },
|
||||
fragment: { entry: "fragment", targets: [{ format: hdrFormat }] },
|
||||
});
|
||||
let firstRender = true;
|
||||
for (const mesh of renderedMeshes) {
|
||||
const vertex = `geometry-${mesh.geometryId}-positions`;
|
||||
addBuffer({ id: vertex, array: `geometry.${mesh.geometryId}.positions`, usage: ["vertex"] });
|
||||
const indexed = mesh.indexCount > 0;
|
||||
if (indexed) addBuffer({ id: `geometry-${mesh.geometryId}-indices`, array: `geometry.${mesh.geometryId}.indices`, usage: ["index"] });
|
||||
const draws = indexed && mesh.faceMaterials.size
|
||||
? Array.from({ length: Math.floor(mesh.indexCount / 3) }, (_, face) => ({
|
||||
face,
|
||||
count: 3,
|
||||
firstIndex: face * 3,
|
||||
material: mesh.faceMaterials.get(face),
|
||||
}))
|
||||
: [{ face: -1, count: indexed ? mesh.indexCount : mesh.vertexCount, firstIndex: 0, material: undefined }];
|
||||
for (const draw of draws) {
|
||||
const id = `forward-${mesh.id}-${draw.face}`;
|
||||
if (mesh.id > 65535 || (draw.material ?? 0) > 65534) throw new RangeError("Scene handle limit");
|
||||
const instance = (mesh.id + (draw.material === undefined ? 0 : (draw.material + 1) * 65536)) >>> 0;
|
||||
passes.push({
|
||||
id, type: "render", pipeline: "forward-pbr", after: previous,
|
||||
bindings: ["clusters", "accent", "node-positions", "node-quaternions", "node-scales", "mesh-info", "materials", "cameras"]
|
||||
.map((resource, binding) => ({ group: 0, binding, resource })),
|
||||
color: [{ resource: "hdr", ...(firstRender ? { clear: [0.015, 0.025, 0.05, 1] } : { load: "load" }) }],
|
||||
vertexBuffers: [{ slot: 0, resource: vertex }],
|
||||
...(indexed ? { indexBuffer: { resource: `geometry-${mesh.geometryId}-indices`, format: "uint32" } } : {}),
|
||||
draw: indexed
|
||||
? { indices: draw.count, firstIndex: draw.firstIndex, instances: 1, firstInstance: instance }
|
||||
: { vertices: draw.count, instances: 1, firstInstance: instance },
|
||||
});
|
||||
firstRender = false;
|
||||
previous = [id];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const material of this.#shaders.values()) {
|
||||
const id = `shader-${material.id}`;
|
||||
renderPipelines.push({
|
||||
id, code: material.code,
|
||||
vertex: { entry: material.vertexEntry },
|
||||
fragment: { entry: material.fragmentEntry, targets: [{ format: hdrFormat }] },
|
||||
});
|
||||
passes.push({ id, type: "render", pipeline: id, after: previous, color: [{ resource: "hdr", load: "load" }], draw: { vertices: 3 } });
|
||||
previous = [id];
|
||||
}
|
||||
|
||||
let input = "hdr";
|
||||
for (const [index, effect] of [...this.#effects.values()].entries()) {
|
||||
const output = `post-${index}`;
|
||||
const pipeline = `post-${effect.id}`;
|
||||
addTexture({ id: output, format: hdrFormat, size: ["canvas", "canvas", 1], usage: ["render", "sampled"], transient: true });
|
||||
renderPipelines.push({ id: pipeline, code: effectFragment(effect.kind, effect.options), vertex: { entry: "vertex" }, fragment: { entry: "fragment", targets: [{ format: hdrFormat }] } });
|
||||
passes.push({
|
||||
id: pipeline, type: "render", pipeline, after: previous,
|
||||
bindings: [{ group: 0, binding: 0, resource: input }, { group: 0, binding: 1, resource: "linear" }],
|
||||
color: [{ resource: output, clear: [0, 0, 0, 1] }], draw: { vertices: 3 },
|
||||
});
|
||||
input = output;
|
||||
previous = [pipeline];
|
||||
}
|
||||
|
||||
for (const { number, source: _, ...texture } of this.#textures.values()) {
|
||||
addTexture(texture);
|
||||
const id = `retain-texture-${number}`;
|
||||
computePipelines.push({
|
||||
id,
|
||||
code: "@group(0) @binding(0) var source: texture_2d<f32>; @group(0) @binding(1) var<storage, read_write> output: array<u32>; @compute @workgroup_size(1) fn main() { output[1] = textureDimensions(source).x; }",
|
||||
entry: "main",
|
||||
});
|
||||
passes.push({
|
||||
id, type: "compute", pipeline: id, after: ["cluster-lights"], dispatch: [1, 1, 1],
|
||||
bindings: [{ group: 0, binding: 0, resource: texture.id }, { group: 0, binding: 1, resource: "clusters" }],
|
||||
});
|
||||
}
|
||||
const toneMap = String([...this.#effects.values()].find((effect) => effect.kind === "colorGrading")?.options.toneMap ?? "aces");
|
||||
renderPipelines.push({ id: "present", code: presentShader(toneMap), vertex: { entry: "vertex" }, fragment: { entry: "fragment", targets: [{ format: "canvas" }] } });
|
||||
passes.push({
|
||||
id: "present", type: "render", pipeline: "present", after: previous,
|
||||
bindings: [{ group: 0, binding: 0, resource: input }, { group: 0, binding: 1, resource: "linear" }],
|
||||
color: [{ resource: "canvas", clear: [0, 0, 0, 1] }], draw: { vertices: 3 },
|
||||
});
|
||||
|
||||
const graph = {
|
||||
id: "scene",
|
||||
resources: {
|
||||
buffers: [...buffers.values()],
|
||||
textures: [...textures.values()],
|
||||
samplers: [...samplers.values()].map(({ id, ...descriptor }) => ({ id, descriptor })),
|
||||
},
|
||||
pipelines: { render: renderPipelines, compute: computePipelines },
|
||||
passes,
|
||||
};
|
||||
const id = await this.core.compileGraph(serialize(graph));
|
||||
await this.core.switchLoadout(id);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.core.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { Scene } from "../Scene";
|
||||
|
||||
export type PickHit = { id: number; distance: number };
|
||||
|
||||
/** Worker-backed broad-phase picking that returns every SAB AABB hit, nearest first. */
|
||||
export class Picking {
|
||||
readonly scene: Scene;
|
||||
readonly ready: Promise<void>;
|
||||
#worker: Worker;
|
||||
#next = 1;
|
||||
#pending = new Map<number, { resolve: (value: any) => void; reject: (error: Error) => void }>();
|
||||
|
||||
constructor(scene: Scene) {
|
||||
this.scene = scene;
|
||||
this.#worker = new Worker(new URL("./worker.ts", import.meta.url), { type: "module", name: "yawn-bvh" });
|
||||
this.#worker.addEventListener("message", ({ data }) => {
|
||||
const pending = this.#pending.get(data.request);
|
||||
if (!pending) return;
|
||||
this.#pending.delete(data.request);
|
||||
pending.resolve(data.hits);
|
||||
});
|
||||
this.#worker.addEventListener("error", () => this.#fail(new Error("BVH_WORKER_ERROR")));
|
||||
this.ready = scene.ready.then(() => this.refresh());
|
||||
}
|
||||
|
||||
refresh() {
|
||||
return this.#request("sync", {
|
||||
shares: Object.fromEntries(["info", "nodes", "nodePositions", "meshInfo", "bounds"]
|
||||
.map((name) => [name, this.scene.array(name).share()])),
|
||||
}).then(() => undefined);
|
||||
}
|
||||
|
||||
async pick(origin: ArrayLike<number>, direction: ArrayLike<number>): Promise<PickHit[]> {
|
||||
await this.ready;
|
||||
if (origin.length !== 3 || direction.length !== 3) throw new TypeError("Pick rays have three lanes");
|
||||
return this.#request("pick", { origin: Array.from(origin), direction: Array.from(direction) });
|
||||
}
|
||||
|
||||
#request(type: string, payload: object) {
|
||||
const request = this.#next++;
|
||||
return new Promise<any>((resolve, reject) => {
|
||||
this.#pending.set(request, { resolve, reject });
|
||||
this.#worker.postMessage({ type, request, ...payload });
|
||||
});
|
||||
}
|
||||
|
||||
#fail(error: Error) {
|
||||
for (const pending of this.#pending.values()) pending.reject(error);
|
||||
this.#pending.clear();
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.#fail(new Error("DISPOSED"));
|
||||
this.#worker.terminate();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
type SharedRows = { buffer: SharedArrayBuffer; descriptor: { offset: number; rows: number; stride: number; format: string } };
|
||||
type Box = { id: number; min: number[]; max: number[] };
|
||||
type Branch = { min: number[]; max: number[]; boxes?: Box[]; left?: Branch; right?: Branch };
|
||||
|
||||
let shares: Record<string, SharedRows> = {};
|
||||
let root: Branch | undefined;
|
||||
let builtFrame = -1;
|
||||
|
||||
function view(name: string) {
|
||||
const share = shares[name];
|
||||
if (!share) return undefined;
|
||||
const length = share.descriptor.rows * share.descriptor.stride / 4;
|
||||
return share.descriptor.format === "u32"
|
||||
? new Uint32Array(share.buffer, share.descriptor.offset, length)
|
||||
: new Float32Array(share.buffer, share.descriptor.offset, length);
|
||||
}
|
||||
|
||||
function merge(boxes: Box[]) {
|
||||
const min = [Infinity, Infinity, Infinity];
|
||||
const max = [-Infinity, -Infinity, -Infinity];
|
||||
for (const box of boxes) for (let lane = 0; lane < 3; lane++) {
|
||||
min[lane] = Math.min(min[lane], box.min[lane]);
|
||||
max[lane] = Math.max(max[lane], box.max[lane]);
|
||||
}
|
||||
return { min, max };
|
||||
}
|
||||
|
||||
function build(boxes: Box[]): Branch | undefined {
|
||||
if (!boxes.length) return undefined;
|
||||
const bounds = merge(boxes);
|
||||
if (boxes.length <= 4) return { ...bounds, boxes };
|
||||
const extents = bounds.max.map((value, lane) => value - bounds.min[lane]);
|
||||
const axis = extents.indexOf(Math.max(...extents));
|
||||
boxes.sort((a, b) => (a.min[axis] + a.max[axis]) - (b.min[axis] + b.max[axis]));
|
||||
const middle = Math.ceil(boxes.length / 2);
|
||||
return { ...bounds, left: build(boxes.slice(0, middle)), right: build(boxes.slice(middle)) };
|
||||
}
|
||||
|
||||
function rebuild() {
|
||||
const bounds = view("bounds");
|
||||
const positions = view("nodePositions");
|
||||
const meshes = view("meshInfo");
|
||||
const nodes = view("nodes");
|
||||
if (!bounds || !positions || !meshes || !nodes) return;
|
||||
const count = shares.bounds.descriptor.rows;
|
||||
const boxes: Box[] = [];
|
||||
for (let id = 0; id < count; id++) {
|
||||
if (!nodes[id * 4] || !meshes[id * 4 + 2]) continue;
|
||||
const offset = id * 8;
|
||||
const translation = id * 4;
|
||||
const min = [0, 1, 2].map((lane) => Number(bounds[offset + lane]) + Number(positions[translation + lane]));
|
||||
const max = [0, 1, 2].map((lane) => Number(bounds[offset + 4 + lane]) + Number(positions[translation + lane]));
|
||||
if (min.every(Number.isFinite) && max.every(Number.isFinite)) boxes.push({ id, min, max });
|
||||
}
|
||||
root = build(boxes);
|
||||
builtFrame = Number(view("info")?.[1] ?? builtFrame + 1);
|
||||
}
|
||||
|
||||
function intersection(origin: number[], inverse: number[], min: number[], max: number[]) {
|
||||
let near = -Infinity;
|
||||
let far = Infinity;
|
||||
for (let lane = 0; lane < 3; lane++) {
|
||||
const a = (min[lane] - origin[lane]) * inverse[lane];
|
||||
const b = (max[lane] - origin[lane]) * inverse[lane];
|
||||
near = Math.max(near, Math.min(a, b));
|
||||
far = Math.min(far, Math.max(a, b));
|
||||
}
|
||||
return far >= Math.max(near, 0) ? Math.max(near, 0) : Infinity;
|
||||
}
|
||||
|
||||
function trace(branch: Branch | undefined, origin: number[], inverse: number[], hits: { id: number; distance: number }[]) {
|
||||
if (!branch || !Number.isFinite(intersection(origin, inverse, branch.min, branch.max))) return;
|
||||
for (const box of branch.boxes ?? []) {
|
||||
const distance = intersection(origin, inverse, box.min, box.max);
|
||||
if (Number.isFinite(distance)) hits.push({ id: box.id, distance });
|
||||
}
|
||||
trace(branch.left, origin, inverse, hits);
|
||||
trace(branch.right, origin, inverse, hits);
|
||||
}
|
||||
|
||||
addEventListener("message", ({ data }) => {
|
||||
if (data.type === "sync") {
|
||||
shares = data.shares;
|
||||
rebuild();
|
||||
postMessage({ type: "synced", request: data.request });
|
||||
return;
|
||||
}
|
||||
if (data.type === "pick") {
|
||||
const frame = Number(view("info")?.[1] ?? -1);
|
||||
if (frame !== builtFrame) rebuild();
|
||||
const inverse = data.direction.map((lane: number) => 1 / lane);
|
||||
const hits: { id: number; distance: number }[] = [];
|
||||
trace(root, data.origin, inverse, hits);
|
||||
hits.sort((a, b) => a.distance - b.distance);
|
||||
postMessage({ type: "hits", request: data.request, hits });
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Mesh } from "../Mesh";
|
||||
import type { Scene } from "../Scene";
|
||||
import { PBRMaterial } from "../materials/PBRMaterial";
|
||||
|
||||
let worker: Worker | undefined;
|
||||
let nextRequest = 1;
|
||||
const pending = new Map<number, { resolve: (value: any) => void; reject: (error: Error) => void }>();
|
||||
|
||||
function importer() {
|
||||
if (worker) return worker;
|
||||
worker = new Worker(new URL("./worker.ts", import.meta.url), { type: "module", name: "yawn-importer" });
|
||||
worker.addEventListener("message", ({ data }) => {
|
||||
const request = pending.get(data.request);
|
||||
if (!request) return;
|
||||
pending.delete(data.request);
|
||||
if (data.error) request.reject(new Error(data.error));
|
||||
else request.resolve(data.result);
|
||||
});
|
||||
worker.addEventListener("error", () => {
|
||||
for (const request of pending.values()) request.reject(new Error("IMPORT_WORKER_ERROR"));
|
||||
pending.clear();
|
||||
});
|
||||
return worker;
|
||||
}
|
||||
|
||||
/** Imports glTF/GLB off-thread, then hydrates conventional handles backed by Scene SAB rows. */
|
||||
export async function importGltf(scene: Scene, url: string | URL) {
|
||||
await scene.ready;
|
||||
const request = nextRequest++;
|
||||
const result = await new Promise<any>((resolve, reject) => {
|
||||
pending.set(request, { resolve, reject });
|
||||
importer().postMessage({ request, url: String(url) });
|
||||
});
|
||||
const materials = result.materials.map((options: any) => new PBRMaterial(scene, options));
|
||||
await Promise.all(materials.map((material: PBRMaterial) => material.ready));
|
||||
const meshes: Mesh[] = [];
|
||||
for (const primitive of result.primitives) {
|
||||
const mesh = new Mesh(scene, {
|
||||
position: primitive.position,
|
||||
quaternion: primitive.quaternion,
|
||||
scale: primitive.scale,
|
||||
material: materials[primitive.material],
|
||||
vertexData: {
|
||||
positions: primitive.positions,
|
||||
indices: primitive.indices,
|
||||
...(primitive.normals ? { normals: primitive.normals } : {}),
|
||||
...(primitive.tangents ? { tangents: primitive.tangents } : {}),
|
||||
...(primitive.uvs ? { uvs: primitive.uvs } : {}),
|
||||
...(primitive.colors ? { colors: primitive.colors } : {}),
|
||||
},
|
||||
});
|
||||
await mesh.ready;
|
||||
meshes.push(mesh);
|
||||
}
|
||||
return meshes;
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
const decoder = new TextDecoder();
|
||||
const widths: Record<string, number> = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4 };
|
||||
const sizes: Record<number, number> = { 5120: 1, 5121: 1, 5122: 2, 5123: 2, 5125: 4, 5126: 4 };
|
||||
|
||||
function component(view: DataView, offset: number, type: number) {
|
||||
if (type === 5120) return view.getInt8(offset);
|
||||
if (type === 5121) return view.getUint8(offset);
|
||||
if (type === 5122) return view.getInt16(offset, true);
|
||||
if (type === 5123) return view.getUint16(offset, true);
|
||||
if (type === 5125) return view.getUint32(offset, true);
|
||||
if (type === 5126) return view.getFloat32(offset, true);
|
||||
throw new Error("GLTF_COMPONENT");
|
||||
}
|
||||
|
||||
function parse(bytes: Uint8Array) {
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
if (view.getUint32(0, true) !== 0x46546c67)
|
||||
return { document: JSON.parse(decoder.decode(bytes)), binary: undefined as Uint8Array | undefined };
|
||||
let offset = 12;
|
||||
let document: any;
|
||||
let binary: Uint8Array | undefined;
|
||||
while (offset < bytes.length) {
|
||||
const length = view.getUint32(offset, true);
|
||||
const type = view.getUint32(offset + 4, true);
|
||||
const chunk = bytes.subarray(offset + 8, offset + 8 + length);
|
||||
if (type === 0x4e4f534a) document = JSON.parse(decoder.decode(chunk).replace(/\0+$/u, ""));
|
||||
if (type === 0x004e4942) binary = chunk;
|
||||
offset += 8 + length;
|
||||
}
|
||||
if (!document) throw new Error("GLTF_JSON");
|
||||
return { document, binary };
|
||||
}
|
||||
|
||||
async function load(url: string) {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) throw new Error(`HTTP_${response.status}`);
|
||||
const { document, binary } = parse(new Uint8Array(await response.arrayBuffer()));
|
||||
const buffers = await Promise.all((document.buffers ?? []).map(async (buffer: any, index: number) => {
|
||||
if (buffer.uri === undefined) {
|
||||
if (index || !binary) throw new Error("GLTF_BUFFER");
|
||||
return binary;
|
||||
}
|
||||
const result = await fetch(new URL(buffer.uri, url));
|
||||
if (!result.ok) throw new Error(`HTTP_${result.status}`);
|
||||
return new Uint8Array(await result.arrayBuffer());
|
||||
}));
|
||||
|
||||
const accessor = (id: number, integer = false) => {
|
||||
const source = document.accessors[id];
|
||||
const width = widths[source.type];
|
||||
const size = sizes[source.componentType];
|
||||
const bufferView = document.bufferViews[source.bufferView];
|
||||
const bytes = buffers[bufferView.buffer];
|
||||
const start = (bufferView.byteOffset ?? 0) + (source.byteOffset ?? 0);
|
||||
const stride = bufferView.byteStride ?? width * size;
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
const values = integer ? new Uint32Array(source.count * width) : new Float32Array(source.count * width);
|
||||
for (let item = 0; item < source.count; item++) for (let lane = 0; lane < width; lane++) {
|
||||
let value = component(view, start + item * stride + lane * size, source.componentType);
|
||||
if (!integer && source.normalized) {
|
||||
const maximum = source.componentType === 5121 ? 255 : source.componentType === 5123 ? 65535 : 1;
|
||||
value /= maximum;
|
||||
}
|
||||
values[item * width + lane] = value;
|
||||
}
|
||||
return values;
|
||||
};
|
||||
|
||||
const materials = (document.materials ?? []).map((material: any) => {
|
||||
const pbr = material.pbrMetallicRoughness ?? {};
|
||||
return {
|
||||
baseColor: pbr.baseColorFactor ?? [1, 1, 1, 1],
|
||||
metallic: pbr.metallicFactor ?? 0,
|
||||
roughness: pbr.roughnessFactor ?? 0.7,
|
||||
emissive: material.emissiveFactor ?? [0, 0, 0],
|
||||
alphaCutoff: material.alphaCutoff ?? 0.5,
|
||||
};
|
||||
});
|
||||
const primitives: any[] = [];
|
||||
const emitMesh = (meshId: number, transform: any) => {
|
||||
const mesh = document.meshes?.[meshId];
|
||||
for (const primitive of mesh?.primitives ?? []) {
|
||||
if ((primitive.mode ?? 4) !== 4 || primitive.attributes.POSITION === undefined) continue;
|
||||
const positions = accessor(primitive.attributes.POSITION);
|
||||
const indices = primitive.indices === undefined
|
||||
? Uint32Array.from({ length: positions.length / 3 }, (_, index) => index)
|
||||
: accessor(primitive.indices, true);
|
||||
primitives.push({
|
||||
positions,
|
||||
indices,
|
||||
...(primitive.attributes.NORMAL === undefined ? {} : { normals: accessor(primitive.attributes.NORMAL) }),
|
||||
...(primitive.attributes.TANGENT === undefined ? {} : { tangents: accessor(primitive.attributes.TANGENT) }),
|
||||
...(primitive.attributes.TEXCOORD_0 === undefined ? {} : { uvs: accessor(primitive.attributes.TEXCOORD_0) }),
|
||||
...(primitive.attributes.COLOR_0 === undefined ? {} : { colors: accessor(primitive.attributes.COLOR_0) }),
|
||||
material: primitive.material ?? -1,
|
||||
...transform,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const nodes = document.nodes ?? [];
|
||||
const scene = document.scenes?.[document.scene ?? 0];
|
||||
const children = new Set(nodes.flatMap((node: any) => node.children ?? []));
|
||||
const roots = scene?.nodes ?? nodes.map((_: any, id: number) => id).filter((id: number) => !children.has(id));
|
||||
const visit = (id: number, parent = { position: [0, 0, 0], scale: [1, 1, 1] }) => {
|
||||
const node = nodes[id] ?? {};
|
||||
const position = (node.translation ?? [0, 0, 0]).map((value: number, lane: number) => value + parent.position[lane]);
|
||||
const scale = (node.scale ?? [1, 1, 1]).map((value: number, lane: number) => value * parent.scale[lane]);
|
||||
const transform = { position, scale, quaternion: node.rotation ?? [0, 0, 0, 1] };
|
||||
if (node.mesh !== undefined) emitMesh(node.mesh, transform);
|
||||
for (const child of node.children ?? []) visit(child, transform);
|
||||
};
|
||||
for (const root of roots) visit(root);
|
||||
if (!nodes.length) for (let id = 0; id < (document.meshes ?? []).length; id++) emitMesh(id, {});
|
||||
return { materials, primitives };
|
||||
}
|
||||
|
||||
addEventListener("message", async ({ data }) => {
|
||||
try {
|
||||
const result = await load(data.url);
|
||||
const transfers = result.primitives.flatMap((primitive: any) =>
|
||||
["positions", "indices", "normals", "tangents", "uvs", "colors"]
|
||||
.map((name) => primitive[name]?.buffer).filter(Boolean));
|
||||
(postMessage as any)({ request: data.request, result }, transfers);
|
||||
} catch (error) {
|
||||
postMessage({ request: data.request, error: error instanceof Error ? error.message : "GLTF_IMPORT" });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
export * from "./Scene";
|
||||
export * from "./ComputePass";
|
||||
export * from "./Node";
|
||||
export * from "./Mesh";
|
||||
export * from "./camera/Camera";
|
||||
export * from "./camera/ArcRotateCamera";
|
||||
export * from "./camera/FreeCamera";
|
||||
export * from "./camera/FollowCamera";
|
||||
|
||||
export * from "./lights/PointLight";
|
||||
export * from "./lights/RectAreaLight";
|
||||
export * from "./lights/SpotLight";
|
||||
export * from "./lights/DirectionalLight";
|
||||
export * from "./lights/AmbientLight";
|
||||
|
||||
export * from "./materials/Texture";
|
||||
export * from "./materials/PBRMaterial";
|
||||
export * from "./materials/ShaderMaterial";
|
||||
|
||||
export * from "./bvh/picking";
|
||||
export * from "./importers/gltf";
|
||||
|
||||
export * from "./PostProcesses/PostProcess";
|
||||
export * from "./PostProcesses/SSAO";
|
||||
export * from "./PostProcesses/FXAA";
|
||||
export * from "./PostProcesses/ColorGrading";
|
||||
export * from "./PostProcesses/DynamicExposure";
|
||||
export * from "./PostProcesses/Silhouette";
|
||||
export * from "./PostProcesses/Edges";
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Node, type NodeOptions } from "../Node";
|
||||
import type { Scene } from "../Scene";
|
||||
|
||||
export type AmbientLightOptions = NodeOptions & { color?: ArrayLike<number>; intensity?: number };
|
||||
|
||||
export class AmbientLight extends Node {
|
||||
constructor(scene: Scene, options: AmbientLightOptions = {}) {
|
||||
super(scene, options);
|
||||
const nodeReady = this.ready;
|
||||
this.ready = nodeReady.then(async () => {
|
||||
const array = await scene.ensureRows("ambientLights", this.id + 1, 16, "f32");
|
||||
array.row(this.id).set([...Array.from(options.color ?? [1, 1, 1]), options.intensity ?? 0.1]);
|
||||
return this;
|
||||
});
|
||||
}
|
||||
|
||||
get intensity() { return this.scene.array("ambientLights").row(this.id)[3]; }
|
||||
set intensity(value: number) { this.scene.array("ambientLights").row(this.id)[3] = value; }
|
||||
|
||||
override async dispose() {
|
||||
await this.ready;
|
||||
if (this.disposed) return;
|
||||
this.scene.array("ambientLights").row(this.id).fill(0);
|
||||
await super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Node, type NodeOptions } from "../Node";
|
||||
import type { Scene } from "../Scene";
|
||||
|
||||
export type DirectionalLightOptions = NodeOptions & { color?: ArrayLike<number>; intensity?: number };
|
||||
|
||||
export class DirectionalLight extends Node {
|
||||
constructor(scene: Scene, options: DirectionalLightOptions = {}) {
|
||||
super(scene, options);
|
||||
const nodeReady = this.ready;
|
||||
this.ready = nodeReady.then(async () => {
|
||||
const array = await scene.ensureRows("directionalLights", this.id + 1, 32, "f32");
|
||||
array.row(this.id).set([...Array.from(options.color ?? [1, 1, 1]), options.intensity ?? 1, 1, 0, 0, 0]);
|
||||
return this;
|
||||
});
|
||||
}
|
||||
|
||||
get intensity() { return this.scene.array("directionalLights").row(this.id)[3]; }
|
||||
set intensity(value: number) { this.scene.array("directionalLights").row(this.id)[3] = value; }
|
||||
|
||||
override async dispose() {
|
||||
await this.ready;
|
||||
if (this.disposed) return;
|
||||
this.scene.array("directionalLights").row(this.id).fill(0);
|
||||
await super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Node, type NodeOptions } from "../Node";
|
||||
import type { Scene } from "../Scene";
|
||||
|
||||
export type PointLightOptions = NodeOptions & { color?: ArrayLike<number>; intensity?: number; range?: number };
|
||||
|
||||
export class PointLight extends Node {
|
||||
constructor(scene: Scene, options: PointLightOptions = {}) {
|
||||
super(scene, options);
|
||||
const nodeReady = this.ready;
|
||||
this.ready = nodeReady.then(async () => {
|
||||
const array = await scene.ensureRows("pointLights", this.id + 1, 32, "f32");
|
||||
array.row(this.id).set([...Array.from(options.color ?? [1, 1, 1]), options.intensity ?? 1, options.range ?? 10, 1, 0, 0]);
|
||||
return this;
|
||||
});
|
||||
}
|
||||
|
||||
get color() { return this.scene.array("pointLights").row(this.id).subarray(0, 3); }
|
||||
set color(value: ArrayLike<number>) { this.scene.array("pointLights").row(this.id).set(value, 0); }
|
||||
get intensity() { return this.scene.array("pointLights").row(this.id)[3]; }
|
||||
set intensity(value: number) { this.scene.array("pointLights").row(this.id)[3] = value; }
|
||||
get range() { return this.scene.array("pointLights").row(this.id)[4]; }
|
||||
set range(value: number) { this.scene.array("pointLights").row(this.id)[4] = value; }
|
||||
|
||||
override async dispose() {
|
||||
await this.ready;
|
||||
if (this.disposed) return;
|
||||
this.scene.array("pointLights").row(this.id).fill(0);
|
||||
await super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Node, type NodeOptions } from "../Node";
|
||||
import type { Scene } from "../Scene";
|
||||
|
||||
export type RectAreaLightOptions = NodeOptions & {
|
||||
color?: ArrayLike<number>;
|
||||
intensity?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
|
||||
/** Rectangular emitter data for the default clustered forward graph's LTC path. */
|
||||
export class RectAreaLight extends Node {
|
||||
readonly technique = "ltc";
|
||||
|
||||
constructor(scene: Scene, options: RectAreaLightOptions = {}) {
|
||||
super(scene, options);
|
||||
const nodeReady = this.ready;
|
||||
this.ready = nodeReady.then(async () => {
|
||||
const array = await scene.ensureRows("rectAreaLights", this.id + 1, 48, "f32");
|
||||
array.row(this.id).set([
|
||||
...Array.from(options.color ?? [1, 1, 1]), options.intensity ?? 1,
|
||||
options.width ?? 1, options.height ?? 1, 1, 0,
|
||||
]);
|
||||
return this;
|
||||
});
|
||||
}
|
||||
|
||||
get width() { return this.scene.array("rectAreaLights").row(this.id)[4]; }
|
||||
set width(value: number) { this.scene.array("rectAreaLights").row(this.id)[4] = value; }
|
||||
get height() { return this.scene.array("rectAreaLights").row(this.id)[5]; }
|
||||
set height(value: number) { this.scene.array("rectAreaLights").row(this.id)[5] = value; }
|
||||
|
||||
override async dispose() {
|
||||
await this.ready;
|
||||
if (this.disposed) return;
|
||||
this.scene.array("rectAreaLights").row(this.id).fill(0);
|
||||
await super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Node, type NodeOptions } from "../Node";
|
||||
import type { Scene } from "../Scene";
|
||||
|
||||
export type SpotLightOptions = NodeOptions & {
|
||||
color?: ArrayLike<number>;
|
||||
intensity?: number;
|
||||
range?: number;
|
||||
innerAngle?: number;
|
||||
outerAngle?: number;
|
||||
};
|
||||
|
||||
export class SpotLight extends Node {
|
||||
constructor(scene: Scene, options: SpotLightOptions = {}) {
|
||||
super(scene, options);
|
||||
const nodeReady = this.ready;
|
||||
this.ready = nodeReady.then(async () => {
|
||||
const array = await scene.ensureRows("spotLights", this.id + 1, 32, "f32");
|
||||
array.row(this.id).set([
|
||||
...Array.from(options.color ?? [1, 1, 1]), options.intensity ?? 1,
|
||||
options.range ?? 10, options.innerAngle ?? 0.35, options.outerAngle ?? 0.7, 1,
|
||||
]);
|
||||
return this;
|
||||
});
|
||||
}
|
||||
|
||||
get innerAngle() { return this.scene.array("spotLights").row(this.id)[5]; }
|
||||
set innerAngle(value: number) { this.scene.array("spotLights").row(this.id)[5] = value; }
|
||||
get outerAngle() { return this.scene.array("spotLights").row(this.id)[6]; }
|
||||
set outerAngle(value: number) { this.scene.array("spotLights").row(this.id)[6] = value; }
|
||||
|
||||
override async dispose() {
|
||||
await this.ready;
|
||||
if (this.disposed) return;
|
||||
this.scene.array("spotLights").row(this.id).fill(0);
|
||||
await super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { Scene } from "../Scene";
|
||||
import type { Texture } from "./Texture";
|
||||
|
||||
export type PBRMaterialOptions = {
|
||||
baseColor?: ArrayLike<number>;
|
||||
metallic?: number;
|
||||
roughness?: number;
|
||||
emissive?: ArrayLike<number>;
|
||||
normalScale?: number;
|
||||
alphaCutoff?: number;
|
||||
baseColorTexture?: Texture;
|
||||
metallicRoughnessTexture?: Texture;
|
||||
normalTexture?: Texture;
|
||||
emissiveTexture?: Texture;
|
||||
};
|
||||
|
||||
/** Conventional PBR values and texture IDs stored in two shared SOA rows. */
|
||||
export class PBRMaterial {
|
||||
readonly scene: Scene;
|
||||
id = -1;
|
||||
readonly ready: Promise<this>;
|
||||
#disposed = false;
|
||||
|
||||
constructor(scene: Scene, options: PBRMaterialOptions = {}) {
|
||||
this.scene = scene;
|
||||
this.ready = scene.allocateMaterial().then((id) => {
|
||||
this.id = id;
|
||||
const color = Array.from(options.baseColor ?? [1, 1, 1, 1]);
|
||||
const emissive = Array.from(options.emissive ?? [0, 0, 0]);
|
||||
if (color.length !== 4 || emissive.length !== 3) throw new TypeError("PBR material vectors");
|
||||
scene.array("materials").write(id, [
|
||||
...color,
|
||||
options.metallic ?? 0,
|
||||
options.roughness ?? 0.7,
|
||||
...emissive,
|
||||
options.normalScale ?? 1,
|
||||
options.alphaCutoff ?? 0.5,
|
||||
0,
|
||||
]);
|
||||
scene.array("materialTextures").write(id, [
|
||||
(options.baseColorTexture?.id ?? -1) + 1,
|
||||
(options.metallicRoughnessTexture?.id ?? -1) + 1,
|
||||
(options.normalTexture?.id ?? -1) + 1,
|
||||
(options.emissiveTexture?.id ?? -1) + 1,
|
||||
0, 0, 0, 0,
|
||||
]);
|
||||
return this;
|
||||
});
|
||||
}
|
||||
|
||||
#values() {
|
||||
if (this.id < 0) throw new Error("Await material.ready before reading or writing it");
|
||||
return this.scene.array("materials").row(this.id);
|
||||
}
|
||||
|
||||
get baseColor() { return this.#values().subarray(0, 4); }
|
||||
set baseColor(value: ArrayLike<number>) {
|
||||
if (value.length !== 4) throw new RangeError("baseColor");
|
||||
this.#values().set(value, 0);
|
||||
}
|
||||
get metallic() { return this.#values()[4]; }
|
||||
set metallic(value: number) { this.#values()[4] = value; }
|
||||
get roughness() { return this.#values()[5]; }
|
||||
set roughness(value: number) { this.#values()[5] = value; }
|
||||
get emissive() { return this.#values().subarray(6, 9); }
|
||||
set emissive(value: ArrayLike<number>) {
|
||||
if (value.length !== 3) throw new RangeError("emissive");
|
||||
this.#values().set(value, 6);
|
||||
}
|
||||
|
||||
async dispose() {
|
||||
await this.ready;
|
||||
if (this.#disposed) return;
|
||||
this.#disposed = true;
|
||||
await this.scene.core.deleteObject("materials", this.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Scene } from "../Scene";
|
||||
|
||||
export type ShaderMaterialOptions = {
|
||||
code: string;
|
||||
vertexEntry?: string;
|
||||
fragmentEntry?: string;
|
||||
};
|
||||
|
||||
/** User WGSL represented as a material handle; registration rebuilds the Scene graph loadout. */
|
||||
export class ShaderMaterial {
|
||||
readonly scene: Scene;
|
||||
id = -1;
|
||||
code: string;
|
||||
vertexEntry: string;
|
||||
fragmentEntry: string;
|
||||
readonly ready: Promise<this>;
|
||||
#disposed = false;
|
||||
|
||||
constructor(scene: Scene, options: ShaderMaterialOptions) {
|
||||
if (!options?.code) throw new TypeError("ShaderMaterial code is required");
|
||||
this.scene = scene;
|
||||
this.code = options.code;
|
||||
this.vertexEntry = options.vertexEntry ?? "vertex";
|
||||
this.fragmentEntry = options.fragmentEntry ?? "fragment";
|
||||
this.ready = scene.allocateMaterial().then(async (id) => {
|
||||
this.id = id;
|
||||
await scene.registerShader(this);
|
||||
return this;
|
||||
});
|
||||
}
|
||||
|
||||
async update(options: Partial<ShaderMaterialOptions>) {
|
||||
await this.ready;
|
||||
if (options.code !== undefined) this.code = options.code;
|
||||
if (options.vertexEntry !== undefined) this.vertexEntry = options.vertexEntry;
|
||||
if (options.fragmentEntry !== undefined) this.fragmentEntry = options.fragmentEntry;
|
||||
await this.scene.registerShader(this);
|
||||
return this;
|
||||
}
|
||||
|
||||
async dispose() {
|
||||
await this.ready;
|
||||
if (this.#disposed) return;
|
||||
this.#disposed = true;
|
||||
await this.scene.unregisterShader(this.id);
|
||||
await this.scene.core.deleteObject("materials", this.id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { Scene } from "../Scene";
|
||||
|
||||
export type TextureOptions = {
|
||||
source?: string | ImageBitmap;
|
||||
size?: [number | "canvas", number | "canvas", number?];
|
||||
format?: string;
|
||||
usage?: string[];
|
||||
transient?: boolean;
|
||||
};
|
||||
|
||||
let nextTextureName = 1;
|
||||
|
||||
/** A graph texture resource handle; image decoding/upload policy remains outside core. */
|
||||
export class Texture {
|
||||
readonly scene: Scene;
|
||||
readonly id: number;
|
||||
readonly resource: string;
|
||||
readonly source?: string | ImageBitmap;
|
||||
readonly ready: Promise<void>;
|
||||
#disposed = false;
|
||||
|
||||
constructor(scene: Scene, options: TextureOptions = {}) {
|
||||
this.scene = scene;
|
||||
this.resource = `texture-${nextTextureName++}`;
|
||||
this.source = options.source;
|
||||
const registration = scene.registerTexture({
|
||||
id: this.resource,
|
||||
source: options.source,
|
||||
size: options.size ?? [1, 1, 1],
|
||||
format: options.format ?? "rgba8unorm",
|
||||
usage: [...new Set([...(options.usage ?? ["copyDst"]), "sampled"])],
|
||||
transient: options.transient ?? false,
|
||||
});
|
||||
this.id = registration.number;
|
||||
this.ready = registration.ready;
|
||||
}
|
||||
|
||||
async dispose() {
|
||||
await this.ready;
|
||||
if (this.#disposed) return;
|
||||
this.#disposed = true;
|
||||
await this.scene.unregisterTexture(this.id);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user