Add interactive tutorial playgrounds
Embed editable split-view examples throughout the guide, restore the full LFS-backed Sponza demo, batch glTF hydration, honor hierarchical transforms, and report transformed BVH picks with a live FPS counter. 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:
@@ -10,5 +10,11 @@ rustup toolchain install "$toolchain" --profile minimal --component rust-src,rus
|
|||||||
if ! command -v wasm-pack >/dev/null; then
|
if ! command -v wasm-pack >/dev/null; then
|
||||||
cargo install wasm-pack --version 0.15.0 --locked
|
cargo install wasm-pack --version 0.15.0 --locked
|
||||||
fi
|
fi
|
||||||
|
if ! command -v git-lfs >/dev/null; then
|
||||||
|
sudo apt-get update -qq
|
||||||
|
sudo apt-get install -y git-lfs
|
||||||
|
fi
|
||||||
|
git lfs install --local
|
||||||
|
git lfs pull --include="docs/public/models/sponza.glb"
|
||||||
npm ci
|
npm ci
|
||||||
amp orb services ensure
|
amp orb services ensure
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
docs/public/models/sponza.glb filter=lfs diff=lfs merge=lfs -text
|
||||||
+388
-80
@@ -20,8 +20,15 @@ type ShaderLike = {
|
|||||||
vertexEntry: string;
|
vertexEntry: string;
|
||||||
fragmentEntry: string;
|
fragmentEntry: string;
|
||||||
};
|
};
|
||||||
type PostProcessState = { id: string; kind: string; options: Record<string, unknown> };
|
type PostProcessState = {
|
||||||
type TextureState = GraphTexture & { number: number; source?: string | ImageBitmap };
|
id: string;
|
||||||
|
kind: string;
|
||||||
|
options: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
type TextureState = GraphTexture & {
|
||||||
|
number: number;
|
||||||
|
source?: string | ImageBitmap;
|
||||||
|
};
|
||||||
|
|
||||||
const rows = [
|
const rows = [
|
||||||
["nodes", 16, "u32"],
|
["nodes", 16, "u32"],
|
||||||
@@ -193,7 +200,8 @@ function effectFragment(kind: string, options: Record<string, unknown>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function presentShader(toneMap: string) {
|
function presentShader(toneMap: string) {
|
||||||
const tone = toneMap === "reinhard"
|
const tone =
|
||||||
|
toneMap === "reinhard"
|
||||||
? "color / (color + vec3(1.0))"
|
? "color / (color + vec3(1.0))"
|
||||||
: toneMap === "linear"
|
: toneMap === "linear"
|
||||||
? "clamp(color, vec3(0.0), vec3(1.0))"
|
? "clamp(color, vec3(0.0), vec3(1.0))"
|
||||||
@@ -209,11 +217,19 @@ function presentShader(toneMap: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function encode(value: unknown): string {
|
function encode(value: unknown): string {
|
||||||
if (value === null || typeof value === "boolean" || typeof value === "number") return String(value);
|
if (value === null || typeof value === "boolean" || typeof value === "number")
|
||||||
|
return String(value);
|
||||||
if (typeof value === "string") return JSON.stringify(value);
|
if (typeof value === "string") return JSON.stringify(value);
|
||||||
if (Array.isArray(value)) return `(array${value.map((item) => ` ${encode(item)}`).join("")})`;
|
if (Array.isArray(value))
|
||||||
if (value && value.constructor === Object) return `(object${Object.keys(value as object).sort().map((key) =>
|
return `(array${value.map((item) => ` ${encode(item)}`).join("")})`;
|
||||||
` (field ${JSON.stringify(key)} ${encode((value as Record<string, unknown>)[key])})`).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");
|
throw new TypeError("Render graph values must be plain data");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,8 +252,13 @@ export class Scene {
|
|||||||
#geometryRefs = new Map<number, number>();
|
#geometryRefs = new Map<number, number>();
|
||||||
#nextGeometry = 1;
|
#nextGeometry = 1;
|
||||||
#nextTexture = 0;
|
#nextTexture = 0;
|
||||||
|
#graphBatchDepth = 0;
|
||||||
|
#graphBatchDirty = false;
|
||||||
|
|
||||||
constructor(canvas: HTMLCanvasElement, options: { arenaBytes?: number; fps?: number; hdr?: boolean } = {}) {
|
constructor(
|
||||||
|
canvas: HTMLCanvasElement,
|
||||||
|
options: { arenaBytes?: number; fps?: number; hdr?: boolean } = {},
|
||||||
|
) {
|
||||||
this.hdr = options.hdr ?? true;
|
this.hdr = options.hdr ?? true;
|
||||||
this.core = new YawnCore(canvas, { arenaBytes: options.arenaBytes });
|
this.core = new YawnCore(canvas, { arenaBytes: options.arenaBytes });
|
||||||
this.ready = this.#initialize(options.fps ?? 60);
|
this.ready = this.#initialize(options.fps ?? 60);
|
||||||
@@ -247,12 +268,19 @@ export class Scene {
|
|||||||
await this.core.ready;
|
await this.core.ready;
|
||||||
for (const [name, stride, format] of rows)
|
for (const [name, stride, format] of rows)
|
||||||
await this.core.createRows({ name, rows: 1, stride, format });
|
await this.core.createRows({ name, rows: 1, stride, format });
|
||||||
await this.core.createRows({ name: "clusters", rows: 256, stride: 16, format: "u32" });
|
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("nodeQuaternions").write(0, [0, 0, 0, 1]);
|
||||||
this.core.array("nodeScales").write(0, [1, 1, 1, 0]);
|
this.core.array("nodeScales").write(0, [1, 1, 1, 0]);
|
||||||
this.core.array("sceneAccent").write(0, [0.28, 0.72, 1, 1]);
|
this.core.array("sceneAccent").write(0, [0.28, 0.72, 1, 1]);
|
||||||
const material = await this.core.allocateObject("materials");
|
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]);
|
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.core.setFps(fps);
|
||||||
await this.#compileRenderGraph();
|
await this.#compileRenderGraph();
|
||||||
return this;
|
return this;
|
||||||
@@ -262,16 +290,80 @@ export class Scene {
|
|||||||
return this.core.array(name);
|
return this.core.array(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
async ensureRows(name: string, rowCount: number, stride: number, format: RowFormat) {
|
async ensureRows(
|
||||||
|
name: string,
|
||||||
|
rowCount: number,
|
||||||
|
stride: number,
|
||||||
|
format: RowFormat,
|
||||||
|
) {
|
||||||
await this.core.ready;
|
await this.core.ready;
|
||||||
try {
|
try {
|
||||||
const current = this.core.array(name);
|
const current = this.core.array(name);
|
||||||
if (current.stride !== stride || current.format !== format) throw new Error(`ROW_LAYOUT: ${name}`);
|
if (current.stride !== stride || current.format !== format)
|
||||||
|
throw new Error(`ROW_LAYOUT: ${name}`);
|
||||||
if (current.rows >= rowCount) return current;
|
if (current.rows >= rowCount) return current;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!(error instanceof Error) || !error.message.startsWith("UNKNOWN_ARRAY")) throw 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 reserve(additional: { nodes?: number; materials?: number }) {
|
||||||
|
await this.ready;
|
||||||
|
const nodes = additional.nodes ?? 0;
|
||||||
|
const materials = additional.materials ?? 0;
|
||||||
|
if (
|
||||||
|
![nodes, materials].every(
|
||||||
|
(value) => Number.isInteger(value) && value >= 0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
throw new RangeError("additional");
|
||||||
|
const nodeCapacity = this.array("nodes").rows + nodes;
|
||||||
|
const materialCapacity = this.array("materials").rows + materials;
|
||||||
|
const growth = [
|
||||||
|
...rows.slice(0, 6).map(([name, stride, format]) => ({
|
||||||
|
name,
|
||||||
|
rows: nodeCapacity,
|
||||||
|
stride,
|
||||||
|
format,
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
name: "materials",
|
||||||
|
rows: materialCapacity,
|
||||||
|
stride: 48,
|
||||||
|
format: "f32" as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "materialTextures",
|
||||||
|
rows: materialCapacity,
|
||||||
|
stride: 32,
|
||||||
|
format: "u32" as const,
|
||||||
|
},
|
||||||
|
].filter((request) => this.array(request.name).rows < request.rows);
|
||||||
|
if (growth.length) await this.core.createRowsBatch(growth);
|
||||||
|
}
|
||||||
|
|
||||||
|
async batchGraphUpdates<T>(operation: () => T | Promise<T>) {
|
||||||
|
await this.ready;
|
||||||
|
this.#graphBatchDepth++;
|
||||||
|
try {
|
||||||
|
return await operation();
|
||||||
|
} finally {
|
||||||
|
this.#graphBatchDepth--;
|
||||||
|
if (!this.#graphBatchDepth && this.#graphBatchDirty) {
|
||||||
|
this.#graphBatchDirty = false;
|
||||||
|
await this.updateRenderGraph();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return this.core.createRows({ name, rows: Math.max(1, rowCount), stride, format });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async allocateNode() {
|
async allocateNode() {
|
||||||
@@ -279,7 +371,9 @@ export class Scene {
|
|||||||
const id = await this.core.allocateObject("nodes");
|
const id = await this.core.allocateObject("nodes");
|
||||||
const growth = rows.slice(1, 6).flatMap(([name, stride, format]) => {
|
const growth = rows.slice(1, 6).flatMap(([name, stride, format]) => {
|
||||||
const current = this.array(name);
|
const current = this.array(name);
|
||||||
return current.rows < id + 1 ? [{ name, rows: id + 1, stride, format }] : [];
|
return current.rows < id + 1
|
||||||
|
? [{ name, rows: id + 1, stride, format }]
|
||||||
|
: [];
|
||||||
});
|
});
|
||||||
if (growth.length) await this.core.createRowsBatch(growth);
|
if (growth.length) await this.core.createRowsBatch(growth);
|
||||||
this.array("nodeQuaternions").write(id, [0, 0, 0, 1]);
|
this.array("nodeQuaternions").write(id, [0, 0, 0, 1]);
|
||||||
@@ -289,7 +383,14 @@ export class Scene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async releaseNode(id: number) {
|
async releaseNode(id: number) {
|
||||||
for (const name of ["nodes", "nodePositions", "nodeQuaternions", "nodeScales", "meshInfo", "bounds"])
|
for (const name of [
|
||||||
|
"nodes",
|
||||||
|
"nodePositions",
|
||||||
|
"nodeQuaternions",
|
||||||
|
"nodeScales",
|
||||||
|
"meshInfo",
|
||||||
|
"bounds",
|
||||||
|
])
|
||||||
this.array(name).row(id).fill(0);
|
this.array(name).row(id).fill(0);
|
||||||
await this.core.deleteObject("nodes", id);
|
await this.core.deleteObject("nodes", id);
|
||||||
}
|
}
|
||||||
@@ -302,7 +403,8 @@ export class Scene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
addComputePass(pass: ComputePass) {
|
addComputePass(pass: ComputePass) {
|
||||||
if (this.#computePasses.has(pass.id)) throw new Error(`COMPUTE_PASS_EXISTS: ${pass.id}`);
|
if (this.#computePasses.has(pass.id))
|
||||||
|
throw new Error(`COMPUTE_PASS_EXISTS: ${pass.id}`);
|
||||||
this.#computePasses.set(pass.id, pass);
|
this.#computePasses.set(pass.id, pass);
|
||||||
pass.attach(this);
|
pass.attach(this);
|
||||||
return this.updateRenderGraph();
|
return this.updateRenderGraph();
|
||||||
@@ -318,13 +420,19 @@ export class Scene {
|
|||||||
|
|
||||||
registerMesh(mesh: MeshLike) {
|
registerMesh(mesh: MeshLike) {
|
||||||
this.#meshes.set(mesh.id, mesh);
|
this.#meshes.set(mesh.id, mesh);
|
||||||
this.#geometryRefs.set(mesh.geometryId, (this.#geometryRefs.get(mesh.geometryId) ?? 0) + 1);
|
this.#geometryRefs.set(
|
||||||
|
mesh.geometryId,
|
||||||
|
(this.#geometryRefs.get(mesh.geometryId) ?? 0) + 1,
|
||||||
|
);
|
||||||
return this.updateRenderGraph();
|
return this.updateRenderGraph();
|
||||||
}
|
}
|
||||||
|
|
||||||
async unregisterMesh(mesh: MeshLike) {
|
async unregisterMesh(mesh: MeshLike) {
|
||||||
this.#meshes.delete(mesh.id);
|
this.#meshes.delete(mesh.id);
|
||||||
const references = Math.max(0, (this.#geometryRefs.get(mesh.geometryId) ?? 1) - 1);
|
const references = Math.max(
|
||||||
|
0,
|
||||||
|
(this.#geometryRefs.get(mesh.geometryId) ?? 1) - 1,
|
||||||
|
);
|
||||||
this.#geometryRefs.set(mesh.geometryId, references);
|
this.#geometryRefs.set(mesh.geometryId, references);
|
||||||
await this.updateRenderGraph();
|
await this.updateRenderGraph();
|
||||||
if (!references) {
|
if (!references) {
|
||||||
@@ -351,31 +459,62 @@ export class Scene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
releaseGeometry(id: number) {
|
releaseGeometry(id: number) {
|
||||||
this.#geometryRefs.set(id, Math.max(0, (this.#geometryRefs.get(id) ?? 1) - 1));
|
this.#geometryRefs.set(
|
||||||
|
id,
|
||||||
|
Math.max(0, (this.#geometryRefs.get(id) ?? 1) - 1),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async cloneGeometry(id: number) {
|
async cloneGeometry(id: number) {
|
||||||
const clone = this.createGeometry();
|
const clone = this.createGeometry();
|
||||||
for (const [kind, data] of this.#geometry.get(id) ?? [])
|
for (const [kind, data] of this.#geometry.get(id) ?? [])
|
||||||
await this.setVertexData(clone, kind, data.slice() as Float32Array | Uint32Array, false);
|
await this.setVertexData(
|
||||||
|
clone,
|
||||||
|
kind,
|
||||||
|
data.slice() as Float32Array | Uint32Array,
|
||||||
|
false,
|
||||||
|
);
|
||||||
return clone;
|
return clone;
|
||||||
}
|
}
|
||||||
|
|
||||||
async setVertexData(geometry: number, kind: string, source: ArrayLike<number>, updateGraph = true) {
|
async setVertexData(
|
||||||
const components: Record<string, number> = { positions: 3, normals: 3, tangents: 4, uvs: 2, colors: 4, indices: 1 };
|
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];
|
const width = components[kind];
|
||||||
if (!width || source.length % width) throw new RangeError(`VERTEX_DATA: ${kind}`);
|
if (!width || source.length % width)
|
||||||
|
throw new RangeError(`VERTEX_DATA: ${kind}`);
|
||||||
const integer = kind === "indices";
|
const integer = kind === "indices";
|
||||||
const data = integer ? Uint32Array.from(source) : Float32Array.from(source);
|
const data = integer ? Uint32Array.from(source) : Float32Array.from(source);
|
||||||
const name = `geometry.${geometry}.${kind}`;
|
const name = `geometry.${geometry}.${kind}`;
|
||||||
const rowCount = integer ? Math.ceil(data.length / 4) : data.length / width;
|
const rowCount = integer ? Math.ceil(data.length / 4) : data.length / width;
|
||||||
const target = await this.ensureRows(name, rowCount, 16, integer ? "u32" : "f32");
|
const target = await this.ensureRows(
|
||||||
target.view.fill(0);
|
name,
|
||||||
if (integer) target.view.set(data);
|
rowCount,
|
||||||
else for (let row = 0; row < rowCount; row++)
|
16,
|
||||||
target.row(row).set(data.subarray(row * width, (row + 1) * width));
|
integer ? "u32" : "f32",
|
||||||
(this.#geometry.get(geometry) ?? this.#geometry.set(geometry, new Map()).get(geometry)!)
|
);
|
||||||
.set(kind, data);
|
const view = target.view;
|
||||||
|
view.fill(0);
|
||||||
|
if (integer || width === 4) view.set(data);
|
||||||
|
else
|
||||||
|
for (let row = 0; row < rowCount; row++)
|
||||||
|
for (let lane = 0; lane < width; lane++)
|
||||||
|
view[row * 4 + lane] = data[row * width + lane];
|
||||||
|
(
|
||||||
|
this.#geometry.get(geometry) ??
|
||||||
|
this.#geometry.set(geometry, new Map()).get(geometry)!
|
||||||
|
).set(kind, data);
|
||||||
if (updateGraph) await this.updateRenderGraph();
|
if (updateGraph) await this.updateRenderGraph();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -411,6 +550,10 @@ export class Scene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateRenderGraph() {
|
updateRenderGraph() {
|
||||||
|
if (this.#graphBatchDepth) {
|
||||||
|
this.#graphBatchDirty = true;
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
const update = this.#graphUpdates.then(async () => {
|
const update = this.#graphUpdates.then(async () => {
|
||||||
await this.ready;
|
await this.ready;
|
||||||
await this.#compileRenderGraph();
|
await this.#compileRenderGraph();
|
||||||
@@ -431,24 +574,45 @@ export class Scene {
|
|||||||
const addSampler = (value: GraphSampler) => samplers.set(value.id, value);
|
const addSampler = (value: GraphSampler) => samplers.set(value.id, value);
|
||||||
|
|
||||||
for (const [id, array] of [
|
for (const [id, array] of [
|
||||||
["point-lights", "pointLights"], ["rect-lights", "rectAreaLights"],
|
["point-lights", "pointLights"],
|
||||||
["spot-lights", "spotLights"], ["directional-lights", "directionalLights"],
|
["rect-lights", "rectAreaLights"],
|
||||||
["ambient-lights", "ambientLights"], ["clusters", "clusters"],
|
["spot-lights", "spotLights"],
|
||||||
]) addBuffer({ id, array, usage: ["storage"] });
|
["directional-lights", "directionalLights"],
|
||||||
|
["ambient-lights", "ambientLights"],
|
||||||
|
["clusters", "clusters"],
|
||||||
|
])
|
||||||
|
addBuffer({ id, array, usage: ["storage"] });
|
||||||
addBuffer({ id: "accent", array: "sceneAccent", usage: ["uniform"] });
|
addBuffer({ id: "accent", array: "sceneAccent", usage: ["uniform"] });
|
||||||
|
|
||||||
computePipelines.push({ id: "cluster-lights", code: clusterShader, entry: "main" });
|
computePipelines.push({
|
||||||
|
id: "cluster-lights",
|
||||||
|
code: clusterShader,
|
||||||
|
entry: "main",
|
||||||
|
});
|
||||||
passes.push({
|
passes.push({
|
||||||
id: "cluster-lights", type: "compute", pipeline: "cluster-lights", dispatch: [4, 1, 1],
|
id: "cluster-lights",
|
||||||
bindings: ["point-lights", "rect-lights", "spot-lights", "directional-lights", "ambient-lights", "clusters"]
|
type: "compute",
|
||||||
.map((resource, binding) => ({ group: 0, binding, resource })),
|
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()) {
|
for (const pass of this.#computePasses.values()) {
|
||||||
pass.buffers.forEach(addBuffer);
|
pass.buffers.forEach(addBuffer);
|
||||||
pass.textures.forEach(addTexture);
|
pass.textures.forEach(addTexture);
|
||||||
pass.samplers.forEach(addSampler);
|
pass.samplers.forEach(addSampler);
|
||||||
computePipelines.push({ id: pass.id, code: pass.code, entry: pass.entry });
|
computePipelines.push({
|
||||||
|
id: pass.id,
|
||||||
|
code: pass.code,
|
||||||
|
entry: pass.entry,
|
||||||
|
});
|
||||||
passes.push({
|
passes.push({
|
||||||
id: pass.id,
|
id: pass.id,
|
||||||
type: "compute",
|
type: "compute",
|
||||||
@@ -460,57 +624,146 @@ export class Scene {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const computeIds = [...this.#computePasses.keys()];
|
const computeIds = [...this.#computePasses.keys()];
|
||||||
const renderedMeshes = [...this.#meshes.values()].filter((mesh) => mesh.vertexCount > 0);
|
const renderedMeshes = [...this.#meshes.values()].filter(
|
||||||
|
(mesh) => mesh.vertexCount > 0,
|
||||||
|
);
|
||||||
const hdrFormat = this.hdr ? "rgba16float" : "rgba8unorm";
|
const hdrFormat = this.hdr ? "rgba16float" : "rgba8unorm";
|
||||||
addTexture({ id: "hdr", format: hdrFormat, size: ["canvas", "canvas", 1], usage: ["render", "sampled"], transient: false });
|
addTexture({
|
||||||
|
id: "hdr",
|
||||||
|
format: hdrFormat,
|
||||||
|
size: ["canvas", "canvas", 1],
|
||||||
|
usage: ["render", "sampled"],
|
||||||
|
transient: false,
|
||||||
|
});
|
||||||
addSampler({ id: "linear", magFilter: "linear", minFilter: "linear" });
|
addSampler({ id: "linear", magFilter: "linear", minFilter: "linear" });
|
||||||
|
|
||||||
let previous = computeIds.length ? computeIds : ["cluster-lights"];
|
let previous = computeIds.length ? computeIds : ["cluster-lights"];
|
||||||
if (!renderedMeshes.length) {
|
if (!renderedMeshes.length) {
|
||||||
renderPipelines.push({ id: "empty-forward", code: emptyForwardShader, vertex: { entry: "vertex" }, fragment: { entry: "fragment", targets: [{ format: hdrFormat }] } });
|
renderPipelines.push({
|
||||||
|
id: "empty-forward",
|
||||||
|
code: emptyForwardShader,
|
||||||
|
vertex: { entry: "vertex" },
|
||||||
|
fragment: { entry: "fragment", targets: [{ format: hdrFormat }] },
|
||||||
|
});
|
||||||
passes.push({
|
passes.push({
|
||||||
id: "forward-empty", type: "render", pipeline: "empty-forward", after: previous,
|
id: "forward-empty",
|
||||||
|
type: "render",
|
||||||
|
pipeline: "empty-forward",
|
||||||
|
after: previous,
|
||||||
bindings: [{ group: 0, binding: 0, resource: "accent" }],
|
bindings: [{ group: 0, binding: 0, resource: "accent" }],
|
||||||
color: [{ resource: "hdr", clear: [0.015, 0.025, 0.05, 1] }], draw: { vertices: 3 },
|
color: [{ resource: "hdr", clear: [0.015, 0.025, 0.05, 1] }],
|
||||||
|
draw: { vertices: 3 },
|
||||||
});
|
});
|
||||||
previous = ["forward-empty"];
|
previous = ["forward-empty"];
|
||||||
} else {
|
} else {
|
||||||
for (const [id, array] of [
|
for (const [id, array] of [
|
||||||
["node-positions", "nodePositions"], ["node-quaternions", "nodeQuaternions"],
|
["node-positions", "nodePositions"],
|
||||||
["node-scales", "nodeScales"], ["mesh-info", "meshInfo"], ["materials", "materials"], ["cameras", "cameras"],
|
["node-quaternions", "nodeQuaternions"],
|
||||||
]) addBuffer({ id, array, usage: ["storage"] });
|
["node-scales", "nodeScales"],
|
||||||
|
["mesh-info", "meshInfo"],
|
||||||
|
["materials", "materials"],
|
||||||
|
["cameras", "cameras"],
|
||||||
|
])
|
||||||
|
addBuffer({ id, array, usage: ["storage"] });
|
||||||
renderPipelines.push({
|
renderPipelines.push({
|
||||||
id: "forward-pbr", code: forwardShader,
|
id: "forward-pbr",
|
||||||
vertex: { entry: "vertex", buffers: [{ arrayStride: 16, attributes: [{ format: "float32x3", offset: 0, shaderLocation: 0 }] }] },
|
code: forwardShader,
|
||||||
|
vertex: {
|
||||||
|
entry: "vertex",
|
||||||
|
buffers: [
|
||||||
|
{
|
||||||
|
arrayStride: 16,
|
||||||
|
attributes: [
|
||||||
|
{ format: "float32x3", offset: 0, shaderLocation: 0 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
fragment: { entry: "fragment", targets: [{ format: hdrFormat }] },
|
fragment: { entry: "fragment", targets: [{ format: hdrFormat }] },
|
||||||
});
|
});
|
||||||
let firstRender = true;
|
let firstRender = true;
|
||||||
for (const mesh of renderedMeshes) {
|
for (const mesh of renderedMeshes) {
|
||||||
const vertex = `geometry-${mesh.geometryId}-positions`;
|
const vertex = `geometry-${mesh.geometryId}-positions`;
|
||||||
addBuffer({ id: vertex, array: `geometry.${mesh.geometryId}.positions`, usage: ["vertex"] });
|
addBuffer({
|
||||||
|
id: vertex,
|
||||||
|
array: `geometry.${mesh.geometryId}.positions`,
|
||||||
|
usage: ["vertex"],
|
||||||
|
});
|
||||||
const indexed = mesh.indexCount > 0;
|
const indexed = mesh.indexCount > 0;
|
||||||
if (indexed) addBuffer({ id: `geometry-${mesh.geometryId}-indices`, array: `geometry.${mesh.geometryId}.indices`, usage: ["index"] });
|
if (indexed)
|
||||||
const draws = indexed && mesh.faceMaterials.size
|
addBuffer({
|
||||||
? Array.from({ length: Math.floor(mesh.indexCount / 3) }, (_, face) => ({
|
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,
|
face,
|
||||||
count: 3,
|
count: 3,
|
||||||
firstIndex: face * 3,
|
firstIndex: face * 3,
|
||||||
material: mesh.faceMaterials.get(face),
|
material: mesh.faceMaterials.get(face),
|
||||||
}))
|
}),
|
||||||
: [{ face: -1, count: indexed ? mesh.indexCount : mesh.vertexCount, firstIndex: 0, material: undefined }];
|
)
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
face: -1,
|
||||||
|
count: indexed ? mesh.indexCount : mesh.vertexCount,
|
||||||
|
firstIndex: 0,
|
||||||
|
material: undefined,
|
||||||
|
},
|
||||||
|
];
|
||||||
for (const draw of draws) {
|
for (const draw of draws) {
|
||||||
const id = `forward-${mesh.id}-${draw.face}`;
|
const id = `forward-${mesh.id}-${draw.face}`;
|
||||||
if (mesh.id > 65535 || (draw.material ?? 0) > 65534) throw new RangeError("Scene handle limit");
|
if (mesh.id > 65535 || (draw.material ?? 0) > 65534)
|
||||||
const instance = (mesh.id + (draw.material === undefined ? 0 : (draw.material + 1) * 65536)) >>> 0;
|
throw new RangeError("Scene handle limit");
|
||||||
|
const instance =
|
||||||
|
(mesh.id +
|
||||||
|
(draw.material === undefined
|
||||||
|
? 0
|
||||||
|
: (draw.material + 1) * 65536)) >>>
|
||||||
|
0;
|
||||||
passes.push({
|
passes.push({
|
||||||
id, type: "render", pipeline: "forward-pbr", after: previous,
|
id,
|
||||||
bindings: ["clusters", "accent", "node-positions", "node-quaternions", "node-scales", "mesh-info", "materials", "cameras"]
|
type: "render",
|
||||||
.map((resource, binding) => ({ group: 0, binding, resource })),
|
pipeline: "forward-pbr",
|
||||||
color: [{ resource: "hdr", ...(firstRender ? { clear: [0.015, 0.025, 0.05, 1] } : { load: "load" }) }],
|
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 }],
|
vertexBuffers: [{ slot: 0, resource: vertex }],
|
||||||
...(indexed ? { indexBuffer: { resource: `geometry-${mesh.geometryId}-indices`, format: "uint32" } } : {}),
|
...(indexed
|
||||||
|
? {
|
||||||
|
indexBuffer: {
|
||||||
|
resource: `geometry-${mesh.geometryId}-indices`,
|
||||||
|
format: "uint32",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
draw: indexed
|
draw: indexed
|
||||||
? { indices: draw.count, firstIndex: draw.firstIndex, instances: 1, firstInstance: instance }
|
? {
|
||||||
|
indices: draw.count,
|
||||||
|
firstIndex: draw.firstIndex,
|
||||||
|
instances: 1,
|
||||||
|
firstInstance: instance,
|
||||||
|
}
|
||||||
: { vertices: draw.count, instances: 1, firstInstance: instance },
|
: { vertices: draw.count, instances: 1, firstInstance: instance },
|
||||||
});
|
});
|
||||||
firstRender = false;
|
firstRender = false;
|
||||||
@@ -522,11 +775,22 @@ export class Scene {
|
|||||||
for (const material of this.#shaders.values()) {
|
for (const material of this.#shaders.values()) {
|
||||||
const id = `shader-${material.id}`;
|
const id = `shader-${material.id}`;
|
||||||
renderPipelines.push({
|
renderPipelines.push({
|
||||||
id, code: material.code,
|
id,
|
||||||
|
code: material.code,
|
||||||
vertex: { entry: material.vertexEntry },
|
vertex: { entry: material.vertexEntry },
|
||||||
fragment: { entry: material.fragmentEntry, targets: [{ format: hdrFormat }] },
|
fragment: {
|
||||||
|
entry: material.fragmentEntry,
|
||||||
|
targets: [{ format: hdrFormat }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
passes.push({
|
||||||
|
id,
|
||||||
|
type: "render",
|
||||||
|
pipeline: id,
|
||||||
|
after: previous,
|
||||||
|
color: [{ resource: "hdr", load: "load" }],
|
||||||
|
draw: { vertices: 3 },
|
||||||
});
|
});
|
||||||
passes.push({ id, type: "render", pipeline: id, after: previous, color: [{ resource: "hdr", load: "load" }], draw: { vertices: 3 } });
|
|
||||||
previous = [id];
|
previous = [id];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -534,12 +798,30 @@ export class Scene {
|
|||||||
for (const [index, effect] of [...this.#effects.values()].entries()) {
|
for (const [index, effect] of [...this.#effects.values()].entries()) {
|
||||||
const output = `post-${index}`;
|
const output = `post-${index}`;
|
||||||
const pipeline = `post-${effect.id}`;
|
const pipeline = `post-${effect.id}`;
|
||||||
addTexture({ id: output, format: hdrFormat, size: ["canvas", "canvas", 1], usage: ["render", "sampled"], transient: true });
|
addTexture({
|
||||||
renderPipelines.push({ id: pipeline, code: effectFragment(effect.kind, effect.options), vertex: { entry: "vertex" }, fragment: { entry: "fragment", targets: [{ format: hdrFormat }] } });
|
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({
|
passes.push({
|
||||||
id: pipeline, type: "render", pipeline, after: previous,
|
id: pipeline,
|
||||||
bindings: [{ group: 0, binding: 0, resource: input }, { group: 0, binding: 1, resource: "linear" }],
|
type: "render",
|
||||||
color: [{ resource: output, clear: [0, 0, 0, 1] }], draw: { vertices: 3 },
|
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;
|
input = output;
|
||||||
previous = [pipeline];
|
previous = [pipeline];
|
||||||
@@ -554,16 +836,39 @@ export class Scene {
|
|||||||
entry: "main",
|
entry: "main",
|
||||||
});
|
});
|
||||||
passes.push({
|
passes.push({
|
||||||
id, type: "compute", pipeline: id, after: ["cluster-lights"], dispatch: [1, 1, 1],
|
id,
|
||||||
bindings: [{ group: 0, binding: 0, resource: texture.id }, { group: 0, binding: 1, resource: "clusters" }],
|
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");
|
const toneMap = String(
|
||||||
renderPipelines.push({ id: "present", code: presentShader(toneMap), vertex: { entry: "vertex" }, fragment: { entry: "fragment", targets: [{ format: "canvas" }] } });
|
[...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({
|
passes.push({
|
||||||
id: "present", type: "render", pipeline: "present", after: previous,
|
id: "present",
|
||||||
bindings: [{ group: 0, binding: 0, resource: input }, { group: 0, binding: 1, resource: "linear" }],
|
type: "render",
|
||||||
color: [{ resource: "canvas", clear: [0, 0, 0, 1] }], draw: { vertices: 3 },
|
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 = {
|
const graph = {
|
||||||
@@ -571,7 +876,10 @@ export class Scene {
|
|||||||
resources: {
|
resources: {
|
||||||
buffers: [...buffers.values()],
|
buffers: [...buffers.values()],
|
||||||
textures: [...textures.values()],
|
textures: [...textures.values()],
|
||||||
samplers: [...samplers.values()].map(({ id, ...descriptor }) => ({ id, descriptor })),
|
samplers: [...samplers.values()].map(({ id, ...descriptor }) => ({
|
||||||
|
id,
|
||||||
|
descriptor,
|
||||||
|
})),
|
||||||
},
|
},
|
||||||
pipelines: { render: renderPipelines, compute: computePipelines },
|
pipelines: { render: renderPipelines, compute: computePipelines },
|
||||||
passes,
|
passes,
|
||||||
|
|||||||
@@ -8,32 +8,56 @@ export class Picking {
|
|||||||
readonly ready: Promise<void>;
|
readonly ready: Promise<void>;
|
||||||
#worker: Worker;
|
#worker: Worker;
|
||||||
#next = 1;
|
#next = 1;
|
||||||
#pending = new Map<number, { resolve: (value: any) => void; reject: (error: Error) => void }>();
|
#pending = new Map<
|
||||||
|
number,
|
||||||
|
{ resolve: (value: any) => void; reject: (error: Error) => void }
|
||||||
|
>();
|
||||||
|
|
||||||
constructor(scene: Scene) {
|
constructor(scene: Scene) {
|
||||||
this.scene = scene;
|
this.scene = scene;
|
||||||
this.#worker = new Worker(new URL("./worker.ts", import.meta.url), { type: "module", name: "yawn-bvh" });
|
this.#worker = new Worker(new URL("./worker.ts", import.meta.url), {
|
||||||
|
type: "module",
|
||||||
|
name: "yawn-bvh",
|
||||||
|
});
|
||||||
this.#worker.addEventListener("message", ({ data }) => {
|
this.#worker.addEventListener("message", ({ data }) => {
|
||||||
const pending = this.#pending.get(data.request);
|
const pending = this.#pending.get(data.request);
|
||||||
if (!pending) return;
|
if (!pending) return;
|
||||||
this.#pending.delete(data.request);
|
this.#pending.delete(data.request);
|
||||||
pending.resolve(data.hits);
|
pending.resolve(data.hits);
|
||||||
});
|
});
|
||||||
this.#worker.addEventListener("error", () => this.#fail(new Error("BVH_WORKER_ERROR")));
|
this.#worker.addEventListener("error", () =>
|
||||||
|
this.#fail(new Error("BVH_WORKER_ERROR")),
|
||||||
|
);
|
||||||
this.ready = scene.ready.then(() => this.refresh());
|
this.ready = scene.ready.then(() => this.refresh());
|
||||||
}
|
}
|
||||||
|
|
||||||
refresh() {
|
refresh() {
|
||||||
return this.#request("sync", {
|
return this.#request("sync", {
|
||||||
shares: Object.fromEntries(["info", "nodes", "nodePositions", "meshInfo", "bounds"]
|
shares: Object.fromEntries(
|
||||||
.map((name) => [name, this.scene.array(name).share()])),
|
[
|
||||||
|
"info",
|
||||||
|
"nodes",
|
||||||
|
"nodePositions",
|
||||||
|
"nodeQuaternions",
|
||||||
|
"nodeScales",
|
||||||
|
"meshInfo",
|
||||||
|
"bounds",
|
||||||
|
].map((name) => [name, this.scene.array(name).share()]),
|
||||||
|
),
|
||||||
}).then(() => undefined);
|
}).then(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
async pick(origin: ArrayLike<number>, direction: ArrayLike<number>): Promise<PickHit[]> {
|
async pick(
|
||||||
|
origin: ArrayLike<number>,
|
||||||
|
direction: ArrayLike<number>,
|
||||||
|
): Promise<PickHit[]> {
|
||||||
await this.ready;
|
await this.ready;
|
||||||
if (origin.length !== 3 || direction.length !== 3) throw new TypeError("Pick rays have three lanes");
|
if (origin.length !== 3 || direction.length !== 3)
|
||||||
return this.#request("pick", { origin: Array.from(origin), direction: Array.from(direction) });
|
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) {
|
#request(type: string, payload: object) {
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
type SharedRows = { buffer: SharedArrayBuffer; descriptor: { offset: number; rows: number; stride: number; format: string } };
|
export {};
|
||||||
|
|
||||||
|
type SharedRows = {
|
||||||
|
buffer: SharedArrayBuffer;
|
||||||
|
descriptor: { offset: number; rows: number; stride: number; format: string };
|
||||||
|
};
|
||||||
type Box = { id: number; min: number[]; max: number[] };
|
type Box = { id: number; min: number[]; max: number[] };
|
||||||
type Branch = { min: number[]; max: number[]; boxes?: Box[]; left?: Branch; right?: Branch };
|
type Branch = {
|
||||||
|
min: number[];
|
||||||
|
max: number[];
|
||||||
|
boxes?: Box[];
|
||||||
|
left?: Branch;
|
||||||
|
right?: Branch;
|
||||||
|
};
|
||||||
|
|
||||||
let shares: Record<string, SharedRows> = {};
|
let shares: Record<string, SharedRows> = {};
|
||||||
let root: Branch | undefined;
|
let root: Branch | undefined;
|
||||||
@@ -9,7 +20,7 @@ let builtFrame = -1;
|
|||||||
function view(name: string) {
|
function view(name: string) {
|
||||||
const share = shares[name];
|
const share = shares[name];
|
||||||
if (!share) return undefined;
|
if (!share) return undefined;
|
||||||
const length = share.descriptor.rows * share.descriptor.stride / 4;
|
const length = (share.descriptor.rows * share.descriptor.stride) / 4;
|
||||||
return share.descriptor.format === "u32"
|
return share.descriptor.format === "u32"
|
||||||
? new Uint32Array(share.buffer, share.descriptor.offset, length)
|
? new Uint32Array(share.buffer, share.descriptor.offset, length)
|
||||||
: new Float32Array(share.buffer, share.descriptor.offset, length);
|
: new Float32Array(share.buffer, share.descriptor.offset, length);
|
||||||
@@ -18,7 +29,8 @@ function view(name: string) {
|
|||||||
function merge(boxes: Box[]) {
|
function merge(boxes: Box[]) {
|
||||||
const min = [Infinity, Infinity, Infinity];
|
const min = [Infinity, Infinity, Infinity];
|
||||||
const max = [-Infinity, -Infinity, -Infinity];
|
const max = [-Infinity, -Infinity, -Infinity];
|
||||||
for (const box of boxes) for (let lane = 0; lane < 3; lane++) {
|
for (const box of boxes)
|
||||||
|
for (let lane = 0; lane < 3; lane++) {
|
||||||
min[lane] = Math.min(min[lane], box.min[lane]);
|
min[lane] = Math.min(min[lane], box.min[lane]);
|
||||||
max[lane] = Math.max(max[lane], box.max[lane]);
|
max[lane] = Math.max(max[lane], box.max[lane]);
|
||||||
}
|
}
|
||||||
@@ -31,32 +43,74 @@ function build(boxes: Box[]): Branch | undefined {
|
|||||||
if (boxes.length <= 4) return { ...bounds, boxes };
|
if (boxes.length <= 4) return { ...bounds, boxes };
|
||||||
const extents = bounds.max.map((value, lane) => value - bounds.min[lane]);
|
const extents = bounds.max.map((value, lane) => value - bounds.min[lane]);
|
||||||
const axis = extents.indexOf(Math.max(...extents));
|
const axis = extents.indexOf(Math.max(...extents));
|
||||||
boxes.sort((a, b) => (a.min[axis] + a.max[axis]) - (b.min[axis] + b.max[axis]));
|
boxes.sort((a, b) => a.min[axis] + a.max[axis] - (b.min[axis] + b.max[axis]));
|
||||||
const middle = Math.ceil(boxes.length / 2);
|
const middle = Math.ceil(boxes.length / 2);
|
||||||
return { ...bounds, left: build(boxes.slice(0, middle)), right: build(boxes.slice(middle)) };
|
return {
|
||||||
|
...bounds,
|
||||||
|
left: build(boxes.slice(0, middle)),
|
||||||
|
right: build(boxes.slice(middle)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function rotate(quaternion: number[], value: number[]) {
|
||||||
|
const [qx, qy, qz, qw] = quaternion;
|
||||||
|
const [x, y, z] = value;
|
||||||
|
const tx = 2 * (qy * z - qz * y);
|
||||||
|
const ty = 2 * (qz * x - qx * z);
|
||||||
|
const tz = 2 * (qx * y - qy * x);
|
||||||
|
return [
|
||||||
|
x + qw * tx + qy * tz - qz * ty,
|
||||||
|
y + qw * ty + qz * tx - qx * tz,
|
||||||
|
z + qw * tz + qx * ty - qy * tx,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function rebuild() {
|
function rebuild() {
|
||||||
const bounds = view("bounds");
|
const bounds = view("bounds");
|
||||||
const positions = view("nodePositions");
|
const positions = view("nodePositions");
|
||||||
|
const quaternions = view("nodeQuaternions");
|
||||||
|
const scales = view("nodeScales");
|
||||||
const meshes = view("meshInfo");
|
const meshes = view("meshInfo");
|
||||||
const nodes = view("nodes");
|
const nodes = view("nodes");
|
||||||
if (!bounds || !positions || !meshes || !nodes) return;
|
if (!bounds || !positions || !quaternions || !scales || !meshes || !nodes)
|
||||||
|
return;
|
||||||
const count = shares.bounds.descriptor.rows;
|
const count = shares.bounds.descriptor.rows;
|
||||||
const boxes: Box[] = [];
|
const boxes: Box[] = [];
|
||||||
for (let id = 0; id < count; id++) {
|
for (let id = 0; id < count; id++) {
|
||||||
if (!nodes[id * 4] || !meshes[id * 4 + 2]) continue;
|
if (!nodes[id * 4] || !meshes[id * 4 + 2]) continue;
|
||||||
const offset = id * 8;
|
const offset = id * 8;
|
||||||
const translation = id * 4;
|
const transform = id * 4;
|
||||||
const min = [0, 1, 2].map((lane) => Number(bounds[offset + lane]) + Number(positions[translation + lane]));
|
const min = [Infinity, Infinity, Infinity];
|
||||||
const max = [0, 1, 2].map((lane) => Number(bounds[offset + 4 + lane]) + Number(positions[translation + lane]));
|
const max = [-Infinity, -Infinity, -Infinity];
|
||||||
if (min.every(Number.isFinite) && max.every(Number.isFinite)) boxes.push({ id, min, max });
|
const quaternion = [0, 1, 2, 3].map((lane) =>
|
||||||
|
Number(quaternions[transform + lane]),
|
||||||
|
);
|
||||||
|
for (let corner = 0; corner < 8; corner++) {
|
||||||
|
const local = [0, 1, 2].map(
|
||||||
|
(lane) =>
|
||||||
|
Number(bounds[offset + (corner & (1 << lane) ? 4 : 0) + lane]) *
|
||||||
|
Number(scales[transform + lane]),
|
||||||
|
);
|
||||||
|
const rotated = rotate(quaternion, local);
|
||||||
|
for (let lane = 0; lane < 3; lane++) {
|
||||||
|
const value = rotated[lane] + Number(positions[transform + lane]);
|
||||||
|
min[lane] = Math.min(min[lane], value);
|
||||||
|
max[lane] = Math.max(max[lane], value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (min.every(Number.isFinite) && max.every(Number.isFinite))
|
||||||
|
boxes.push({ id, min, max });
|
||||||
}
|
}
|
||||||
root = build(boxes);
|
root = build(boxes);
|
||||||
builtFrame = Number(view("info")?.[1] ?? builtFrame + 1);
|
builtFrame = Number(view("info")?.[1] ?? builtFrame + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
function intersection(origin: number[], inverse: number[], min: number[], max: number[]) {
|
function intersection(
|
||||||
|
origin: number[],
|
||||||
|
inverse: number[],
|
||||||
|
min: number[],
|
||||||
|
max: number[],
|
||||||
|
) {
|
||||||
let near = -Infinity;
|
let near = -Infinity;
|
||||||
let far = Infinity;
|
let far = Infinity;
|
||||||
for (let lane = 0; lane < 3; lane++) {
|
for (let lane = 0; lane < 3; lane++) {
|
||||||
@@ -68,8 +122,17 @@ function intersection(origin: number[], inverse: number[], min: number[], max: n
|
|||||||
return far >= Math.max(near, 0) ? Math.max(near, 0) : Infinity;
|
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 }[]) {
|
function trace(
|
||||||
if (!branch || !Number.isFinite(intersection(origin, inverse, branch.min, branch.max))) return;
|
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 ?? []) {
|
for (const box of branch.boxes ?? []) {
|
||||||
const distance = intersection(origin, inverse, box.min, box.max);
|
const distance = intersection(origin, inverse, box.min, box.max);
|
||||||
if (Number.isFinite(distance)) hits.push({ id: box.id, distance });
|
if (Number.isFinite(distance)) hits.push({ id: box.id, distance });
|
||||||
|
|||||||
@@ -4,11 +4,17 @@ import { PBRMaterial } from "../materials/PBRMaterial";
|
|||||||
|
|
||||||
let worker: Worker | undefined;
|
let worker: Worker | undefined;
|
||||||
let nextRequest = 1;
|
let nextRequest = 1;
|
||||||
const pending = new Map<number, { resolve: (value: any) => void; reject: (error: Error) => void }>();
|
const pending = new Map<
|
||||||
|
number,
|
||||||
|
{ resolve: (value: any) => void; reject: (error: Error) => void }
|
||||||
|
>();
|
||||||
|
|
||||||
function importer() {
|
function importer() {
|
||||||
if (worker) return worker;
|
if (worker) return worker;
|
||||||
worker = new Worker(new URL("./worker.ts", import.meta.url), { type: "module", name: "yawn-importer" });
|
worker = new Worker(new URL("./worker.ts", import.meta.url), {
|
||||||
|
type: "module",
|
||||||
|
name: "yawn-importer",
|
||||||
|
});
|
||||||
worker.addEventListener("message", ({ data }) => {
|
worker.addEventListener("message", ({ data }) => {
|
||||||
const request = pending.get(data.request);
|
const request = pending.get(data.request);
|
||||||
if (!request) return;
|
if (!request) return;
|
||||||
@@ -17,7 +23,8 @@ function importer() {
|
|||||||
else request.resolve(data.result);
|
else request.resolve(data.result);
|
||||||
});
|
});
|
||||||
worker.addEventListener("error", () => {
|
worker.addEventListener("error", () => {
|
||||||
for (const request of pending.values()) request.reject(new Error("IMPORT_WORKER_ERROR"));
|
for (const request of pending.values())
|
||||||
|
request.reject(new Error("IMPORT_WORKER_ERROR"));
|
||||||
pending.clear();
|
pending.clear();
|
||||||
});
|
});
|
||||||
return worker;
|
return worker;
|
||||||
@@ -31,11 +38,18 @@ export async function importGltf(scene: Scene, url: string | URL) {
|
|||||||
pending.set(request, { resolve, reject });
|
pending.set(request, { resolve, reject });
|
||||||
importer().postMessage({ request, url: String(url) });
|
importer().postMessage({ request, url: String(url) });
|
||||||
});
|
});
|
||||||
const materials = result.materials.map((options: any) => new PBRMaterial(scene, options));
|
await scene.reserve({
|
||||||
|
nodes: result.primitives.length,
|
||||||
|
materials: result.materials.length,
|
||||||
|
});
|
||||||
|
return scene.batchGraphUpdates(async () => {
|
||||||
|
const materials = result.materials.map(
|
||||||
|
(options: any) => new PBRMaterial(scene, options),
|
||||||
|
);
|
||||||
await Promise.all(materials.map((material: PBRMaterial) => material.ready));
|
await Promise.all(materials.map((material: PBRMaterial) => material.ready));
|
||||||
const meshes: Mesh[] = [];
|
const meshes: Mesh[] = result.primitives.map(
|
||||||
for (const primitive of result.primitives) {
|
(primitive: any) =>
|
||||||
const mesh = new Mesh(scene, {
|
new Mesh(scene, {
|
||||||
position: primitive.position,
|
position: primitive.position,
|
||||||
quaternion: primitive.quaternion,
|
quaternion: primitive.quaternion,
|
||||||
scale: primitive.scale,
|
scale: primitive.scale,
|
||||||
@@ -48,9 +62,9 @@ export async function importGltf(scene: Scene, url: string | URL) {
|
|||||||
...(primitive.uvs ? { uvs: primitive.uvs } : {}),
|
...(primitive.uvs ? { uvs: primitive.uvs } : {}),
|
||||||
...(primitive.colors ? { colors: primitive.colors } : {}),
|
...(primitive.colors ? { colors: primitive.colors } : {}),
|
||||||
},
|
},
|
||||||
});
|
}),
|
||||||
await mesh.ready;
|
);
|
||||||
meshes.push(mesh);
|
await Promise.all(meshes.map((mesh) => mesh.ready));
|
||||||
}
|
|
||||||
return meshes;
|
return meshes;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,23 @@
|
|||||||
|
export {};
|
||||||
|
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
const widths: Record<string, number> = { SCALAR: 1, VEC2: 2, VEC3: 3, VEC4: 4 };
|
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 };
|
const sizes: Record<number, number> = {
|
||||||
|
5120: 1,
|
||||||
|
5121: 1,
|
||||||
|
5122: 2,
|
||||||
|
5123: 2,
|
||||||
|
5125: 4,
|
||||||
|
5126: 4,
|
||||||
|
};
|
||||||
|
const arrays: Record<number, any> = {
|
||||||
|
5120: Int8Array,
|
||||||
|
5121: Uint8Array,
|
||||||
|
5122: Int16Array,
|
||||||
|
5123: Uint16Array,
|
||||||
|
5125: Uint32Array,
|
||||||
|
5126: Float32Array,
|
||||||
|
};
|
||||||
|
|
||||||
function component(view: DataView, offset: number, type: number) {
|
function component(view: DataView, offset: number, type: number) {
|
||||||
if (type === 5120) return view.getInt8(offset);
|
if (type === 5120) return view.getInt8(offset);
|
||||||
@@ -12,10 +29,88 @@ function component(view: DataView, offset: number, type: number) {
|
|||||||
throw new Error("GLTF_COMPONENT");
|
throw new Error("GLTF_COMPONENT");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rotate(quaternion: number[], value: number[]) {
|
||||||
|
const [qx, qy, qz, qw] = quaternion;
|
||||||
|
const [x, y, z] = value;
|
||||||
|
const tx = 2 * (qy * z - qz * y);
|
||||||
|
const ty = 2 * (qz * x - qx * z);
|
||||||
|
const tz = 2 * (qx * y - qy * x);
|
||||||
|
return [
|
||||||
|
x + qw * tx + qy * tz - qz * ty,
|
||||||
|
y + qw * ty + qz * tx - qx * tz,
|
||||||
|
z + qw * tz + qx * ty - qy * tx,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function multiply(a: number[], b: number[]) {
|
||||||
|
return [
|
||||||
|
a[3] * b[0] + a[0] * b[3] + a[1] * b[2] - a[2] * b[1],
|
||||||
|
a[3] * b[1] - a[0] * b[2] + a[1] * b[3] + a[2] * b[0],
|
||||||
|
a[3] * b[2] + a[0] * b[1] - a[1] * b[0] + a[2] * b[3],
|
||||||
|
a[3] * b[3] - a[0] * b[0] - a[1] * b[1] - a[2] * b[2],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function decompose(matrix: number[]) {
|
||||||
|
const scale = [
|
||||||
|
Math.hypot(matrix[0], matrix[1], matrix[2]),
|
||||||
|
Math.hypot(matrix[4], matrix[5], matrix[6]),
|
||||||
|
Math.hypot(matrix[8], matrix[9], matrix[10]),
|
||||||
|
];
|
||||||
|
const [sx, sy, sz] = scale;
|
||||||
|
const [m00, m01, m02] = [matrix[0] / sx, matrix[4] / sy, matrix[8] / sz];
|
||||||
|
const [m10, m11, m12] = [matrix[1] / sx, matrix[5] / sy, matrix[9] / sz];
|
||||||
|
const [m20, m21, m22] = [matrix[2] / sx, matrix[6] / sy, matrix[10] / sz];
|
||||||
|
let quaternion: number[];
|
||||||
|
if (m00 + m11 + m22 > 0) {
|
||||||
|
const s = Math.sqrt(1 + m00 + m11 + m22) * 2;
|
||||||
|
quaternion = [(m21 - m12) / s, (m02 - m20) / s, (m10 - m01) / s, s / 4];
|
||||||
|
} else if (m00 > m11 && m00 > m22) {
|
||||||
|
const s = Math.sqrt(1 + m00 - m11 - m22) * 2;
|
||||||
|
quaternion = [s / 4, (m01 + m10) / s, (m02 + m20) / s, (m21 - m12) / s];
|
||||||
|
} else if (m11 > m22) {
|
||||||
|
const s = Math.sqrt(1 + m11 - m00 - m22) * 2;
|
||||||
|
quaternion = [(m01 + m10) / s, s / 4, (m12 + m21) / s, (m02 - m20) / s];
|
||||||
|
} else {
|
||||||
|
const s = Math.sqrt(1 + m22 - m00 - m11) * 2;
|
||||||
|
quaternion = [(m02 + m20) / s, (m12 + m21) / s, s / 4, (m10 - m01) / s];
|
||||||
|
}
|
||||||
|
return { position: matrix.slice(12, 15), quaternion, scale };
|
||||||
|
}
|
||||||
|
|
||||||
|
function transform(node: any) {
|
||||||
|
return node.matrix
|
||||||
|
? decompose(node.matrix)
|
||||||
|
: {
|
||||||
|
position: node.translation ?? [0, 0, 0],
|
||||||
|
quaternion: node.rotation ?? [0, 0, 0, 1],
|
||||||
|
scale: node.scale ?? [1, 1, 1],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function compose(parent: any, local: any) {
|
||||||
|
const position = rotate(
|
||||||
|
parent.quaternion,
|
||||||
|
local.position.map(
|
||||||
|
(value: number, lane: number) => value * parent.scale[lane],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
position: position.map((value, lane) => value + parent.position[lane]),
|
||||||
|
quaternion: multiply(parent.quaternion, local.quaternion),
|
||||||
|
scale: local.scale.map(
|
||||||
|
(value: number, lane: number) => value * parent.scale[lane],
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function parse(bytes: Uint8Array) {
|
function parse(bytes: Uint8Array) {
|
||||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||||
if (view.getUint32(0, true) !== 0x46546c67)
|
if (view.getUint32(0, true) !== 0x46546c67)
|
||||||
return { document: JSON.parse(decoder.decode(bytes)), binary: undefined as Uint8Array | undefined };
|
return {
|
||||||
|
document: JSON.parse(decoder.decode(bytes)),
|
||||||
|
binary: undefined as Uint8Array | undefined,
|
||||||
|
};
|
||||||
let offset = 12;
|
let offset = 12;
|
||||||
let document: any;
|
let document: any;
|
||||||
let binary: Uint8Array | undefined;
|
let binary: Uint8Array | undefined;
|
||||||
@@ -23,7 +118,8 @@ function parse(bytes: Uint8Array) {
|
|||||||
const length = view.getUint32(offset, true);
|
const length = view.getUint32(offset, true);
|
||||||
const type = view.getUint32(offset + 4, true);
|
const type = view.getUint32(offset + 4, true);
|
||||||
const chunk = bytes.subarray(offset + 8, offset + 8 + length);
|
const chunk = bytes.subarray(offset + 8, offset + 8 + length);
|
||||||
if (type === 0x4e4f534a) document = JSON.parse(decoder.decode(chunk).replace(/\0+$/u, ""));
|
if (type === 0x4e4f534a)
|
||||||
|
document = JSON.parse(decoder.decode(chunk).replace(/\0+$/u, ""));
|
||||||
if (type === 0x004e4942) binary = chunk;
|
if (type === 0x004e4942) binary = chunk;
|
||||||
offset += 8 + length;
|
offset += 8 + length;
|
||||||
}
|
}
|
||||||
@@ -34,8 +130,11 @@ function parse(bytes: Uint8Array) {
|
|||||||
async function load(url: string) {
|
async function load(url: string) {
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
if (!response.ok) throw new Error(`HTTP_${response.status}`);
|
if (!response.ok) throw new Error(`HTTP_${response.status}`);
|
||||||
const { document, binary } = parse(new Uint8Array(await response.arrayBuffer()));
|
const { document, binary } = parse(
|
||||||
const buffers = await Promise.all((document.buffers ?? []).map(async (buffer: any, index: number) => {
|
new Uint8Array(await response.arrayBuffer()),
|
||||||
|
);
|
||||||
|
const buffers = await Promise.all(
|
||||||
|
(document.buffers ?? []).map(async (buffer: any, index: number) => {
|
||||||
if (buffer.uri === undefined) {
|
if (buffer.uri === undefined) {
|
||||||
if (index || !binary) throw new Error("GLTF_BUFFER");
|
if (index || !binary) throw new Error("GLTF_BUFFER");
|
||||||
return binary;
|
return binary;
|
||||||
@@ -43,7 +142,8 @@ async function load(url: string) {
|
|||||||
const result = await fetch(new URL(buffer.uri, url));
|
const result = await fetch(new URL(buffer.uri, url));
|
||||||
if (!result.ok) throw new Error(`HTTP_${result.status}`);
|
if (!result.ok) throw new Error(`HTTP_${result.status}`);
|
||||||
return new Uint8Array(await result.arrayBuffer());
|
return new Uint8Array(await result.arrayBuffer());
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const accessor = (id: number, integer = false) => {
|
const accessor = (id: number, integer = false) => {
|
||||||
const source = document.accessors[id];
|
const source = document.accessors[id];
|
||||||
@@ -54,11 +154,36 @@ async function load(url: string) {
|
|||||||
const start = (bufferView.byteOffset ?? 0) + (source.byteOffset ?? 0);
|
const start = (bufferView.byteOffset ?? 0) + (source.byteOffset ?? 0);
|
||||||
const stride = bufferView.byteStride ?? width * size;
|
const stride = bufferView.byteStride ?? width * size;
|
||||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||||
const values = integer ? new Uint32Array(source.count * width) : new Float32Array(source.count * width);
|
const values = integer
|
||||||
for (let item = 0; item < source.count; item++) for (let lane = 0; lane < width; lane++) {
|
? new Uint32Array(source.count * width)
|
||||||
let value = component(view, start + item * stride + lane * size, source.componentType);
|
: new Float32Array(source.count * width);
|
||||||
|
if (
|
||||||
|
!source.normalized &&
|
||||||
|
stride === width * size &&
|
||||||
|
(bytes.byteOffset + start) % size === 0
|
||||||
|
) {
|
||||||
|
const packed = new arrays[source.componentType](
|
||||||
|
bytes.buffer,
|
||||||
|
bytes.byteOffset + start,
|
||||||
|
source.count * width,
|
||||||
|
);
|
||||||
|
values.set(packed);
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
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) {
|
if (!integer && source.normalized) {
|
||||||
const maximum = source.componentType === 5121 ? 255 : source.componentType === 5123 ? 65535 : 1;
|
const maximum =
|
||||||
|
source.componentType === 5121
|
||||||
|
? 255
|
||||||
|
: source.componentType === 5123
|
||||||
|
? 65535
|
||||||
|
: 1;
|
||||||
value /= maximum;
|
value /= maximum;
|
||||||
}
|
}
|
||||||
values[item * width + lane] = value;
|
values[item * width + lane] = value;
|
||||||
@@ -80,18 +205,34 @@ async function load(url: string) {
|
|||||||
const emitMesh = (meshId: number, transform: any) => {
|
const emitMesh = (meshId: number, transform: any) => {
|
||||||
const mesh = document.meshes?.[meshId];
|
const mesh = document.meshes?.[meshId];
|
||||||
for (const primitive of mesh?.primitives ?? []) {
|
for (const primitive of mesh?.primitives ?? []) {
|
||||||
if ((primitive.mode ?? 4) !== 4 || primitive.attributes.POSITION === undefined) continue;
|
if (
|
||||||
|
(primitive.mode ?? 4) !== 4 ||
|
||||||
|
primitive.attributes.POSITION === undefined
|
||||||
|
)
|
||||||
|
continue;
|
||||||
const positions = accessor(primitive.attributes.POSITION);
|
const positions = accessor(primitive.attributes.POSITION);
|
||||||
const indices = primitive.indices === undefined
|
const indices =
|
||||||
? Uint32Array.from({ length: positions.length / 3 }, (_, index) => index)
|
primitive.indices === undefined
|
||||||
|
? Uint32Array.from(
|
||||||
|
{ length: positions.length / 3 },
|
||||||
|
(_, index) => index,
|
||||||
|
)
|
||||||
: accessor(primitive.indices, true);
|
: accessor(primitive.indices, true);
|
||||||
primitives.push({
|
primitives.push({
|
||||||
positions,
|
positions,
|
||||||
indices,
|
indices,
|
||||||
...(primitive.attributes.NORMAL === undefined ? {} : { normals: accessor(primitive.attributes.NORMAL) }),
|
...(primitive.attributes.NORMAL === undefined
|
||||||
...(primitive.attributes.TANGENT === undefined ? {} : { tangents: accessor(primitive.attributes.TANGENT) }),
|
? {}
|
||||||
...(primitive.attributes.TEXCOORD_0 === undefined ? {} : { uvs: accessor(primitive.attributes.TEXCOORD_0) }),
|
: { normals: accessor(primitive.attributes.NORMAL) }),
|
||||||
...(primitive.attributes.COLOR_0 === undefined ? {} : { colors: accessor(primitive.attributes.COLOR_0) }),
|
...(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,
|
material: primitive.material ?? -1,
|
||||||
...transform,
|
...transform,
|
||||||
});
|
});
|
||||||
@@ -101,17 +242,28 @@ async function load(url: string) {
|
|||||||
const nodes = document.nodes ?? [];
|
const nodes = document.nodes ?? [];
|
||||||
const scene = document.scenes?.[document.scene ?? 0];
|
const scene = document.scenes?.[document.scene ?? 0];
|
||||||
const children = new Set(nodes.flatMap((node: any) => node.children ?? []));
|
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 roots =
|
||||||
const visit = (id: number, parent = { position: [0, 0, 0], scale: [1, 1, 1] }) => {
|
scene?.nodes ??
|
||||||
|
nodes
|
||||||
|
.map((_: any, id: number) => id)
|
||||||
|
.filter((id: number) => !children.has(id));
|
||||||
|
const visit = (
|
||||||
|
id: number,
|
||||||
|
parent = {
|
||||||
|
position: [0, 0, 0],
|
||||||
|
quaternion: [0, 0, 0, 1],
|
||||||
|
scale: [1, 1, 1],
|
||||||
|
},
|
||||||
|
) => {
|
||||||
const node = nodes[id] ?? {};
|
const node = nodes[id] ?? {};
|
||||||
const position = (node.translation ?? [0, 0, 0]).map((value: number, lane: number) => value + parent.position[lane]);
|
const world = compose(parent, transform(node));
|
||||||
const scale = (node.scale ?? [1, 1, 1]).map((value: number, lane: number) => value * parent.scale[lane]);
|
if (node.mesh !== undefined) emitMesh(node.mesh, world);
|
||||||
const transform = { position, scale, quaternion: node.rotation ?? [0, 0, 0, 1] };
|
for (const child of node.children ?? []) visit(child, world);
|
||||||
if (node.mesh !== undefined) emitMesh(node.mesh, transform);
|
|
||||||
for (const child of node.children ?? []) visit(child, transform);
|
|
||||||
};
|
};
|
||||||
for (const root of roots) visit(root);
|
for (const root of roots) visit(root);
|
||||||
if (!nodes.length) for (let id = 0; id < (document.meshes ?? []).length; id++) emitMesh(id, {});
|
if (!nodes.length)
|
||||||
|
for (let id = 0; id < (document.meshes ?? []).length; id++)
|
||||||
|
emitMesh(id, {});
|
||||||
return { materials, primitives };
|
return { materials, primitives };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,9 +272,14 @@ addEventListener("message", async ({ data }) => {
|
|||||||
const result = await load(data.url);
|
const result = await load(data.url);
|
||||||
const transfers = result.primitives.flatMap((primitive: any) =>
|
const transfers = result.primitives.flatMap((primitive: any) =>
|
||||||
["positions", "indices", "normals", "tangents", "uvs", "colors"]
|
["positions", "indices", "normals", "tangents", "uvs", "colors"]
|
||||||
.map((name) => primitive[name]?.buffer).filter(Boolean));
|
.map((name) => primitive[name]?.buffer)
|
||||||
|
.filter(Boolean),
|
||||||
|
);
|
||||||
(postMessage as any)({ request: data.request, result }, transfers);
|
(postMessage as any)({ request: data.request, result }, transfers);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
postMessage({ request: data.request, error: error instanceof Error ? error.message : "GLTF_IMPORT" });
|
postMessage({
|
||||||
|
request: data.request,
|
||||||
|
error: error instanceof Error ? error.message : "GLTF_IMPORT",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+297
-71
@@ -1,98 +1,324 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, onUnmounted, ref } from "vue";
|
import { nextTick, onMounted, onUnmounted, ref } from "vue";
|
||||||
import {
|
import * as Handles from "@yawn/handles";
|
||||||
ComputePass,
|
import { YawnCore } from "@yawn/core";
|
||||||
FXAA,
|
import { playgrounds } from "./playgrounds";
|
||||||
Mesh,
|
|
||||||
PBRMaterial,
|
|
||||||
PointLight,
|
|
||||||
Scene,
|
|
||||||
} from "@yawn/handles";
|
|
||||||
|
|
||||||
const props = defineProps({ example: { type: String, default: "triangle" } });
|
const props = defineProps({ example: { type: String, default: "triangle" } });
|
||||||
|
const preset = playgrounds[props.example] ?? playgrounds.triangle;
|
||||||
const canvas = ref();
|
const canvas = ref();
|
||||||
|
const source = ref(preset.code);
|
||||||
const status = ref("Starting…");
|
const status = ref("Starting…");
|
||||||
|
const output = ref([]);
|
||||||
const failed = ref(false);
|
const failed = ref(false);
|
||||||
let scene;
|
const running = ref(false);
|
||||||
let accent;
|
const canvasKey = ref(0);
|
||||||
|
const fps = ref(0);
|
||||||
|
let generation = 0;
|
||||||
|
let current;
|
||||||
|
let fpsFrame = 0;
|
||||||
|
let sampledFrame = 0;
|
||||||
|
let sampledAt = 0;
|
||||||
|
|
||||||
function move(event) {
|
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
|
||||||
if (!accent) return;
|
const api = { ...Handles, YawnCore };
|
||||||
const bounds = canvas.value.getBoundingClientRect();
|
|
||||||
const row = accent.row(0);
|
async function dispose(value = current) {
|
||||||
row[0] = (event.clientX - bounds.left) / bounds.width;
|
if (!value) return;
|
||||||
row[1] = 1 - (event.clientY - bounds.top) / bounds.height;
|
current = undefined;
|
||||||
|
delete window.__yawnPlayground;
|
||||||
|
if (typeof value === "function") await value();
|
||||||
|
else if (typeof value.dispose === "function") await value.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
async function run() {
|
||||||
|
const runId = ++generation;
|
||||||
|
running.value = true;
|
||||||
|
failed.value = false;
|
||||||
|
output.value = [];
|
||||||
|
status.value = "Running…";
|
||||||
try {
|
try {
|
||||||
if (!crossOriginIsolated) throw new Error("Cross-origin isolation is disabled");
|
await dispose();
|
||||||
|
if (!crossOriginIsolated)
|
||||||
|
throw new Error("Cross-origin isolation is disabled");
|
||||||
|
canvasKey.value++;
|
||||||
|
await nextTick();
|
||||||
canvas.value.width = 960;
|
canvas.value.width = 960;
|
||||||
canvas.value.height = 540;
|
canvas.value.height = 540;
|
||||||
scene = new Scene(canvas.value, { hdr: true });
|
const executable = source.value.replace(/^\s*import\s+[^;]+;\s*$/gm, "");
|
||||||
await scene.ready;
|
const names = Object.keys(api);
|
||||||
accent = scene.array("sceneAccent");
|
const started = performance.now();
|
||||||
|
const result = await new AsyncFunction(
|
||||||
const material = new PBRMaterial(scene, {
|
...names,
|
||||||
baseColor: props.example === "lights" ? [1, 0.55, 0.18, 1] : [0.75, 0.9, 1, 1],
|
"canvas",
|
||||||
metallic: props.example === "materials" ? 0.9 : 0.1,
|
"log",
|
||||||
roughness: 0.38,
|
executable,
|
||||||
});
|
)(...names.map((name) => api[name]), canvas.value, (message) =>
|
||||||
await material.ready;
|
output.value.push(String(message)),
|
||||||
const mesh = new Mesh(scene, {
|
);
|
||||||
material,
|
if (runId !== generation) {
|
||||||
vertexData: {
|
await dispose(result);
|
||||||
positions: [-0.7, -0.6, 0, 0.7, -0.6, 0, 0, 0.72, 0],
|
return;
|
||||||
indices: [0, 1, 2],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await mesh.ready;
|
|
||||||
|
|
||||||
if (props.example === "instances") {
|
|
||||||
mesh.position = [-0.45, 0, 0];
|
|
||||||
mesh.scale = [0.65, 0.65, 1];
|
|
||||||
const clone = mesh.clone({ position: [0.45, 0, 0], scale: [0.65, 0.65, 1] });
|
|
||||||
await clone.ready;
|
|
||||||
} else if (props.example === "lights") {
|
|
||||||
await new PointLight(scene, { position: [0, 0.4, 0.2], color: [1, 0.4, 0.1], intensity: 8 }).ready;
|
|
||||||
} else if (props.example === "post") {
|
|
||||||
await new FXAA(scene).ready;
|
|
||||||
} else if (props.example === "compute") {
|
|
||||||
await scene.ensureRows("playgroundCompute", 1, 16, "u32");
|
|
||||||
const compute = new ComputePass({
|
|
||||||
id: "playground-compute",
|
|
||||||
code: "@group(0) @binding(0) var<storage, read_write> value: array<u32>; @compute @workgroup_size(1) fn main() { value[0] = value[0] + 1u; }",
|
|
||||||
buffers: [{ id: "playground-value", array: "playgroundCompute", usage: ["storage"] }],
|
|
||||||
bindings: [{ group: 0, binding: 0, resource: "playground-value" }],
|
|
||||||
});
|
|
||||||
await scene.addComputePass(compute);
|
|
||||||
}
|
}
|
||||||
|
current = result;
|
||||||
window.__yawnPlayground = { scene, mesh, material, accent };
|
window.__yawnPlayground = result;
|
||||||
status.value = `${props.example} running · pointer movement writes sceneAccent in the SAB`;
|
status.value = `Running · ${Math.round(performance.now() - started)} ms`;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
failed.value = true;
|
failed.value = true;
|
||||||
status.value = error.message;
|
status.value = error instanceof Error ? error.message : String(error);
|
||||||
|
} finally {
|
||||||
|
if (runId === generation) running.value = false;
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
source.value = preset.code;
|
||||||
|
run();
|
||||||
|
}
|
||||||
|
|
||||||
|
function tab(event) {
|
||||||
|
if (event.key !== "Tab") return;
|
||||||
|
event.preventDefault();
|
||||||
|
const editor = event.currentTarget;
|
||||||
|
const start = editor.selectionStart;
|
||||||
|
source.value = `${source.value.slice(0, start)} ${source.value.slice(editor.selectionEnd)}`;
|
||||||
|
requestAnimationFrame(() => editor.setSelectionRange(start + 2, start + 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
function sampleFps(time = performance.now()) {
|
||||||
|
try {
|
||||||
|
const core = current?.scene?.core ?? current?.core;
|
||||||
|
if (!core) throw new Error();
|
||||||
|
const frame = Number(core.array("info").row(0)[1]);
|
||||||
|
if (frame < sampledFrame) sampledAt = 0;
|
||||||
|
if (!sampledAt) {
|
||||||
|
sampledFrame = frame;
|
||||||
|
sampledAt = time;
|
||||||
|
} else if (time - sampledAt >= 500) {
|
||||||
|
fps.value = Math.round(
|
||||||
|
((frame - sampledFrame) * 1000) / (time - sampledAt),
|
||||||
|
);
|
||||||
|
sampledFrame = frame;
|
||||||
|
sampledAt = time;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
fps.value = 0;
|
||||||
|
sampledFrame = 0;
|
||||||
|
sampledAt = time;
|
||||||
|
}
|
||||||
|
fpsFrame = requestAnimationFrame(sampleFps);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
sampleFps();
|
||||||
|
run();
|
||||||
|
});
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
delete window.__yawnPlayground;
|
generation++;
|
||||||
scene?.dispose();
|
cancelAnimationFrame(fpsFrame);
|
||||||
|
dispose();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="playground">
|
<section class="playground" :aria-label="`${preset.title} playground`">
|
||||||
<canvas ref="canvas" aria-label="Yawn WebGPU output" @pointermove="move" />
|
<header>
|
||||||
<p :class="{ failed }" data-playground-status>{{ status }}</p>
|
<strong>{{ preset.title }}</strong>
|
||||||
|
<span :class="{ failed }" data-playground-status>{{ status }}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="secondary"
|
||||||
|
:disabled="running"
|
||||||
|
@click="reset"
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
<button type="button" :disabled="running" @click="run">
|
||||||
|
{{ running ? "Running…" : "Run" }}
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div class="workspace">
|
||||||
|
<textarea
|
||||||
|
v-model="source"
|
||||||
|
aria-label="Editable playground code"
|
||||||
|
autocomplete="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
spellcheck="false"
|
||||||
|
@keydown="tab"
|
||||||
|
/>
|
||||||
|
<div class="preview">
|
||||||
|
<canvas :key="canvasKey" ref="canvas" aria-label="Yawn WebGPU output" />
|
||||||
|
<div v-if="output.length" class="output" data-playground-log>
|
||||||
|
<div v-for="(line, index) in output" :key="index">{{ line }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="fps" data-playground-fps>{{ fps }} FPS</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.playground { margin: 24px 0; }
|
.playground {
|
||||||
canvas { width: 100%; aspect-ratio: 16 / 9; display: block; background: #111827; border-radius: 12px; }
|
position: relative;
|
||||||
p { color: var(--vp-c-text-2); }
|
left: 50%;
|
||||||
.failed { color: var(--vp-c-danger-1); }
|
width: min(1120px, calc(100vw - 64px));
|
||||||
|
margin: 28px 0 36px;
|
||||||
|
overflow: hidden;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
border: 1px solid var(--vp-c-divider);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #0b1020;
|
||||||
|
box-shadow: var(--vp-shadow-3);
|
||||||
|
}
|
||||||
|
:global(.VPContent.has-sidebar .playground) {
|
||||||
|
left: calc(50% + var(--vp-sidebar-width) / 2);
|
||||||
|
width: min(1120px, calc(100vw - var(--vp-sidebar-width) - 64px));
|
||||||
|
}
|
||||||
|
@media (min-width: 1280px) {
|
||||||
|
:global(.VPDoc.has-sidebar.has-aside .playground) {
|
||||||
|
left: 50%;
|
||||||
|
width: min(1120px, calc(100vw - var(--vp-sidebar-width) - 288px));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
min-height: 46px;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 7px 10px 7px 16px;
|
||||||
|
color: #dbeafe;
|
||||||
|
border-bottom: 1px solid #263249;
|
||||||
|
background: #111827;
|
||||||
|
}
|
||||||
|
header strong {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
header span {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
color: #94a3b8;
|
||||||
|
font:
|
||||||
|
12px/1.4 ui-monospace,
|
||||||
|
SFMono-Regular,
|
||||||
|
Menlo,
|
||||||
|
monospace;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
padding: 6px 14px;
|
||||||
|
color: white;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #2563eb;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
button.secondary {
|
||||||
|
color: #cbd5e1;
|
||||||
|
background: #293449;
|
||||||
|
}
|
||||||
|
button:disabled {
|
||||||
|
cursor: wait;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
.workspace {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
min-height: 510px;
|
||||||
|
}
|
||||||
|
textarea {
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
height: 510px;
|
||||||
|
resize: none;
|
||||||
|
padding: 18px;
|
||||||
|
color: #dbeafe;
|
||||||
|
border: 0;
|
||||||
|
border-right: 1px solid #263249;
|
||||||
|
outline: none;
|
||||||
|
background: #0b1020;
|
||||||
|
tab-size: 2;
|
||||||
|
font:
|
||||||
|
13px/1.55 ui-monospace,
|
||||||
|
SFMono-Regular,
|
||||||
|
Menlo,
|
||||||
|
Consolas,
|
||||||
|
monospace;
|
||||||
|
}
|
||||||
|
textarea:focus {
|
||||||
|
box-shadow: inset 0 0 0 2px #2563eb;
|
||||||
|
}
|
||||||
|
.preview {
|
||||||
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #050914;
|
||||||
|
}
|
||||||
|
canvas {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
.output {
|
||||||
|
position: absolute;
|
||||||
|
right: 12px;
|
||||||
|
bottom: 48px;
|
||||||
|
left: 12px;
|
||||||
|
max-height: 30%;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 8px 10px;
|
||||||
|
color: #bfdbfe;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgb(2 6 23 / 82%);
|
||||||
|
font:
|
||||||
|
11px/1.45 ui-monospace,
|
||||||
|
SFMono-Regular,
|
||||||
|
Menlo,
|
||||||
|
monospace;
|
||||||
|
}
|
||||||
|
.fps {
|
||||||
|
position: absolute;
|
||||||
|
right: 12px;
|
||||||
|
bottom: 12px;
|
||||||
|
padding: 5px 9px;
|
||||||
|
color: #dbeafe;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgb(2 6 23 / 82%);
|
||||||
|
font:
|
||||||
|
700 12px/1 ui-monospace,
|
||||||
|
SFMono-Regular,
|
||||||
|
Menlo,
|
||||||
|
monospace;
|
||||||
|
}
|
||||||
|
.failed {
|
||||||
|
color: #fca5a5;
|
||||||
|
}
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.playground,
|
||||||
|
:global(.VPContent.has-sidebar .playground) {
|
||||||
|
left: auto;
|
||||||
|
width: 100%;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
.workspace {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
textarea {
|
||||||
|
height: 360px;
|
||||||
|
border-right: 0;
|
||||||
|
border-bottom: 1px solid #263249;
|
||||||
|
}
|
||||||
|
.preview {
|
||||||
|
min-height: 360px;
|
||||||
|
}
|
||||||
|
header strong {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,388 @@
|
|||||||
|
const triangle = `import { Mesh, PBRMaterial, Scene } from "@yawn/handles";
|
||||||
|
|
||||||
|
const scene = new Scene(canvas, { hdr: true });
|
||||||
|
await scene.ready;
|
||||||
|
|
||||||
|
const material = new PBRMaterial(scene, {
|
||||||
|
baseColor: [0.15, 0.55, 1, 1],
|
||||||
|
metallic: 0.15,
|
||||||
|
roughness: 0.4,
|
||||||
|
});
|
||||||
|
await material.ready;
|
||||||
|
|
||||||
|
const mesh = new Mesh(scene, {
|
||||||
|
material,
|
||||||
|
vertexData: {
|
||||||
|
positions: [-0.7, -0.6, 0, 0.7, -0.6, 0, 0, 0.72, 0],
|
||||||
|
indices: [0, 1, 2],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await mesh.ready;
|
||||||
|
|
||||||
|
log("Move the pointer to write sceneAccent directly in the SAB.");
|
||||||
|
const move = (event) => {
|
||||||
|
const bounds = canvas.getBoundingClientRect();
|
||||||
|
scene.array("sceneAccent").row(0).set([
|
||||||
|
(event.clientX - bounds.left) / bounds.width,
|
||||||
|
1 - (event.clientY - bounds.top) / bounds.height,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
canvas.addEventListener("pointermove", move);
|
||||||
|
|
||||||
|
return {
|
||||||
|
scene,
|
||||||
|
mesh,
|
||||||
|
dispose() {
|
||||||
|
canvas.removeEventListener("pointermove", move);
|
||||||
|
scene.dispose();
|
||||||
|
},
|
||||||
|
};`;
|
||||||
|
|
||||||
|
const sab = `import { Mesh, PBRMaterial, Scene } from "@yawn/handles";
|
||||||
|
|
||||||
|
const scene = new Scene(canvas, { hdr: true });
|
||||||
|
await scene.ready;
|
||||||
|
const material = new PBRMaterial(scene, { baseColor: [0.2, 0.9, 0.65, 1] });
|
||||||
|
await material.ready;
|
||||||
|
const mesh = new Mesh(scene, {
|
||||||
|
material,
|
||||||
|
vertexData: {
|
||||||
|
positions: [-0.35, -0.35, 0, 0.35, -0.35, 0, 0, 0.42, 0],
|
||||||
|
indices: [0, 1, 2],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await mesh.ready;
|
||||||
|
|
||||||
|
const velocity = await scene.ensureRows("app.velocity", 1, 16, "f32");
|
||||||
|
velocity.row(0).set([0.8, 0, 0, 0]);
|
||||||
|
log("Pointer movement mutates nodePositions; no worker message is sent.");
|
||||||
|
|
||||||
|
const move = (event) => {
|
||||||
|
const bounds = canvas.getBoundingClientRect();
|
||||||
|
mesh.position[0] = ((event.clientX - bounds.left) / bounds.width - 0.5) * 1.4;
|
||||||
|
mesh.position[1] = (0.5 - (event.clientY - bounds.top) / bounds.height) * 1.2;
|
||||||
|
};
|
||||||
|
canvas.addEventListener("pointermove", move);
|
||||||
|
|
||||||
|
return {
|
||||||
|
scene,
|
||||||
|
mesh,
|
||||||
|
dispose() {
|
||||||
|
canvas.removeEventListener("pointermove", move);
|
||||||
|
scene.dispose();
|
||||||
|
},
|
||||||
|
};`;
|
||||||
|
|
||||||
|
const cameras = `import { ArcRotateCamera, Mesh, PBRMaterial, Scene } from "@yawn/handles";
|
||||||
|
|
||||||
|
const scene = new Scene(canvas, { hdr: true });
|
||||||
|
await scene.ready;
|
||||||
|
const material = new PBRMaterial(scene, { baseColor: [0.9, 0.3, 0.18, 1] });
|
||||||
|
await material.ready;
|
||||||
|
const mesh = new Mesh(scene, {
|
||||||
|
material,
|
||||||
|
vertexData: {
|
||||||
|
positions: [-0.7, -0.6, 0, 0.7, -0.6, 0, 0, 0.72, 0],
|
||||||
|
indices: [0, 1, 2],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await mesh.ready;
|
||||||
|
|
||||||
|
const camera = new ArcRotateCamera(scene, {
|
||||||
|
target: mesh,
|
||||||
|
alpha: 0,
|
||||||
|
beta: Math.PI / 2,
|
||||||
|
radius: 3,
|
||||||
|
fov: Math.PI / 3,
|
||||||
|
near: 0.05,
|
||||||
|
far: 100,
|
||||||
|
aspect: canvas.width / canvas.height,
|
||||||
|
controls: { element: canvas, pointer: true, controller: true },
|
||||||
|
});
|
||||||
|
await camera.ready;
|
||||||
|
log("Drag to orbit, right-drag to pan, and wheel to zoom.");
|
||||||
|
|
||||||
|
return {
|
||||||
|
scene,
|
||||||
|
mesh,
|
||||||
|
camera,
|
||||||
|
async dispose() {
|
||||||
|
await camera.dispose();
|
||||||
|
scene.dispose();
|
||||||
|
},
|
||||||
|
};`;
|
||||||
|
|
||||||
|
const instances = `import { Mesh, PBRMaterial, Scene } from "@yawn/handles";
|
||||||
|
|
||||||
|
const scene = new Scene(canvas, { hdr: true });
|
||||||
|
await scene.ready;
|
||||||
|
const material = new PBRMaterial(scene, { baseColor: [0.25, 0.8, 1, 1] });
|
||||||
|
await material.ready;
|
||||||
|
const source = new Mesh(scene, {
|
||||||
|
position: [-0.48, 0, 0],
|
||||||
|
scale: [0.58, 0.58, 1],
|
||||||
|
material,
|
||||||
|
vertexData: {
|
||||||
|
positions: [-0.7, -0.6, 0, 0.7, -0.6, 0, 0, 0.72, 0],
|
||||||
|
indices: [0, 1, 2],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await source.ready;
|
||||||
|
|
||||||
|
const instance = source.clone({ position: [0.48, 0, 0], scale: [0.58, 0.58, 1] });
|
||||||
|
await instance.ready;
|
||||||
|
log(\`Two mesh handles share geometry #\${source.geometryId}.\`);
|
||||||
|
|
||||||
|
return { scene, source, instance, dispose: () => scene.dispose() };`;
|
||||||
|
|
||||||
|
const materials = `import { Mesh, PBRMaterial, Scene } from "@yawn/handles";
|
||||||
|
|
||||||
|
const scene = new Scene(canvas, { hdr: true });
|
||||||
|
await scene.ready;
|
||||||
|
const paint = new PBRMaterial(scene, {
|
||||||
|
baseColor: [0.85, 0.08, 0.18, 1],
|
||||||
|
metallic: 0.75,
|
||||||
|
roughness: 0.18,
|
||||||
|
});
|
||||||
|
await paint.ready;
|
||||||
|
const mesh = new Mesh(scene, {
|
||||||
|
material: paint,
|
||||||
|
vertexData: {
|
||||||
|
positions: [-0.7, -0.6, 0, 0.7, -0.6, 0, 0, 0.72, 0],
|
||||||
|
indices: [0, 1, 2],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await mesh.ready;
|
||||||
|
|
||||||
|
log("Move horizontally to mutate material roughness in shared memory.");
|
||||||
|
const move = (event) => {
|
||||||
|
const bounds = canvas.getBoundingClientRect();
|
||||||
|
paint.roughness = (event.clientX - bounds.left) / bounds.width;
|
||||||
|
};
|
||||||
|
canvas.addEventListener("pointermove", move);
|
||||||
|
|
||||||
|
return {
|
||||||
|
scene,
|
||||||
|
mesh,
|
||||||
|
paint,
|
||||||
|
dispose() {
|
||||||
|
canvas.removeEventListener("pointermove", move);
|
||||||
|
scene.dispose();
|
||||||
|
},
|
||||||
|
};`;
|
||||||
|
|
||||||
|
const lights = `import { AmbientLight, Mesh, PBRMaterial, PointLight, Scene } from "@yawn/handles";
|
||||||
|
|
||||||
|
const scene = new Scene(canvas, { hdr: true });
|
||||||
|
await scene.ready;
|
||||||
|
const material = new PBRMaterial(scene, { baseColor: [1, 0.45, 0.08, 1], roughness: 0.35 });
|
||||||
|
await material.ready;
|
||||||
|
const mesh = new Mesh(scene, {
|
||||||
|
material,
|
||||||
|
vertexData: {
|
||||||
|
positions: [-0.7, -0.6, 0, 0.7, -0.6, 0, 0, 0.72, 0],
|
||||||
|
indices: [0, 1, 2],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await mesh.ready;
|
||||||
|
|
||||||
|
const point = new PointLight(scene, {
|
||||||
|
position: [0, 0.5, 0.5],
|
||||||
|
color: [1, 0.18, 0.04],
|
||||||
|
intensity: 12,
|
||||||
|
range: 8,
|
||||||
|
});
|
||||||
|
const ambient = new AmbientLight(scene, { color: [0.04, 0.12, 0.3], intensity: 0.35 });
|
||||||
|
await Promise.all([point.ready, ambient.ready]);
|
||||||
|
log("Point and ambient rows are consumed by the clustered compute pass.");
|
||||||
|
|
||||||
|
return { scene, mesh, point, ambient, dispose: () => scene.dispose() };`;
|
||||||
|
|
||||||
|
const compute = `import { ComputePass, Mesh, PBRMaterial, Scene } from "@yawn/handles";
|
||||||
|
|
||||||
|
const scene = new Scene(canvas, { hdr: true });
|
||||||
|
await scene.ready;
|
||||||
|
const material = new PBRMaterial(scene, { baseColor: [0.15, 0.75, 1, 1] });
|
||||||
|
await material.ready;
|
||||||
|
const mesh = new Mesh(scene, {
|
||||||
|
material,
|
||||||
|
vertexData: {
|
||||||
|
positions: [-0.7, -0.6, 0, 0.7, -0.6, 0, 0, 0.72, 0],
|
||||||
|
indices: [0, 1, 2],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await mesh.ready;
|
||||||
|
|
||||||
|
const values = await scene.ensureRows("simulation.values", 1, 16, "u32");
|
||||||
|
const simulation = new ComputePass({
|
||||||
|
id: "increment",
|
||||||
|
code: "@group(0) @binding(0) var<storage, read_write> values: array<u32>; @compute @workgroup_size(1) fn main() { values[0] += 1u; }",
|
||||||
|
buffers: [{ id: "simulation-values", array: "simulation.values", usage: ["storage"] }],
|
||||||
|
bindings: [{ group: 0, binding: 0, resource: "simulation-values" }],
|
||||||
|
});
|
||||||
|
await scene.addComputePass(simulation);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
log(\`Compute wrote \${values.row(0)[0]} into the SAB-backed buffer.\`);
|
||||||
|
|
||||||
|
return { scene, mesh, simulation, dispose: () => scene.dispose() };`;
|
||||||
|
|
||||||
|
const post = `import { ColorGrading, DynamicExposure, FXAA, Mesh, PBRMaterial, Scene } from "@yawn/handles";
|
||||||
|
|
||||||
|
const scene = new Scene(canvas, { hdr: true });
|
||||||
|
await scene.ready;
|
||||||
|
const material = new PBRMaterial(scene, { baseColor: [0.8, 0.18, 1, 1] });
|
||||||
|
await material.ready;
|
||||||
|
const mesh = new Mesh(scene, {
|
||||||
|
material,
|
||||||
|
vertexData: {
|
||||||
|
positions: [-0.7, -0.6, 0, 0.7, -0.6, 0, 0, 0.72, 0],
|
||||||
|
indices: [0, 1, 2],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await mesh.ready;
|
||||||
|
|
||||||
|
let exposure, grade, fxaa;
|
||||||
|
await scene.batchGraphUpdates(async () => {
|
||||||
|
exposure = new DynamicExposure(scene, { exposure: 1.25 });
|
||||||
|
grade = new ColorGrading(scene, { toneMap: "aces", amount: 1 });
|
||||||
|
fxaa = new FXAA(scene);
|
||||||
|
await Promise.all([exposure.ready, grade.ready, fxaa.ready]);
|
||||||
|
});
|
||||||
|
log("HDR → exposure → color grading → FXAA → canvas");
|
||||||
|
|
||||||
|
return { scene, mesh, exposure, grade, fxaa, dispose: () => scene.dispose() };`;
|
||||||
|
|
||||||
|
const importing = `import { AmbientLight, ArcRotateCamera, Picking, Scene, importGltf } from "@yawn/handles";
|
||||||
|
|
||||||
|
const scene = new Scene(canvas, { hdr: true, fps: 1, arenaBytes: 384 * 1024 * 1024 });
|
||||||
|
await scene.ready;
|
||||||
|
await scene.core.pause();
|
||||||
|
log("Importing /models/sponza.glb in the importer worker…");
|
||||||
|
const meshes = await importGltf(scene, "/models/sponza.glb");
|
||||||
|
|
||||||
|
const minimum = [Infinity, Infinity, Infinity];
|
||||||
|
const maximum = [-Infinity, -Infinity, -Infinity];
|
||||||
|
const rotate = (q, v) => {
|
||||||
|
const t = [
|
||||||
|
2 * (q[1] * v[2] - q[2] * v[1]),
|
||||||
|
2 * (q[2] * v[0] - q[0] * v[2]),
|
||||||
|
2 * (q[0] * v[1] - q[1] * v[0]),
|
||||||
|
];
|
||||||
|
return [
|
||||||
|
v[0] + q[3] * t[0] + q[1] * t[2] - q[2] * t[1],
|
||||||
|
v[1] + q[3] * t[1] + q[2] * t[0] - q[0] * t[2],
|
||||||
|
v[2] + q[3] * t[2] + q[0] * t[1] - q[1] * t[0],
|
||||||
|
];
|
||||||
|
};
|
||||||
|
const worldBounds = (mesh) => {
|
||||||
|
const bounds = scene.array("bounds").row(mesh.id);
|
||||||
|
const low = [Infinity, Infinity, Infinity];
|
||||||
|
const high = [-Infinity, -Infinity, -Infinity];
|
||||||
|
for (let corner = 0; corner < 8; corner++) {
|
||||||
|
const local = [0, 1, 2].map((lane) =>
|
||||||
|
bounds[(corner & (1 << lane) ? 4 : 0) + lane] * mesh.scale[lane]);
|
||||||
|
const point = rotate(mesh.quaternion, local).map((value, lane) => value + mesh.position[lane]);
|
||||||
|
for (let lane = 0; lane < 3; lane++) {
|
||||||
|
low[lane] = Math.min(low[lane], point[lane]);
|
||||||
|
high[lane] = Math.max(high[lane], point[lane]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [low, high];
|
||||||
|
};
|
||||||
|
for (const mesh of meshes) {
|
||||||
|
const [low, high] = worldBounds(mesh);
|
||||||
|
for (let lane = 0; lane < 3; lane++) {
|
||||||
|
minimum[lane] = Math.min(minimum[lane], low[lane]);
|
||||||
|
maximum[lane] = Math.max(maximum[lane], high[lane]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const center = minimum.map((value, lane) => (value + maximum[lane]) * 0.5);
|
||||||
|
const extent = Math.max(...maximum.map((value, lane) => value - minimum[lane]));
|
||||||
|
const scale = 1.5 / extent;
|
||||||
|
for (const mesh of meshes) {
|
||||||
|
mesh.position = mesh.position.map((value, lane) => (value - center[lane]) * scale);
|
||||||
|
mesh.scale = mesh.scale.map((value) => value * scale);
|
||||||
|
}
|
||||||
|
|
||||||
|
const camera = new ArcRotateCamera(scene, {
|
||||||
|
alpha: 0.7,
|
||||||
|
beta: 1.1,
|
||||||
|
radius: 2.7,
|
||||||
|
aspect: canvas.width / canvas.height,
|
||||||
|
controls: { element: canvas, pointer: true },
|
||||||
|
});
|
||||||
|
await camera.ready;
|
||||||
|
const ambient = new AmbientLight(scene, { color: [0.7, 0.8, 1], intensity: 0.7 });
|
||||||
|
await ambient.ready;
|
||||||
|
|
||||||
|
const picking = new Picking(scene);
|
||||||
|
await picking.ready;
|
||||||
|
const [targetLow, targetHigh] = worldBounds(meshes[0]);
|
||||||
|
const pickTarget = targetLow.map((value, lane) => (value + targetHigh[lane]) * 0.5);
|
||||||
|
const pick = async () => {
|
||||||
|
const origin = Array.from(camera.position);
|
||||||
|
const direction = pickTarget.map((value, lane) => value - origin[lane]);
|
||||||
|
const length = Math.hypot(...direction);
|
||||||
|
const hits = await picking.pick(origin, direction.map((value) => value / length));
|
||||||
|
log(\`Imported \${meshes.length} primitives; BVH ray returned \${hits.length} hit(s).\`);
|
||||||
|
};
|
||||||
|
canvas.addEventListener("click", pick);
|
||||||
|
await pick();
|
||||||
|
const play = setTimeout(() => scene.core.play(), 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
scene,
|
||||||
|
meshes,
|
||||||
|
camera,
|
||||||
|
picking,
|
||||||
|
async dispose() {
|
||||||
|
clearTimeout(play);
|
||||||
|
canvas.removeEventListener("click", pick);
|
||||||
|
picking.dispose();
|
||||||
|
await camera.dispose();
|
||||||
|
scene.dispose();
|
||||||
|
},
|
||||||
|
};`;
|
||||||
|
|
||||||
|
const core = `import { YawnCore } from "@yawn/core";
|
||||||
|
|
||||||
|
const encode = (value) => {
|
||||||
|
if (value === null || ["boolean", "number"].includes(typeof value)) return String(value);
|
||||||
|
if (typeof value === "string") return JSON.stringify(value);
|
||||||
|
if (Array.isArray(value)) return \`(array\${value.map((item) => \` \${encode(item)}\`).join("")})\`;
|
||||||
|
return \`(object\${Object.keys(value).sort().map((key) =>
|
||||||
|
\` (field \${JSON.stringify(key)} \${encode(value[key])})\`).join("")})\`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const core = new YawnCore(canvas);
|
||||||
|
await core.ready;
|
||||||
|
const accent = await core.createRows({ name: "accent", rows: 1, stride: 16, format: "f32" });
|
||||||
|
accent.write(0, [0.2, 0.75, 1, 1]);
|
||||||
|
|
||||||
|
const code = "struct Out { @builtin(position) position: vec4<f32> }; @group(0) @binding(0) var<uniform> color: vec4<f32>; @vertex fn vertex(@builtin(vertex_index) id: u32) -> Out { let points = array(vec2(-.7,-.6), vec2(.7,-.6), vec2(0.,.72)); var out: Out; out.position = vec4(points[id],0.,1.); return out; } @fragment fn fragment() -> @location(0) vec4<f32> { return color; }";
|
||||||
|
const graph = {
|
||||||
|
id: "direct-core",
|
||||||
|
resources: { buffers: [{ id: "accent", array: "accent", usage: ["uniform"] }], textures: [], samplers: [] },
|
||||||
|
pipelines: { render: [{ id: "triangle", code, vertex: { entry: "vertex" }, fragment: { entry: "fragment", targets: [{ format: "canvas" }] } }], compute: [] },
|
||||||
|
passes: [{ id: "triangle", type: "render", pipeline: "triangle", bindings: [{ group: 0, binding: 0, resource: "accent" }], color: [{ resource: "canvas", clear: [0.01, 0.02, 0.04, 1] }], draw: { vertices: 3 } }],
|
||||||
|
};
|
||||||
|
const id = await core.compileGraph(\`(yawn-graph 1 \${encode(graph)})\`);
|
||||||
|
await core.switchLoadout(id);
|
||||||
|
log("The canvas is rendered by a graph sent directly to the Rust/WASM core.");
|
||||||
|
|
||||||
|
return { core, accent, dispose: () => core.dispose() };`;
|
||||||
|
|
||||||
|
export const playgrounds = {
|
||||||
|
triangle: { title: "First scene", code: triangle },
|
||||||
|
sab: { title: "Direct shared-memory movement", code: sab },
|
||||||
|
cameras: { title: "Arc rotate camera", code: cameras },
|
||||||
|
instances: { title: "Geometry instances", code: instances },
|
||||||
|
materials: { title: "PBR material", code: materials },
|
||||||
|
lights: { title: "Clustered lights", code: lights },
|
||||||
|
compute: { title: "Compute pass", code: compute },
|
||||||
|
post: { title: "HDR post processing", code: post },
|
||||||
|
importing: { title: "glTF import and BVH picking", code: importing },
|
||||||
|
core: { title: "Direct core graph", code: core },
|
||||||
|
};
|
||||||
@@ -73,3 +73,9 @@ follow.start();
|
|||||||
```
|
```
|
||||||
|
|
||||||
The input and follow loops never post camera updates to core: they read and mutate the same camera, position, and quaternion rows that any other worker can use.
|
The input and follow loops never post camera updates to core: they read and mutate the same camera, position, and quaternion rows that any other worker can use.
|
||||||
|
|
||||||
|
<Playground example="cameras" />
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import Playground from "../.vitepress/Playground.vue";
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -42,3 +42,9 @@ values.row(id).set([1, 2, 3, 4]);
|
|||||||
Graph frontends serialize plain data to `(yawn-graph 1 ...)`. Named `after` edges preserve DAG fan-out; Rust sorts passes, detects cycles, culls unused declarations, plans compatible transient lifetimes, and allocates the active loadout.
|
Graph frontends serialize plain data to `(yawn-graph 1 ...)`. Named `after` edges preserve DAG fan-out; Rust sorts passes, detects cycles, culls unused declarations, plans compatible transient lifetimes, and allocates the active loadout.
|
||||||
|
|
||||||
The `Scene` addon is one such frontend. It is replaceable and has no privileged core API.
|
The `Scene` addon is one such frontend. It is replaceable and has no privileged core API.
|
||||||
|
|
||||||
|
<Playground example="core" />
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import Playground from "../.vitepress/Playground.vue";
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ The shared importer worker fetches and parses glTF/GLB, then the response hydrat
|
|||||||
```ts
|
```ts
|
||||||
import { importGltf } from "@yawn/handles";
|
import { importGltf } from "@yawn/handles";
|
||||||
|
|
||||||
const meshes = await importGltf(scene, "/models/helmet.glb");
|
const meshes = await importGltf(scene, "/models/sponza.glb");
|
||||||
meshes[0].position[1] = 0.5;
|
meshes[0].position[1] = 0.5;
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -33,3 +33,11 @@ If row allocations relocated since `Picking` was created, refresh its shared des
|
|||||||
```ts
|
```ts
|
||||||
await picking.refresh();
|
await picking.refresh();
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The playground below imports the repository's full LFS-backed `sponza.glb` in the importer worker, hydrates all 138 primitives, frames them with an arc camera, and sends a real ray to the BVH worker. Click the preview to pick again.
|
||||||
|
|
||||||
|
<Playground example="importing" />
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import Playground from "../.vitepress/Playground.vue";
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ canvas.addEventListener("pointermove", (event) => {
|
|||||||
|
|
||||||
The pointer handler sends no messages. The typed-array view points directly into the arena shared with the render worker. The camera helpers use this same pattern; see [Cameras and controls](/guide/cameras).
|
The pointer handler sends no messages. The typed-array view points directly into the arena shared with the render worker. The camera helpers use this same pattern; see [Cameras and controls](/guide/cameras).
|
||||||
|
|
||||||
|
<Playground example="sab" />
|
||||||
|
|
||||||
## Add an application-specific row
|
## Add an application-specific row
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
@@ -42,3 +44,7 @@ info[4] = 0; // resume rendering
|
|||||||
```
|
```
|
||||||
|
|
||||||
Use messages for rare control changes (`setFps`, graph updates, allocation); use SAB writes for existing hot state.
|
Use messages for rare control changes (`setFps`, graph updates, allocation); use SAB writes for existing hot state.
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import Playground from "../.vitepress/Playground.vue";
|
||||||
|
</script>
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user