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:
Amp
2026-08-19 14:20:59 +00:00
co-authored by heaust
parent 6bbf8039e4
commit d34722d66d
57 changed files with 5063 additions and 767 deletions
+6 -7
View File
@@ -1,10 +1,9 @@
# Yawn examples
- `index.html` is the browser landing page for the complete example and recipes.
- `render-graph-studio/` is the complete browser integration with FXNode, JSO,
external pipelines, shared glTF import, mesh handles, and picking.
- `cookbook/` contains small copyable recipes, each focused on one public API.
- `playground/` is the editable code-and-preview environment used by the docs.
- `render-graph-studio/` is the advanced FXNode and JSO graph playground.
- `shared/` contains browser bootstrap utilities shared by those playgrounds.
Start the examples index with `npm run examples`. The cookbook modules are plain
ES modules and accept a `YawnCore`, mesh, instance, or worker endpoint where a live
renderer is required.
Start the playgrounds and VitePress tutorials together with `npm run examples`.
Copyable recipes live under `docs/recipes`; the example server contains only
runnable playground code.
-27
View File
@@ -1,27 +0,0 @@
import {
createGraphAst,
reference,
serializeGraphAst,
} from "@yawn/render-graph-ast";
const expression = (id, inputs = {}) => ({
id,
state: "enabled",
executor: { key: "and", version: 2 },
parameters: {},
inputs,
});
/** Build one DAG whose shared output fans out to two consumers. */
export function canonicalAstExample() {
const ast = createGraphAst({
id: "shared_dag",
revision: 1,
nodes: [
expression("source"),
expression("left", { inputs: [reference("source", "value")] }),
expression("right", { inputs: [reference("source", "value")] }),
],
});
return { ast, source: serializeGraphAst(ast) };
}
-18
View File
@@ -1,18 +0,0 @@
import { graphFromObject } from "@yawn/render-graph-js";
/** Author a graph with an ordinary JavaScript object and receive canonical AST. */
export function jsoGraphExample() {
return graphFromObject({
id: "jso_graph",
revision: 1,
nodes: [
{
id: "mesh",
state: "enabled",
executor: { key: "mesh", version: 2 },
parameters: {},
inputs: {},
},
],
});
}
-12
View File
@@ -1,12 +0,0 @@
import { RenderGraph, ref } from "@yawn/render-graph-js";
/** Build the same sort of DAG with the small mutable authoring facade. */
export function fluentGraphExample() {
return new RenderGraph("fluent_graph", 1)
.node("source", "and", { version: 2 })
.node("consumer", "not", {
version: 1,
inputs: { operand: [ref("source", "value")] },
})
.ast();
}
-19
View File
@@ -1,19 +0,0 @@
import { defaultPipelines } from "@yawn/default-pipelines";
import { adaptFxNodeSnapshot } from "@yawn/render-graph-fxnode";
import { CATALOG_VERSION, GRAPH_ID } from "@yawn/render-graph-fxnode/catalog";
/** Export a minimal FXNode authoring document through the shared AST boundary. */
export function fxNodeExportExample() {
return adaptFxNodeSnapshot(
{
graphId: GRAPH_ID,
catalogVersion: CATALOG_VERSION,
nodes: [],
links: [],
metadata: {},
version: 1,
},
1,
{ pipelines: defaultPipelines },
);
}
-12
View File
@@ -1,12 +0,0 @@
import { defaultPipelines } from "@yawn/default-pipelines";
import { RenderGraph } from "@yawn/render-graph-js";
/** Copy the optional package's external programs into a graph AST. */
export function defaultPipelineExample() {
const graph = new RenderGraph("default_programs", 1);
for (const pipeline of defaultPipelines.render)
graph.renderPipeline(pipeline);
for (const pipeline of defaultPipelines.compute)
graph.computePipeline(pipeline);
return graph.ast();
}
@@ -1,31 +0,0 @@
import { RenderGraph } from "@yawn/render-graph-js";
export const simpleSceneShader = /* wgsl */ `
@group(1) @binding(0) var<uniform> view_projection: mat4x4<f32>;
struct Input {
@location(0) position: vec3<f32>,
@location(3) model_0: vec4<f32>,
@location(4) model_1: vec4<f32>,
@location(5) model_2: vec4<f32>,
@location(6) model_3: vec4<f32>,
}
@vertex fn vertex_main(input: Input) -> @builtin(position) vec4<f32> {
let model = mat4x4<f32>(input.model_0, input.model_1, input.model_2, input.model_3);
return view_projection * model * vec4(input.position, 1.0);
}
@fragment fn fragment_main() -> @location(0) vec4<f32> {
return vec4(0.2, 0.7, 1.0, 1.0);
}`;
/** Supply scene WGSL and entry points as graph data, never as core source. */
export function customRenderPipelineExample() {
return new RenderGraph("custom_render_program", 1)
.renderPipeline({
name: "ground_plane",
shader: simpleSceneShader,
vertexEntry: "vertex_main",
fragmentEntry: "fragment_main",
doubleSided: false,
})
.ast();
}
-18
View File
@@ -1,18 +0,0 @@
import { RenderGraph } from "@yawn/render-graph-js";
export const initializeShader = /* wgsl */ `
@compute @workgroup_size(8, 1, 1)
fn initialize() {}
`;
/** Declare a binding-free compute pass that runs before graph render passes. */
export function computePipelineExample() {
return new RenderGraph("compute_program", 1)
.computePipeline({
name: "initialize",
shader: initializeShader,
entry: "initialize",
dispatch: [4, 1, 1],
})
.ast();
}
@@ -1,13 +0,0 @@
import { loadGraph } from "@yawn/render-graph-js";
/** Compile a complete AST/JSO and make its prepared loadout active. */
export async function compileAndSwitch(core, graph) {
const compiled = await loadGraph(core, graph);
try {
await core.switchCompiledGraph(compiled.compiledId);
return compiled;
} catch (error) {
await core.dropCompiledGraph(compiled.compiledId).catch(() => {});
throw error;
}
}
@@ -1,13 +0,0 @@
import { GltfImporter } from "@yawn/gltf-import";
import { MeshHandles } from "@yawn/mesh-handles";
/** Fetch a glTF URL in the import worker and wrap the resulting protocol handles. */
export async function importGltf(core, url, options) {
const importer = new GltfImporter(core);
try {
const imported = await importer.load(url, options);
return new MeshHandles(core).fromImportedScene(imported);
} finally {
importer.dispose();
}
}
-14
View File
@@ -1,14 +0,0 @@
export const identityTransform = Object.freeze([
1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1,
]);
/** Create a conventional instance object from an imported mesh handle. */
export function createInstance(mesh, transform = identityTransform) {
return mesh.createInstance(transform);
}
/** Frequent mutations stay on the shared-memory path exposed by the handle addon. */
export function updateInstance(instance, transform, typeWords) {
instance.setTransform(transform);
instance.setType(typeWords);
}
-14
View File
@@ -1,14 +0,0 @@
/** Allocate one SIMD-aligned velocity row for every live instance slot. */
export function createVelocityColumn(core) {
return core.allocateArray({
name: "instance.velocity",
domain: "instance",
scalar: "f32",
lanes: 4,
});
}
/** Mutate an existing row directly; no renderer command message is emitted. */
export function setVelocity(column, instance, xyz) {
column.write(instance.handle[0], [xyz[0], xyz[1], xyz[2], 0]);
}
@@ -1,9 +0,0 @@
/** Write a new model matrix through core's generation-guarded shared SOA column. */
export function animateInstance(core, instance, transform) {
core.setInstanceTransform(instance.handle, transform);
}
/** Write the opaque 512-bit instance classification used by graph predicates. */
export function classifyInstance(core, instance, words) {
core.setInstanceType(instance.handle, words);
}
-7
View File
@@ -1,7 +0,0 @@
/** Query the optional snapshot/BVH worker and receive wrapped instance handles. */
export function pickNearest(meshHandles, origin, direction) {
return meshHandles.pickRay(origin, direction, {
maxDistance: 10_000,
maxHits: 1,
});
}
-13
View File
@@ -1,13 +0,0 @@
import { YawnCore } from "@yawn/core";
/** Connect from any worker using a MessagePort with the Worker-like transport API. */
export async function connectFromWorker(port, options = {}) {
const core = new YawnCore({
worker: port,
memory: options.memory,
ringPtr: options.ringPtr,
free: options.free,
});
await core.ready;
return core;
}
-10
View File
@@ -1,10 +0,0 @@
import { culling } from "../render-graph-studio/render-graph/presets.js";
import { importGltf } from "./09-gltf-import-worker.js";
import { compileAndSwitch } from "./08-compile-and-switch.js";
/** Combine the complete JSO graph, shared glTF import, and mesh-handle facade. */
export async function loadCompleteScene(core, gltfUrl) {
const meshes = await importGltf(core, gltfUrl);
const compiled = await compileAndSwitch(core, culling);
return { compiled, meshes };
}
@@ -1,19 +0,0 @@
import { CameraHandle, MaterialHandles, MeshHandles } from "@yawn/mesh-handles";
/** Wrap one import result in the conventional object API without hiding core. */
export function conventionalSceneHandles(core, imported) {
return {
camera: new CameraHandle(core),
meshes: new MeshHandles(core).fromImportedScene(imported),
materials: new MaterialHandles(core).fromImportedScene(imported),
};
}
/** Property assignments remain direct SAB writes; no camera/material message is sent. */
export function restyleScene({ camera, materials }) {
camera.lookAt([4, 3, 6], [0, 0, 0]);
if (materials[0]) {
materials[0].baseColor = [0.2, 0.55, 1, 1];
materials[0].roughness = 0.35;
}
}
-28
View File
@@ -1,28 +0,0 @@
# Yawn addon cookbook
These recipes deliberately avoid another application framework or renderer wrapper.
Import the function you need and pass the same `YawnCore` instance to every addon.
| Recipe | Demonstrates |
| --- | --- |
| `01-canonical-ast.js` | A shared DAG output and canonical S-expression serialization |
| `02-jso-graph.js` | Plain JavaScript object authoring |
| `03-fluent-builder.js` | Fluent render-graph authoring |
| `04-fxnode-export.js` | Exporting an FXNode snapshot to the canonical AST |
| `05-default-pipelines.js` | Attaching optional external WGSL declarations |
| `06-custom-render-pipeline.js` | Supplying a custom scene render shader |
| `07-compute-pipeline.js` | Supplying a binding-free compute pass |
| `08-compile-and-switch.js` | Compiling and transactionally activating a graph |
| `09-gltf-import-worker.js` | Fetching a glTF URL into shared memory from a worker |
| `10-mesh-instances.js` | Creating and mutating conventional instance handles |
| `11-custom-soa-column.js` | Allocating an instance-sized shared SOA column |
| `12-direct-sab-animation.js` | Updating transforms through generation-guarded SAB writes |
| `13-bvh-picking.js` | Ray picking through the mesh-handles addon |
| `14-worker-to-worker.js` | Using core from another worker through a `MessagePort` |
| `15-complete-scene.js` | Combining the graph, glTF, and mesh addons |
| `16-camera-render-data.js` | Treating camera input as direct render-data SAB writes |
| `17-conventional-handles.js` | Conventional camera and material properties over SAB rows |
Recipes 17 isolate graph authoring concepts, so their ASTs are intentionally
fragments rather than complete renderable loadouts. Recipe 15 uses the complete
scene graph from `render-graph-studio` when an end-to-end example is needed.
-17
View File
@@ -1,17 +0,0 @@
export * from "./01-canonical-ast.js";
export * from "./02-jso-graph.js";
export * from "./03-fluent-builder.js";
export * from "./04-fxnode-export.js";
export * from "./05-default-pipelines.js";
export * from "./06-custom-render-pipeline.js";
export * from "./07-compute-pipeline.js";
export * from "./08-compile-and-switch.js";
export * from "./09-gltf-import-worker.js";
export * from "./10-mesh-instances.js";
export * from "./11-custom-soa-column.js";
export * from "./12-direct-sab-animation.js";
export * from "./13-bvh-picking.js";
export * from "./14-worker-to-worker.js";
export * from "./15-complete-scene.js";
export * from "./16-camera-render-data.js";
export * from "./17-conventional-handles.js";
+4 -193
View File
@@ -2,200 +2,11 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta
name="description"
content="Runnable integration and focused addon recipes for the Yawn render core."
/>
<title>Yawn Examples</title>
<style>
:root {
color-scheme: dark;
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
background: #0a0c0d;
color: #f3f1e8;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; }
body::before {
position: fixed;
inset: 0;
z-index: -1;
content: "";
background:
radial-gradient(circle at 75% 8%, #bb5b2b26, transparent 28rem),
linear-gradient(#ffffff08 1px, transparent 1px),
linear-gradient(90deg, #ffffff08 1px, transparent 1px),
#0a0c0d;
background-size: auto, 64px 64px, 64px 64px, auto;
}
a { color: inherit; }
.shell { width: min(1180px, calc(100% - 40px)); margin: auto; }
header {
display: flex;
align-items: center;
justify-content: space-between;
height: 82px;
border-bottom: 1px solid #ffffff1a;
}
.wordmark { font-size: 18px; font-weight: 850; letter-spacing: .18em; }
.wordmark span { color: #da7543; }
header nav { display: flex; gap: 22px; color: #a9ada8; font-size: 13px; }
header nav a { text-decoration: none; }
header nav a:hover { color: #fff; }
.hero { padding: 88px 0 58px; max-width: 820px; }
.eyebrow,
.recipe-number {
color: #dc7543;
font: 700 11px ui-monospace, SFMono-Regular, Menlo, monospace;
letter-spacing: .14em;
text-transform: uppercase;
}
h1 {
margin: 16px 0 20px;
max-width: 780px;
font-size: clamp(48px, 8vw, 92px);
font-weight: 760;
letter-spacing: -.055em;
line-height: .94;
}
.hero p { max-width: 660px; color: #b0b3ae; font-size: 18px; line-height: 1.65; }
.primary {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(270px, .6fr);
min-height: 340px;
overflow: hidden;
border: 1px solid #ffffff20;
border-radius: 18px;
background: #111416e8;
text-decoration: none;
transition: border-color 160ms, transform 160ms;
}
.primary:hover { transform: translateY(-3px); border-color: #dc754388; }
.primary-copy { padding: clamp(30px, 5vw, 64px); }
.primary h2 { margin: 15px 0 12px; font-size: clamp(30px, 4vw, 52px); letter-spacing: -.035em; }
.primary p { max-width: 580px; color: #aaafaa; font-size: 16px; line-height: 1.65; }
.launch { display: inline-flex; align-items: center; gap: 10px; margin-top: 30px; font-weight: 720; }
.launch span { color: #dc7543; font-size: 22px; transition: transform 160ms; }
.primary:hover .launch span { transform: translateX(5px); }
.primary-art {
display: grid;
place-items: center;
min-height: 260px;
border-left: 1px solid #ffffff16;
background: linear-gradient(145deg, #d57442, #6d2e1d);
}
.graph {
position: relative;
width: 190px;
height: 165px;
filter: drop-shadow(0 18px 25px #3d130b88);
}
.node { position: absolute; width: 78px; height: 48px; border: 1px solid #ffffff77; border-radius: 7px; background: #17191bd9; }
.node::after { position: absolute; inset: 10px 13px; content: ""; border-top: 3px solid #fff; border-bottom: 3px solid #ffffff55; }
.node:nth-child(1) { left: 0; top: 8px; }
.node:nth-child(2) { right: 0; top: 58px; }
.node:nth-child(3) { left: 20px; bottom: 0; }
.edge { position: absolute; height: 1px; transform-origin: left; background: #fff9; }
.edge-a { left: 70px; top: 48px; width: 77px; transform: rotate(19deg); }
.edge-b { left: 92px; top: 135px; width: 70px; transform: rotate(-40deg); }
.section-heading { display: flex; align-items: end; justify-content: space-between; gap: 20px; margin: 94px 0 26px; }
.section-heading h2 { margin: 0; font-size: 34px; letter-spacing: -.03em; }
.section-heading p { margin: 0; color: #929792; }
.recipes { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
.recipe {
min-height: 180px;
padding: 25px;
border: 1px solid #ffffff17;
border-radius: 12px;
background: #101315d9;
text-decoration: none;
transition: background 140ms, border-color 140ms;
}
.recipe:hover { border-color: #dc75436b; background: #17191b; }
.recipe h3 { margin: 24px 0 8px; font-size: 17px; }
.recipe p { margin: 0; color: #989d98; font-size: 13px; line-height: 1.55; }
footer { display: flex; justify-content: space-between; gap: 20px; margin-top: 80px; padding: 34px 0 50px; border-top: 1px solid #ffffff1a; color: #858a85; font-size: 13px; }
code { color: #d5d0c4; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
@media (max-width: 850px) {
.primary { grid-template-columns: 1fr; }
.primary-art { min-height: 220px; border-top: 1px solid #ffffff16; border-left: 0; }
.recipes { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 560px) {
.shell { width: min(100% - 24px, 1180px); }
header nav a:first-child { display: none; }
.hero { padding-top: 60px; }
.recipes { grid-template-columns: 1fr; }
.section-heading, footer { align-items: start; flex-direction: column; }
}
</style>
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta http-equiv="refresh" content="0;url=/playground/" />
<title>Yawn Playground</title>
</head>
<body>
<div class="shell">
<header>
<div class="wordmark">YAWN<span>.</span></div>
<nav aria-label="Examples navigation">
<a href="#recipes">Recipes</a>
<a href="https://github.com/heaust-ops/yawn">Repository ↗</a>
</nav>
</header>
<main>
<section class="hero">
<div class="eyebrow">Worker-native rendering toolkit</div>
<h1>Build the graph.<br />Share the data.</h1>
<p>
Start with the complete interactive scene, then pull apart the focused
recipes for graph ASTs, external pipelines, glTF workers, mesh handles,
and direct shared-memory mutation.
</p>
</section>
<a class="primary" href="./render-graph-studio/">
<div class="primary-copy">
<div class="eyebrow">Interactive example · all packages</div>
<h2>Render Graph Studio</h2>
<p>
Edit an FXNode graph, swap JSO loadouts, stream procedural glTF through
shared memory, pick instances, and orbit the shared-SOA camera.
</p>
<div class="launch">Launch studio <span></span></div>
</div>
<div class="primary-art" aria-hidden="true">
<div class="graph">
<i class="node"></i><i class="node"></i><i class="node"></i>
<i class="edge edge-a"></i><i class="edge edge-b"></i>
</div>
</div>
</a>
<section id="recipes" aria-labelledby="recipes-title">
<div class="section-heading">
<h2 id="recipes-title">Focused recipes</h2>
<p>Small ES modules meant to copy, change, and combine.</p>
</div>
<div class="recipes">
<a class="recipe" href="./cookbook/01-canonical-ast.js"><span class="recipe-number">01 · AST</span><h3>Canonical DAG</h3><p>Share one graph output through references and serialize it as an S-expression.</p></a>
<a class="recipe" href="./cookbook/02-jso-graph.js"><span class="recipe-number">02 · JSO</span><h3>Plain object graph</h3><p>Describe a graph with ordinary JavaScript objects and compile to the canonical AST.</p></a>
<a class="recipe" href="./cookbook/03-fluent-builder.js"><span class="recipe-number">03 · JSO</span><h3>Fluent builder</h3><p>Author the same public graph contract with a compact chainable API.</p></a>
<a class="recipe" href="./cookbook/04-fxnode-export.js"><span class="recipe-number">04 · FXNode</span><h3>Snapshot export</h3><p>Turn an editor snapshot into the same AST consumed by every other frontend.</p></a>
<a class="recipe" href="./cookbook/05-default-pipelines.js"><span class="recipe-number">05 · Pipelines</span><h3>Default loadout</h3><p>Attach the optional external pipeline addon without placing shaders in core.</p></a>
<a class="recipe" href="./cookbook/06-custom-render-pipeline.js"><span class="recipe-number">06 · WGSL</span><h3>Custom render pipeline</h3><p>Provide scene WGSL, entry points, and state as graph-owned data.</p></a>
<a class="recipe" href="./cookbook/07-compute-pipeline.js"><span class="recipe-number">07 · Compute</span><h3>Compute pipeline</h3><p>Declare a compute shader and dispatch dimensions inside the graph loadout.</p></a>
<a class="recipe" href="./cookbook/08-compile-and-switch.js"><span class="recipe-number">08 · Lifecycle</span><h3>Compile and switch</h3><p>Prepare and transactionally activate compiled graph resources.</p></a>
<a class="recipe" href="./cookbook/09-gltf-import-worker.js"><span class="recipe-number">09 · glTF</span><h3>Shared import worker</h3><p>Parse a URL in a worker and write generic render data directly into shared SOA memory.</p></a>
<a class="recipe" href="./cookbook/10-mesh-instances.js"><span class="recipe-number">10 · Handles</span><h3>Mesh instances</h3><p>Use conventional generation-safe mesh and instance objects over the core protocol.</p></a>
<a class="recipe" href="./cookbook/11-custom-soa-column.js"><span class="recipe-number">11 · SOA</span><h3>Custom column</h3><p>Request an aligned instance-sized array, then mutate it without messages.</p></a>
<a class="recipe" href="./cookbook/12-direct-sab-animation.js"><span class="recipe-number">12 · SAB</span><h3>Direct animation</h3><p>Update guarded transforms at frame rate through shared memory.</p></a>
<a class="recipe" href="./cookbook/13-bvh-picking.js"><span class="recipe-number">13 · Picking</span><h3>BVH picking</h3><p>Ray-pick shared scene data using the optional mesh-handles worker.</p></a>
<a class="recipe" href="./cookbook/14-worker-to-worker.js"><span class="recipe-number">14 · Workers</span><h3>Worker to worker</h3><p>Run the public core API from any browser worker through a MessagePort.</p></a>
<a class="recipe" href="./cookbook/15-complete-scene.js"><span class="recipe-number">15 · Complete</span><h3>Complete scene</h3><p>Combine graph, pipeline, glTF import, and mesh addons end to end.</p></a>
<a class="recipe" href="./cookbook/16-camera-render-data.js"><span class="recipe-number">16 · Camera</span><h3>Camera render data</h3><p>Orbit, pan, and zoom by directly writing one SIMD-width SAB row.</p></a>
<a class="recipe" href="./cookbook/17-conventional-handles.js"><span class="recipe-number">17 · Handles</span><h3>Camera and materials</h3><p>Use familiar camera and material properties while retaining direct SAB mutations.</p></a>
</div>
</section>
</main>
<footer><span>Yawn examples</span><code>npm run examples</code></footer>
</div>
<p><a href="/playground/">Open the Yawn Playground</a></p>
</body>
</html>
+38
View File
@@ -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>
+79
View File
@@ -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);
+104
View File
@@ -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"];
}
+21
View File
@@ -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>
+43
View File
@@ -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);
}
+134
View File
@@ -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();
},
});
}
+72
View File
@@ -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; }
}
+3 -43
View File
@@ -1,6 +1,7 @@
import { YawnCore, RendererError } from "@yawn/core";
import { MeshHandles } from "@yawn/mesh-handles";
import { installCameraRenderDataControls } from "../cookbook/16-camera-render-data.js";
import { installCameraRenderDataControls } from "../shared/camera-controls.js";
import { createWorkerTransport } from "../shared/create-worker-transport.js";
import { loadDemoLoadout } from "./demo-loadouts.js";
import { adaptFxNodeSnapshot } from "@yawn/render-graph-fxnode";
import { createGraphAst } from "@yawn/render-graph-ast";
@@ -38,47 +39,6 @@ const state = {
compiled: {},
telemetry: null,
};
function createWorkerTransport() {
const canvas = document.querySelector("#canvas0");
const dpr = devicePixelRatio;
canvas.width = Math.round(Math.max(1, canvas.clientWidth) * dpr);
canvas.height = Math.round(Math.max(1, canvas.clientHeight) * dpr);
const worker = new Worker(
new URL(
"../../renderer/src/platform/web/worker/mainWorker.js",
import.meta.url,
),
{ type: "module", name: "yawn-renderer" },
);
const abort = new AbortController();
const options = { signal: abort.signal };
const post = (kind, values) =>
worker.postMessage({
type: "window-event",
kind,
values: new Float64Array(values),
});
addEventListener(
"resize",
() =>
post(0, [
Math.max(1, canvas.clientWidth),
Math.max(1, canvas.clientHeight),
devicePixelRatio,
]),
options,
);
const offscreen = canvas.transferControlToOffscreen();
worker.postMessage({ type: "init", canvas: offscreen }, [offscreen]);
return {
worker,
free() {
abort.abort();
},
};
}
function publish(telemetry) {
state.telemetry = telemetry;
document.documentElement.dataset.yawnState = JSON.stringify({
@@ -238,7 +198,7 @@ const pagehide = () => {
async function start() {
addEventListener("pagehide", pagehide, { once: true });
delete document.documentElement.dataset.yawnReady;
const transport = createWorkerTransport();
const transport = createWorkerTransport(document.querySelector("#canvas0"));
renderer = new YawnCore(transport);
meshHandles = new MeshHandles(renderer);
gltfImporter = new GltfImporter(renderer);
@@ -1,8 +1,7 @@
const MIN_DISTANCE = 0.1;
const MAX_PITCH = Math.PI / 2 - 0.01;
// The camera is ordinary render data, not a core API. This example maps browser
// controls straight onto its packed SOA row without sending input messages.
// Browser controls map straight onto the packed camera SOA row without messages.
export function installCameraRenderDataControls(core, canvas) {
const camera = core.array("camera.state");
const abort = new AbortController();
@@ -0,0 +1,42 @@
/** Create the worker bridge expected by YawnCore for one OffscreenCanvas. */
export function createWorkerTransport(canvas) {
if (!(canvas instanceof HTMLCanvasElement)) {
throw new TypeError("canvas must be an HTMLCanvasElement");
}
const dimensions = () => {
const dpr = devicePixelRatio;
return {
dpr,
width: Math.max(1, canvas.clientWidth),
height: Math.max(1, canvas.clientHeight),
};
};
const initial = dimensions();
canvas.width = Math.round(initial.width * initial.dpr);
canvas.height = Math.round(initial.height * initial.dpr);
const worker = new Worker(
new URL(
"../../renderer/src/platform/web/worker/mainWorker.js",
import.meta.url,
),
{ type: "module", name: "yawn-renderer" },
);
const resize = () => {
const { dpr, width, height } = dimensions();
worker.postMessage({
type: "window-event",
kind: 0,
values: new Float64Array([width, height, dpr]),
});
};
const abort = new AbortController();
addEventListener("resize", resize, { signal: abort.signal });
const offscreen = canvas.transferControlToOffscreen();
worker.postMessage({ type: "init", canvas: offscreen }, [offscreen]);
return {
worker,
free() {
abort.abort();
},
};
}