Add tutorial docs and interactive playgrounds
Amp-Thread-ID: https://ampcode.com/threads/T-01a01380-b478-77d0-84a0-102880a5c5ae Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<meta name="description" content="Edit and run Yawn render graph examples." />
|
||||
<title>Yawn Playground</title>
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<a class="brand" href="/docs/">YAWN<span>.</span></a>
|
||||
<div class="recipe-meta">
|
||||
<strong id="recipe-title">Playground</strong>
|
||||
<span id="recipe-package"></span>
|
||||
</div>
|
||||
<label>
|
||||
<span>Example</span>
|
||||
<select id="recipe-select" aria-label="Playground example"></select>
|
||||
</label>
|
||||
<button id="run" class="primary" type="button">▶ Run</button>
|
||||
<button id="reset" type="button">Reset</button>
|
||||
<button id="copy" type="button">Copy link</button>
|
||||
<a id="docs-link" class="button" href="/docs/">Docs ↗</a>
|
||||
</header>
|
||||
<main>
|
||||
<section class="code-pane" aria-label="Code editor">
|
||||
<div class="pane-title"><span>JavaScript</span><kbd>Ctrl</kbd> + <kbd>Enter</kbd> to run</div>
|
||||
<textarea id="editor" spellcheck="false" aria-label="Playground code"></textarea>
|
||||
</section>
|
||||
<section class="preview-pane" aria-label="Live preview">
|
||||
<iframe id="preview" title="Yawn playground preview"></iframe>
|
||||
<output id="status" aria-live="polite">Preparing playground…</output>
|
||||
</section>
|
||||
</main>
|
||||
<script type="module" src="./index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,79 @@
|
||||
import { PLAYGROUND_RECIPES, playgroundRecipe } from "./recipes.js";
|
||||
|
||||
const select = document.querySelector("#recipe-select");
|
||||
const title = document.querySelector("#recipe-title");
|
||||
const packageName = document.querySelector("#recipe-package");
|
||||
const editor = document.querySelector("#editor");
|
||||
const preview = document.querySelector("#preview");
|
||||
const status = document.querySelector("#status");
|
||||
const docs = document.querySelector("#docs-link");
|
||||
let recipeId;
|
||||
|
||||
for (const [id, recipe] of Object.entries(PLAYGROUND_RECIPES)) {
|
||||
const option = document.createElement("option");
|
||||
option.value = id;
|
||||
option.textContent = recipe.title;
|
||||
select.append(option);
|
||||
}
|
||||
|
||||
function choose(id, updateUrl = true) {
|
||||
recipeId = PLAYGROUND_RECIPES[id] ? id : "first-scene";
|
||||
const recipe = playgroundRecipe(recipeId);
|
||||
select.value = recipeId;
|
||||
title.textContent = recipe.title;
|
||||
packageName.textContent = recipe.package;
|
||||
editor.value = recipe.source;
|
||||
docs.href = recipe.docs;
|
||||
status.textContent = recipe.description;
|
||||
if (updateUrl) {
|
||||
const url = new URL(location.href);
|
||||
url.searchParams.set("recipe", recipeId);
|
||||
history.replaceState(null, "", url);
|
||||
}
|
||||
run();
|
||||
}
|
||||
|
||||
function run() {
|
||||
status.textContent = "Running…";
|
||||
status.dataset.error = "false";
|
||||
preview.src = `./runner.html?recipe=${encodeURIComponent(recipeId)}&run=${Date.now()}`;
|
||||
}
|
||||
|
||||
addEventListener("message", (event) => {
|
||||
if (event.origin !== location.origin || event.source !== preview.contentWindow) return;
|
||||
if (event.data?.type === "playground-runner-ready") {
|
||||
preview.contentWindow.postMessage(
|
||||
{ type: "playground-run", source: editor.value },
|
||||
location.origin,
|
||||
);
|
||||
} else if (event.data?.type === "playground-status" || event.data?.type === "playground-error") {
|
||||
status.textContent = event.data.message;
|
||||
status.dataset.error = String(event.data.type === "playground-error");
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelector("#run").addEventListener("click", run);
|
||||
document.querySelector("#reset").addEventListener("click", () => {
|
||||
editor.value = playgroundRecipe(recipeId).source;
|
||||
run();
|
||||
});
|
||||
document.querySelector("#copy").addEventListener("click", async (event) => {
|
||||
await navigator.clipboard.writeText(location.href);
|
||||
const original = event.currentTarget.textContent;
|
||||
event.currentTarget.textContent = "Copied";
|
||||
setTimeout(() => { event.currentTarget.textContent = original; }, 1200);
|
||||
});
|
||||
select.addEventListener("change", () => choose(select.value));
|
||||
editor.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
|
||||
event.preventDefault();
|
||||
run();
|
||||
}
|
||||
if (event.key === "Tab") {
|
||||
event.preventDefault();
|
||||
const start = editor.selectionStart;
|
||||
editor.setRangeText(" ", start, editor.selectionEnd, "end");
|
||||
}
|
||||
});
|
||||
|
||||
choose(new URLSearchParams(location.search).get("recipe"), false);
|
||||
@@ -0,0 +1,104 @@
|
||||
export const PLAYGROUND_RECIPES = Object.freeze({
|
||||
"first-scene": Object.freeze({
|
||||
title: "Your first scene",
|
||||
package: "All packages",
|
||||
description: "Boot core, load procedural glTF through the import worker, and activate a graph.",
|
||||
docs: "/docs/guide/first-scene",
|
||||
source: `const scene = await yawn.createScene({ loadout: "cubes" });
|
||||
yawn.status(
|
||||
\`Ready · \${scene.meshes.length} meshes · \${scene.core.telemetry.draws} draws\`,
|
||||
);`,
|
||||
}),
|
||||
"jso-graph": Object.freeze({
|
||||
title: "Load a JSO render graph",
|
||||
package: "@yawn/render-graph-js",
|
||||
description: "Compile a plain-object graph through the canonical AST boundary.",
|
||||
docs: "/docs/packages/render-graph#plain-object-authoring",
|
||||
source: `const scene = await yawn.createScene({
|
||||
loadout: "spheres",
|
||||
graph: yawn.graphs.culling,
|
||||
});
|
||||
yawn.status(
|
||||
\`Graph \${scene.compiled.graphId} · \${scene.core.telemetry.draws} draws\`,
|
||||
);`,
|
||||
}),
|
||||
"gltf-worker": Object.freeze({
|
||||
title: "Import glTF in a worker",
|
||||
package: "@yawn/gltf-import",
|
||||
description: "Stage a generated GLB in shared memory and commit only metadata.",
|
||||
docs: "/docs/packages/gltf-import",
|
||||
source: `const scene = await yawn.createScene({ loadout: "spheres" });
|
||||
yawn.status(
|
||||
\`Imported \${scene.meshes.length} mesh handles through shared memory\`,
|
||||
);`,
|
||||
}),
|
||||
"shared-animation": Object.freeze({
|
||||
title: "Animate through the SAB",
|
||||
package: "@yawn/core",
|
||||
description: "Write a generation-guarded instance transform every frame without messages.",
|
||||
docs: "/docs/packages/core#fast-path-shared-writes",
|
||||
source: `const scene = await yawn.createScene({ loadout: "cubes" });
|
||||
const instance = scene.meshes[0].defaultInstance;
|
||||
const start = performance.now();
|
||||
|
||||
function animate(now) {
|
||||
const angle = (now - start) * 0.001;
|
||||
instance.setTransform(yawn.rotationY(angle));
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
requestAnimationFrame(animate);
|
||||
yawn.status("Animating instance.transform directly in shared memory");`,
|
||||
}),
|
||||
"custom-soa": Object.freeze({
|
||||
title: "Allocate custom render data",
|
||||
package: "@yawn/core",
|
||||
description: "Add one aligned velocity row for every instance slot.",
|
||||
docs: "/docs/packages/core#request-an-soa-column",
|
||||
source: `const scene = await yawn.createScene({ loadout: "cubes" });
|
||||
const velocity = await scene.core.allocateArray({
|
||||
name: "instance.velocity",
|
||||
domain: "instance",
|
||||
scalar: "f32",
|
||||
lanes: 4,
|
||||
});
|
||||
|
||||
for (const mesh of scene.meshes) {
|
||||
velocity.write(mesh.defaultInstance.handle[0], [0, 0.25, 0, 0]);
|
||||
}
|
||||
yawn.status(\`Allocated \${velocity.length} SIMD-aligned velocity rows\`);`,
|
||||
}),
|
||||
"conventional-handles": Object.freeze({
|
||||
title: "Camera and material handles",
|
||||
package: "@yawn/mesh-handles",
|
||||
description: "Use familiar properties while mutations remain direct shared-memory writes.",
|
||||
docs: "/docs/packages/mesh-handles#camera-and-material-handles",
|
||||
source: `const scene = await yawn.createScene({ loadout: "materials" });
|
||||
scene.camera.lookAt([11, 9, 13], [0, 0, 0]);
|
||||
|
||||
const material = scene.materials[1];
|
||||
material.baseColor = [0.1, 0.55, 1, 1];
|
||||
material.metallic = 0.15;
|
||||
material.roughness = 0.28;
|
||||
yawn.status("Camera and material properties committed through SAB rows");`,
|
||||
}),
|
||||
picking: Object.freeze({
|
||||
title: "Pick shared scene data",
|
||||
package: "@yawn/mesh-handles",
|
||||
description: "Build the optional worker-side BVH and query the closest instance.",
|
||||
docs: "/docs/packages/mesh-handles#worker-side-picking",
|
||||
source: `const scene = await yawn.createScene({ loadout: "cubes" });
|
||||
const state = scene.camera.state;
|
||||
const origin = state.slice(0, 3);
|
||||
const direction = state.slice(4, 7).map((value, axis) => value - origin[axis]);
|
||||
const result = await scene.handles.pickRay(origin, direction, { maxHits: 1 });
|
||||
yawn.status(
|
||||
result.hits.length
|
||||
? \`Picked instance \${result.hits[0].instance.handle.join(":")}\`
|
||||
: "No instance intersected the center ray",
|
||||
);`,
|
||||
}),
|
||||
});
|
||||
|
||||
export function playgroundRecipe(id) {
|
||||
return PLAYGROUND_RECIPES[id] ?? PLAYGROUND_RECIPES["first-scene"];
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Yawn Playground Runner</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
html, body, main, canvas { width: 100%; height: 100%; margin: 0; }
|
||||
body { overflow: hidden; background: #080b10; color: #e8edf6; font: 13px Inter, system-ui, sans-serif; }
|
||||
canvas { display: block; background: #080b10; }
|
||||
output { position: fixed; left: 14px; bottom: 14px; max-width: calc(100% - 28px); padding: 8px 11px; border: 1px solid #ffffff18; border-radius: 7px; background: #0d121bea; color: #b9c3d2; box-shadow: 0 8px 28px #0008; }
|
||||
body[data-embed="false"] output { display: none; }
|
||||
body[data-error="true"] output { border-color: #ef795d88; color: #ffb8a6; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main><canvas id="scene"></canvas><output id="status">Waiting for code…</output></main>
|
||||
<script type="module" src="./runner.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,43 @@
|
||||
import { createPlaygroundRuntime } from "./runtime.js";
|
||||
import { playgroundRecipe } from "./recipes.js";
|
||||
|
||||
const status = document.querySelector("#status");
|
||||
const canvas = document.querySelector("#scene");
|
||||
const parentOrigin = location.origin;
|
||||
const parameters = new URLSearchParams(location.search);
|
||||
const embedded = parameters.has("embed");
|
||||
document.body.dataset.embed = String(embedded);
|
||||
let started = false;
|
||||
|
||||
function report(message, error = false) {
|
||||
status.textContent = message;
|
||||
document.body.dataset.error = String(error);
|
||||
parent.postMessage({ type: error ? "playground-error" : "playground-status", message }, parentOrigin);
|
||||
}
|
||||
|
||||
async function execute(source) {
|
||||
if (started) return;
|
||||
started = true;
|
||||
try {
|
||||
const yawn = createPlaygroundRuntime(canvas, report);
|
||||
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
|
||||
await new AsyncFunction("yawn", `"use strict";\n${source}`)(yawn);
|
||||
document.documentElement.dataset.yawnReady = "true";
|
||||
parent.postMessage({ type: "playground-ready" }, parentOrigin);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
report(error?.stack ?? error?.message ?? String(error), true);
|
||||
}
|
||||
}
|
||||
|
||||
addEventListener("message", (event) => {
|
||||
if (event.origin !== parentOrigin || event.data?.type !== "playground-run") return;
|
||||
void execute(event.data.source);
|
||||
});
|
||||
|
||||
const recipeId = parameters.get("recipe");
|
||||
if (embedded) {
|
||||
void execute(playgroundRecipe(recipeId).source);
|
||||
} else {
|
||||
parent.postMessage({ type: "playground-runner-ready" }, parentOrigin);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { YawnCore } from "@yawn/core";
|
||||
import { GltfImporter } from "@yawn/gltf-import";
|
||||
import {
|
||||
CameraHandle,
|
||||
MaterialHandles,
|
||||
MeshHandles,
|
||||
} from "@yawn/mesh-handles";
|
||||
import { loadGraph, graphFromObject, RenderGraph, ref } from "@yawn/render-graph-js";
|
||||
import {
|
||||
createGraphAst,
|
||||
reference,
|
||||
serializeGraphAst,
|
||||
} from "@yawn/render-graph-ast";
|
||||
import { defaultPipelines } from "@yawn/default-pipelines";
|
||||
import { loadDemoLoadout } from "../render-graph-studio/demo-loadouts.js";
|
||||
import { culling } from "../render-graph-studio/render-graph/presets.js";
|
||||
import { installCameraRenderDataControls } from "../shared/camera-controls.js";
|
||||
import { createWorkerTransport } from "../shared/create-worker-transport.js";
|
||||
|
||||
const waitForFrame = (core, predicate, timeout = 30_000) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const current = core.telemetry;
|
||||
if (current && predicate(current)) {
|
||||
resolve(current);
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
core.removeEventListener("renderer-frame", frame);
|
||||
reject(new Error("Renderer confirmation timed out"));
|
||||
}, timeout);
|
||||
const frame = (event) => {
|
||||
if (!predicate(event.detail)) return;
|
||||
clearTimeout(timer);
|
||||
core.removeEventListener("renderer-frame", frame);
|
||||
resolve(event.detail);
|
||||
};
|
||||
core.addEventListener("renderer-frame", frame);
|
||||
});
|
||||
|
||||
export function rotationY(angle) {
|
||||
const cosine = Math.cos(angle);
|
||||
const sine = Math.sin(angle);
|
||||
return [
|
||||
cosine, 0, -sine, 0,
|
||||
0, 1, 0, 0,
|
||||
sine, 0, cosine, 0,
|
||||
0, 0, 0, 1,
|
||||
];
|
||||
}
|
||||
|
||||
export function createPlaygroundRuntime(canvas, report) {
|
||||
let activeScene;
|
||||
const status = (message) => {
|
||||
report(String(message));
|
||||
};
|
||||
|
||||
async function createScene({ loadout = "cubes", graph = culling } = {}) {
|
||||
activeScene?.dispose();
|
||||
const core = new YawnCore(createWorkerTransport(canvas));
|
||||
const handles = new MeshHandles(core);
|
||||
const importer = new GltfImporter(core);
|
||||
let stopControls = () => {};
|
||||
try {
|
||||
status("Starting render worker…");
|
||||
await core.ready;
|
||||
const compiled = await loadGraph(core, graph);
|
||||
const targetRevision = (core.telemetry?.revision ?? 0) + 1;
|
||||
const glb = await loadDemoLoadout(loadout);
|
||||
const url = URL.createObjectURL(
|
||||
new Blob([glb], { type: "model/gltf-binary" }),
|
||||
);
|
||||
let imported;
|
||||
try {
|
||||
imported = await importer.load(url);
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
const meshes = handles.fromImportedScene(imported);
|
||||
const materials = new MaterialHandles(core).fromImportedScene(imported);
|
||||
const camera = new CameraHandle(core);
|
||||
stopControls = installCameraRenderDataControls(core, canvas);
|
||||
await core.switchCompiledGraph(compiled.compiledId);
|
||||
await waitForFrame(
|
||||
core,
|
||||
(frame) =>
|
||||
frame.revision === targetRevision &&
|
||||
frame.activeCompiledGraph === graph.id &&
|
||||
frame.draws > 0 &&
|
||||
frame.gpuError === false,
|
||||
);
|
||||
activeScene = {
|
||||
core,
|
||||
handles,
|
||||
meshes,
|
||||
materials,
|
||||
camera,
|
||||
compiled: { ...compiled, graphId: graph.id },
|
||||
dispose() {
|
||||
stopControls();
|
||||
handles.dispose();
|
||||
core.dispose();
|
||||
activeScene = undefined;
|
||||
},
|
||||
};
|
||||
return activeScene;
|
||||
} catch (error) {
|
||||
stopControls();
|
||||
handles.dispose();
|
||||
core.dispose();
|
||||
throw error;
|
||||
} finally {
|
||||
importer.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
createScene,
|
||||
status,
|
||||
rotationY,
|
||||
graphs: Object.freeze({ culling }),
|
||||
packages: Object.freeze({
|
||||
createGraphAst,
|
||||
defaultPipelines,
|
||||
graphFromObject,
|
||||
reference,
|
||||
ref,
|
||||
RenderGraph,
|
||||
serializeGraphAst,
|
||||
}),
|
||||
dispose() {
|
||||
activeScene?.dispose();
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
|
||||
background: #0a0d12;
|
||||
color: #eef2f8;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; margin: 0; overflow: hidden; }
|
||||
body { display: grid; grid-template-rows: 64px minmax(0, 1fr); }
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
padding: 9px 14px;
|
||||
border-bottom: 1px solid #29303b;
|
||||
background: #11151c;
|
||||
}
|
||||
.brand { margin-right: 4px; color: #fff; font-size: 17px; font-weight: 850; letter-spacing: .14em; text-decoration: none; }
|
||||
.brand span { color: #e16f3c; }
|
||||
.recipe-meta { display: grid; min-width: 190px; margin-right: auto; }
|
||||
.recipe-meta strong { overflow: hidden; font-size: 14px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.recipe-meta span, label > span { color: #8792a3; font: 10px ui-monospace, monospace; letter-spacing: .08em; text-transform: uppercase; }
|
||||
label { display: grid; gap: 3px; }
|
||||
button, select, .button {
|
||||
min-height: 36px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #3a4452;
|
||||
border-radius: 6px;
|
||||
color: #e8edf5;
|
||||
background: #202733;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.button { display: inline-flex; align-items: center; }
|
||||
button:hover, select:hover, .button:hover { border-color: #69778c; }
|
||||
button.primary { border-color: #ee8251; background: #c65d2f; }
|
||||
main { display: grid; grid-template-columns: minmax(360px, .9fr) minmax(0, 1.1fr); min-height: 0; }
|
||||
.code-pane, .preview-pane { position: relative; min-width: 0; min-height: 0; }
|
||||
.code-pane { display: grid; grid-template-rows: 38px minmax(0, 1fr); border-right: 1px solid #29303b; background: #0d1117; }
|
||||
.pane-title { display: flex; align-items: center; gap: 5px; padding: 0 14px; border-bottom: 1px solid #252c36; color: #8894a5; font-size: 11px; }
|
||||
.pane-title span { margin-right: auto; color: #d7dee9; font-weight: 700; }
|
||||
kbd { padding: 2px 5px; border: 1px solid #39424e; border-radius: 4px; background: #171d25; font: 10px ui-monospace, monospace; }
|
||||
textarea {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
resize: none;
|
||||
padding: 22px;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
color: #dce6f3;
|
||||
background: transparent;
|
||||
font: 14px/1.65 ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
tab-size: 2;
|
||||
}
|
||||
.preview-pane { background: #080b10; }
|
||||
iframe { width: 100%; height: 100%; border: 0; }
|
||||
#status { position: absolute; left: 14px; bottom: 14px; max-width: calc(100% - 28px); overflow: hidden; padding: 8px 11px; border: 1px solid #ffffff18; border-radius: 7px; background: #0d121bea; color: #b9c3d2; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; pointer-events: none; }
|
||||
#status[data-error="true"] { color: #ffb8a6; border-color: #ef795d88; }
|
||||
@media (max-width: 850px) {
|
||||
body { grid-template-rows: auto minmax(0, 1fr); }
|
||||
header { flex-wrap: wrap; }
|
||||
.recipe-meta { min-width: 0; }
|
||||
header label { order: 2; width: 100%; }
|
||||
header select { width: 100%; }
|
||||
main { grid-template-columns: 1fr; grid-template-rows: 48% 52%; }
|
||||
.code-pane { border-right: 0; border-bottom: 1px solid #29303b; }
|
||||
#copy { display: none; }
|
||||
}
|
||||
Reference in New Issue
Block a user