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:
Amp
2026-08-20 09:21:21 +00:00
co-authored by heaust
parent 9768765d74
commit ded70a3cb4
14 changed files with 1466 additions and 250 deletions
+298 -72
View File
@@ -1,98 +1,324 @@
<script setup>
import { onMounted, onUnmounted, ref } from "vue";
import {
ComputePass,
FXAA,
Mesh,
PBRMaterial,
PointLight,
Scene,
} from "@yawn/handles";
import { nextTick, onMounted, onUnmounted, ref } from "vue";
import * as Handles from "@yawn/handles";
import { YawnCore } from "@yawn/core";
import { playgrounds } from "./playgrounds";
const props = defineProps({ example: { type: String, default: "triangle" } });
const preset = playgrounds[props.example] ?? playgrounds.triangle;
const canvas = ref();
const source = ref(preset.code);
const status = ref("Starting…");
const output = ref([]);
const failed = ref(false);
let scene;
let accent;
const running = ref(false);
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) {
if (!accent) return;
const bounds = canvas.value.getBoundingClientRect();
const row = accent.row(0);
row[0] = (event.clientX - bounds.left) / bounds.width;
row[1] = 1 - (event.clientY - bounds.top) / bounds.height;
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
const api = { ...Handles, YawnCore };
async function dispose(value = current) {
if (!value) return;
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 {
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.height = 540;
scene = new Scene(canvas.value, { hdr: true });
await scene.ready;
accent = scene.array("sceneAccent");
const material = new PBRMaterial(scene, {
baseColor: props.example === "lights" ? [1, 0.55, 0.18, 1] : [0.75, 0.9, 1, 1],
metallic: props.example === "materials" ? 0.9 : 0.1,
roughness: 0.38,
});
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;
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);
const executable = source.value.replace(/^\s*import\s+[^;]+;\s*$/gm, "");
const names = Object.keys(api);
const started = performance.now();
const result = await new AsyncFunction(
...names,
"canvas",
"log",
executable,
)(...names.map((name) => api[name]), canvas.value, (message) =>
output.value.push(String(message)),
);
if (runId !== generation) {
await dispose(result);
return;
}
window.__yawnPlayground = { scene, mesh, material, accent };
status.value = `${props.example} running · pointer movement writes sceneAccent in the SAB`;
current = result;
window.__yawnPlayground = result;
status.value = `Running · ${Math.round(performance.now() - started)} ms`;
} catch (error) {
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(() => {
delete window.__yawnPlayground;
scene?.dispose();
generation++;
cancelAnimationFrame(fpsFrame);
dispose();
});
</script>
<template>
<div class="playground">
<canvas ref="canvas" aria-label="Yawn WebGPU output" @pointermove="move" />
<p :class="{ failed }" data-playground-status>{{ status }}</p>
</div>
<section class="playground" :aria-label="`${preset.title} playground`">
<header>
<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 class="fps" data-playground-fps>{{ fps }} FPS</div>
</div>
</div>
</section>
</template>
<style scoped>
.playground { margin: 24px 0; }
canvas { width: 100%; aspect-ratio: 16 / 9; display: block; background: #111827; border-radius: 12px; }
p { color: var(--vp-c-text-2); }
.failed { color: var(--vp-c-danger-1); }
.playground {
position: relative;
left: 50%;
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>
+388
View File
@@ -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 },
};
+6
View File
@@ -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.
<Playground example="cameras" />
<script setup>
import Playground from "../.vitepress/Playground.vue";
</script>
+6
View File
@@ -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.
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>
+9 -1
View File
@@ -7,7 +7,7 @@ The shared importer worker fetches and parses glTF/GLB, then the response hydrat
```ts
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;
```
@@ -33,3 +33,11 @@ If row allocations relocated since `Picking` was created, refresh its shared des
```ts
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>
+6
View File
@@ -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).
<Playground example="sab" />
## Add an application-specific row
```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.
<script setup>
import Playground from "../.vitepress/Playground.vue";
</script>
Binary file not shown.