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:
+298
-72
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user