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>