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
+2 -2
View File
@@ -2,5 +2,5 @@ services:
yawn-examples:
command: npm run examples
portal:
title: Yawn examples
description: Example index and WebGPU render graph studio with hot reload.
title: Yawn docs and playgrounds
description: VitePress package tutorials and isolated WebGPU playgrounds with hot reload.
+1
View File
@@ -46,6 +46,7 @@ target/
# WASM build artifacts
.rsw/
static/level-editor/
docs/.vitepress/cache/
# Amp runtime artifacts
.amp/in/
+2 -1
View File
@@ -1,5 +1,6 @@
# Build, Lint, and Test Commands
- `npm run examples`: Start the examples index with Vite and WASM hot reload
- `npm run examples`: Start VitePress docs and WebGPU playgrounds with WASM hot reload
- `npm run docs:build`: Build the VitePress documentation into `dist/docs/`
- `npm run build`: Build optimized WASM and JS in `dist/` for development
- `npm run build-release`: Build optimized WASM and JS for production
- `cargo check`: Validate Rust sources quickly before full builds
+5 -7
View File
@@ -43,13 +43,11 @@ loadouts, lifecycle, and transient resource management; conveniences live outsid
- `addons/gltf-import` — glTF worker that writes format-neutral render-data packets directly to a fixed SOA.
- `addons/mesh-handles` — conventional mesh, instance, camera, and material objects plus optional BVH picking.
The integration example in `examples/render-graph-studio` consumes every package
through its public API; no example source or shader lives in core.
The focused recipes in `examples/cookbook` show each addon independently, including
AST/JSO/FXNode authoring, external render and compute programs, graph activation,
shared glTF import, mesh instances, custom SOA columns, SAB animation, picking, and
worker-to-worker use.
The editable playground and Render Graph Studio under `examples/` consume the
packages through their public APIs; no example source or shader lives in core.
Tutorial-style package guides and all focused recipes live under `docs/`, including
AST/JSO/FXNode authoring, render and compute programs, graph activation, shared glTF
import, mesh instances, custom SOA columns, SAB animation, picking, and worker use.
Example graph authoring:
+67
View File
@@ -0,0 +1,67 @@
import { defineConfig } from "vitepress";
export default defineConfig({
title: "Yawn",
description: "Worker-native WebGPU rendering with shared render data.",
base: "/docs/",
outDir: "../dist/docs",
cleanUrls: true,
head: [
["meta", { name: "theme-color", content: "#0d1117" }],
["link", { rel: "icon", href: "data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 64 64%22><rect width=%2264%22 height=%2264%22 rx=%2214%22 fill=%22%230d1117%22/><path d=%22M13 14h10l9 17 9-17h10L37 40v11H27V40z%22 fill=%22%23ed7946%22/></svg>" }],
],
themeConfig: {
logo: {
light: "data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 42 42%22><rect width=%2242%22 height=%2242%22 rx=%229%22 fill=%22%2311161e%22/><path d=%22M8 9h7l6 11 6-11h7l-9 17v7h-8v-7z%22 fill=%22%23ed7946%22/></svg>",
dark: "data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 42 42%22><rect width=%2242%22 height=%2242%22 rx=%229%22 fill=%22%2311161e%22/><path d=%22M8 9h7l6 11 6-11h7l-9 17v7h-8v-7z%22 fill=%22%23ed7946%22/></svg>",
},
nav: [
{ text: "Learn", link: "/guide/first-scene" },
{ text: "Packages", link: "/packages/" },
{ text: "Recipes", link: "/recipes/" },
{ text: "Playground", link: "/../playground/" },
],
sidebar: [
{
text: "Get started",
items: [
{ text: "Your first scene", link: "/guide/first-scene" },
{ text: "How Yawn fits together", link: "/guide/architecture" },
],
},
{
text: "Package tutorials",
items: [
{ text: "Package map", link: "/packages/" },
{ text: "Core and render data", link: "/packages/core" },
{ text: "Render graph frontends", link: "/packages/render-graph" },
{ text: "glTF import worker", link: "/packages/gltf-import" },
{ text: "Conventional handles", link: "/packages/mesh-handles" },
],
},
{
text: "Recipes",
items: [
{ text: "All recipes", link: "/recipes/" },
{ text: "Graph authoring", link: "/recipes/graph-authoring" },
{ text: "Pipelines and loadouts", link: "/recipes/pipelines" },
{ text: "Assets and render data", link: "/recipes/render-data" },
{ text: "Runtime interaction", link: "/recipes/runtime" },
],
},
],
socialLinks: [
{ icon: "github", link: "https://github.com/heaust-ops/yawn" },
],
search: { provider: "local" },
outline: { level: [2, 3] },
editLink: {
pattern: "https://github.com/heaust-ops/yawn/edit/feat/core/docs/:path",
text: "Edit this page on GitHub",
},
footer: {
message: "Core owns render data and render graphs. Addons own conveniences.",
copyright: "Yawn is pre-1.0 software.",
},
},
});
+31
View File
@@ -0,0 +1,31 @@
<script setup>
import { computed } from "vue";
const props = defineProps({
id: { type: String, required: true },
title: { type: String, required: true },
description: { type: String, default: "Run this recipe against the real Yawn worker." },
});
const query = computed(() => encodeURIComponent(props.id));
</script>
<template>
<section class="yawn-playground">
<div class="yawn-playground__header">
<div>
<span>LIVE PLAYGROUND</span>
<strong>{{ title }}</strong>
<p>{{ description }}</p>
</div>
<a :href="`/playground/?recipe=${query}`">Edit and run </a>
</div>
<ClientOnly>
<iframe
:src="`/playground/runner.html?recipe=${query}&embed=1`"
:title="`${title} live preview`"
loading="lazy"
/>
</ClientOnly>
</section>
</template>
+90
View File
@@ -0,0 +1,90 @@
:root {
--vp-c-brand-1: #d86535;
--vp-c-brand-2: #ee7946;
--vp-c-brand-3: #f1956d;
--vp-c-brand-soft: rgba(224, 104, 54, 0.14);
--vp-home-hero-name-color: transparent;
--vp-home-hero-name-background: linear-gradient(110deg, #ef7b47, #f5bc75);
--vp-home-hero-image-background-image: radial-gradient(circle, #da633950 0%, transparent 68%);
--vp-home-hero-image-filter: blur(44px);
}
.dark {
--vp-c-bg: #0a0d12;
--vp-c-bg-alt: #0f141c;
--vp-c-bg-soft: #121821;
--vp-c-bg-elv: #151c26;
--vp-c-divider: #27303c;
--vp-code-block-bg: #0b1017;
}
.VPNavBarTitle .title { letter-spacing: 0.08em; }
.VPHomeHero .name { letter-spacing: -0.045em; }
.VPHomeHero .text { max-width: 760px; letter-spacing: -0.04em; }
.VPHomeHero .tagline { max-width: 610px; }
.VPFeature { border-color: var(--vp-c-divider); }
.yawn-playground {
margin: 28px 0;
overflow: hidden;
border: 1px solid var(--vp-c-divider);
border-radius: 12px;
background: #080b10;
box-shadow: 0 18px 55px #0003;
}
.yawn-playground__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 16px 18px;
border-bottom: 1px solid #26303c;
background: #111720;
}
.yawn-playground__header > div { display: grid; gap: 3px; }
.yawn-playground__header span {
color: #ef7b47;
font: 700 10px ui-monospace, monospace;
letter-spacing: 0.12em;
}
.yawn-playground__header strong { color: #f1f4f8; font-size: 15px; }
.yawn-playground__header p { margin: 0; color: #8f9bac; font-size: 12px; }
.yawn-playground__header a {
flex: none;
padding: 8px 11px;
border: 1px solid #e47748;
border-radius: 6px;
color: #fff;
background: #c65d2f;
font-size: 12px;
font-weight: 700;
text-decoration: none;
}
.yawn-playground iframe { display: block; width: 100%; height: 360px; border: 0; }
.package-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin: 24px 0;
}
.package-grid > a {
display: block;
padding: 18px;
border: 1px solid var(--vp-c-divider);
border-radius: 10px;
color: var(--vp-c-text-1);
background: var(--vp-c-bg-soft);
text-decoration: none;
}
.package-grid > a:hover { border-color: var(--vp-c-brand-1); }
.package-grid strong { display: block; margin-bottom: 4px; }
.package-grid span { color: var(--vp-c-text-2); font-size: 13px; }
@media (max-width: 640px) {
.package-grid { grid-template-columns: 1fr; }
.yawn-playground__header { align-items: flex-start; flex-direction: column; }
.yawn-playground iframe { height: 280px; }
}
+10
View File
@@ -0,0 +1,10 @@
import DefaultTheme from "vitepress/theme";
import Playground from "./Playground.vue";
import "./custom.css";
export default {
extends: DefaultTheme,
enhanceApp({ app }) {
app.component("Playground", Playground);
},
};
+40
View File
@@ -0,0 +1,40 @@
# How Yawn fits together
Yawn has two public boundaries: **worker communication** for small, infrequent operations and **shared render data** for values that change often. Everything else is an authoring or convenience layer outside core.
```text
JSO / fluent builder ─┐
├──▶ canonical DAG AST ─▶ S-expression ─▶ render worker
FXNode snapshot ─────┘ │
├─ graph compiler
glTF import worker ───── shared upload array ────────────────────┤
├─ transient allocator
any browser thread ─── lifecycle messages ──────────────────────┤
any browser thread ─── atomic SOA writes ────────────────────────┘
```
## What core owns
`@yawn/core` owns only the protocol client for render data and render graphs. The worker behind it owns graph validation, loadout preparation, transient lifetime analysis, GPU allocation, and rendering.
Core does **not** own a camera module, scene object model, glTF parser, shader library, editor, or picking system. Camera and material values are ordinary render-data columns. Higher-level objects are optional addon views over those columns.
## The graph is the program
Every frontend must produce `@yawn/render-graph-ast` data. A node is named, while an input contains one or more `{ node, socket }` references. Reusing the same reference creates fan-out, so the format describes a DAG instead of duplicating a tree.
Render and compute pipeline declarations are part of that AST. Their WGSL and state are compiled into a prepared loadout, not linked into core.
## The SOA is the mutable scene
Shared arrays are 64-byte aligned and each row stride is a multiple of 16 bytes. The standard columns cover mesh, instance, camera, and material data. Applications can request additional mesh-, instance-, or fixed-domain arrays; domain arrays grow with the corresponding render-data capacity.
Lifecycle operations such as allocating a column, importing an asset, compiling a graph, or creating an instance cross the command boundary. A frame-rate transform, camera, classification, or material update writes the existing SAB row directly.
## Thread placement is a choice
The JS client only requires a Worker-like endpoint. A main thread can own it, or another worker can connect through a `MessagePort`. Shared descriptors can be passed to additional workers, which can then read or write the same SOA data without proxying every update through the main thread.
::: warning Cross-origin isolation is required
Serve the application with `Cross-Origin-Opener-Policy: same-origin` and `Cross-Origin-Embedder-Policy: require-corp`. `npm run examples` supplies both headers.
:::
+84
View File
@@ -0,0 +1,84 @@
# Your first scene
This tutorial composes Yawn the same way an application does: create the worker transport, wait for shared render data, compile a graph, import an asset, and activate the prepared loadout.
## 1. Start core
Create one renderer worker and transfer an `OffscreenCanvas` to it. `YawnCore` is deliberately transport-oriented; your bootstrap owns canvas sizing and worker construction.
```js
import { YawnCore } from "@yawn/core";
const canvas = document.querySelector("canvas");
canvas.width = Math.round(canvas.clientWidth * devicePixelRatio);
canvas.height = Math.round(canvas.clientHeight * devicePixelRatio);
const offscreen = canvas.transferControlToOffscreen();
const worker = new Worker(new URL("./render-worker.js", import.meta.url), {
type: "module",
});
const core = new YawnCore({ worker });
worker.postMessage({ type: "init", canvas: offscreen }, [offscreen]);
await core.ready;
```
`ready` resolves after core receives the standard SOA descriptors. From that point, `core.array("camera.state")` and the other built-in columns are safe to access.
## 2. Compile a graph
The optional default-pipelines addon supplies scene WGSL. A JSO graph places those declarations beside graph nodes, then the graph addon serializes the canonical AST for core.
```js
import { loadGraph } from "@yawn/render-graph-js";
import { defaultPipelines } from "@yawn/default-pipelines";
const graph = {
id: "main",
revision: 1,
pipelines: defaultPipelines,
nodes: completeSceneNodes,
};
const compiled = await loadGraph(core, graph);
```
Compilation validates the DAG, removes dead work, computes transient resource lifetimes, aliases compatible resources, and allocates the resulting loadout before returning its ID.
## 3. Import render data
The glTF addon fetches and parses in its own worker. It asks core for a fixed shared upload array, writes the packet into that SAB, then sends only the array ID and byte count for the commit.
```js
import { GltfImporter } from "@yawn/gltf-import";
import { MeshHandles } from "@yawn/mesh-handles";
const importer = new GltfImporter(core);
const result = await importer.load("/assets/scene.glb");
const handles = new MeshHandles(core);
const meshes = handles.fromImportedScene(result);
importer.dispose();
```
## 4. Activate the loadout
Switching is transactional from the application's perspective: the previously active loadout keeps rendering until the prepared graph becomes active.
```js
await core.switchCompiledGraph(compiled.compiledId);
meshes[0].defaultInstance.setTransform(nextTransform); // direct SAB write
```
Use messages for setup and teardown. Use shared writes for values that are already present and can change every frame.
<Playground
id="first-scene"
title="Complete first scene"
description="Open the editor to change the procedural loadout or inspect live telemetry."
/>
## Next steps
- Learn why these boundaries exist in [How Yawn fits together](./architecture).
- Author graphs with [plain objects, a fluent builder, or FXNode](../packages/render-graph).
- Add custom shared columns in [Core and render data](../packages/core).
- Use familiar objects in [Conventional handles](../packages/mesh-handles).
+35
View File
@@ -0,0 +1,35 @@
---
layout: home
hero:
name: Yawn
text: Build the graph. Share the data.
tagline: A worker-native WebGPU renderer where infrequent lifecycle commands use messages and hot render data lives in SIMD-aligned shared memory.
actions:
- theme: brand
text: Build your first scene
link: /guide/first-scene
- theme: alt
text: Open the playground
link: /../playground/
features:
- title: One graph boundary
details: JSO, a fluent builder, and FXNode all export the same immutable DAG AST and S-expression wire format.
- title: Shared render data
details: Meshes, instances, camera state, materials, and user columns use aligned SOA rows backed by shared WASM memory.
- title: External programs
details: WGSL, render pipelines, and compute passes travel with a graph loadout; core ships no scene shader.
- title: Worker-native
details: The same core client runs on the browser main thread or another worker through a Worker-like endpoint.
- title: Up-front loadouts
details: Graph compilation culls dead work, aliases compatible transients, coalesces passes, and prepares resources before activation.
- title: Optional conveniences
details: glTF import, mesh handles, material properties, camera controls, and picking stay in focused addons.
---
<Playground
id="first-scene"
title="Your first Yawn scene"
description="The preview imports procedural glTF through a worker, activates a graph, and renders shared instance data."
/>
+66
View File
@@ -0,0 +1,66 @@
# Core and render data
`@yawn/core` is a protocol client. It manages the command ring, payload handshakes, graph lifecycle, and typed views over shared render-data arrays.
## Fast-path shared writes
Standard instance APIs validate `[slot, generation]`, then write the corresponding guarded SOA row. They do not enqueue a renderer command.
```js
core.setInstanceTransform(instanceHandle, matrix);
core.setInstanceType(instanceHandle, sixteenU32Words);
```
The convenience `Instance` methods in `@yawn/mesh-handles` call exactly these APIs.
<Playground
id="shared-animation"
title="Direct shared-memory animation"
description="A requestAnimationFrame loop updates one instance transform without per-frame messages."
/>
## Request an SOA column
Array creation is intentionally an infrequent worker command. Choose a domain so core can keep the array's logical length synchronized with fixed, mesh, or instance capacity.
```js
const velocity = await core.allocateArray({
name: "instance.velocity",
domain: "instance",
scalar: "f32",
lanes: 4,
});
velocity.write(instanceHandle[0], [1, 0, 0, 0]);
```
Every stride is a multiple of 16 bytes, keeping rows suitable for vectorized consumers. `SharedSoaArray` uses atomic lane access and refreshes its typed views when shared WASM memory grows.
<Playground
id="custom-soa"
title="Application-owned velocity rows"
description="Allocate an instance-domain column and populate one SIMD-width row per live instance."
/>
## Share a column with another worker
Use `share()` only during setup. It returns the shared backing buffer and wire descriptor needed to construct a compatible view in another package or worker.
```js
const { buffer, descriptor } = velocity.share();
simulationWorker.postMessage({ type: "velocity-layout", buffer, descriptor });
```
The `SharedArrayBuffer` is shared, not transferred. Once installed, the simulation worker should mutate rows directly and reserve messages for layout or lifecycle changes.
## Graph lifecycle
Core accepts one graph format: the serialized S-expression produced by `@yawn/render-graph-ast`.
```js
const compiled = await core.compileGraph(serializedAst);
await core.switchCompiledGraph(compiled.compiledId);
await core.dropCompiledGraph(oldCompiledId);
```
Graph operations are serialized by the client so compile, switch, and drop cannot race each other on one core instance.
+55
View File
@@ -0,0 +1,55 @@
# glTF import worker
`@yawn/gltf-import` keeps parsing and bulk upload off the renderer command channel. It fetches a `.gltf` or `.glb` URL in a dedicated worker and writes a format-neutral packet directly into shared memory.
## Load a scene
```js
import { GltfImporter } from "@yawn/gltf-import";
const importer = new GltfImporter(core);
try {
const result = await importer.load("/models/level.glb");
console.log(result.meshes, result.materials, result.bounds);
} finally {
importer.dispose();
}
```
The import handshake is:
1. The import worker fetches and measures the asset packet.
2. Core allocates a fixed `upload.renderData` shared array.
3. The import worker writes packet bytes into that SAB.
4. Core receives only the array ID and byte count, then installs render data.
No GLB payload is copied through the renderer's message queue.
<Playground
id="gltf-worker"
title="Worker-side glTF import"
description="A generated GLB is fetched through an object URL and committed from shared upload memory."
/>
## Camera framing
Import frames the canonical `camera.state` row from scene bounds by default. Select an exterior or interior framing policy, or preserve the current camera.
```js
await importer.load(url, { framing: "exterior" });
await importer.load(url, { framing: "interior" });
await importer.load(url, { framing: false });
```
Framing is an addon behavior implemented as a shared camera-row write. It is not a camera subsystem in core.
## Wrap the result when useful
Import returns protocol descriptors. Less technical consumers can turn those descriptors into generation-safe objects.
```js
import { MeshHandles, MaterialHandles } from "@yawn/mesh-handles";
const meshes = new MeshHandles(core).fromImportedScene(result);
const materials = new MaterialHandles(core).fromImportedScene(result);
```
+26
View File
@@ -0,0 +1,26 @@
# Package map
Install only the authoring and convenience layers your application needs. None of the addons is required by core's protocol.
<div class="package-grid">
<a href="./core"><strong>@yawn/core</strong><span>Worker commands, render-graph lifecycle, and shared SOA arrays.</span></a>
<a href="./render-graph"><strong>@yawn/render-graph-*</strong><span>Canonical AST plus JSO, fluent, and FXNode frontends.</span></a>
<a href="./gltf-import"><strong>@yawn/gltf-import</strong><span>Worker-side glTF parsing directly into shared upload memory.</span></a>
<a href="./mesh-handles"><strong>@yawn/mesh-handles</strong><span>Generation-safe mesh, instance, camera, material, and picking facades.</span></a>
<a href="../recipes/pipelines"><strong>@yawn/default-pipelines</strong><span>Optional scene WGSL and render/compute declarations.</span></a>
<a href="/playground/"><strong>Examples</strong><span>Editable playgrounds that compose the public packages as an application would.</span></a>
</div>
## Dependency direction
Applications create core first, then pass the same `YawnCore` instance to addons. Addons use public commands and shared descriptors; core never imports an addon.
```text
application ─▶ graph frontend ─▶ graph AST
│ │
├────▶ glTF / handles addons │
│ │ │
└─────────────┴───────────────▶ core ─▶ render worker
```
This keeps scene policy outside the renderer. You can replace default pipelines, skip conventional handles, or author the AST directly without forking core.
+59
View File
@@ -0,0 +1,59 @@
# Conventional handles
`@yawn/mesh-handles` is an optional object-oriented facade. It never hides core: lifecycle methods call core commands, while frequent mutations write shared rows.
## Meshes and instances
Wrap imported descriptors, use the default instance created by glTF, or create another generation-safe instance.
```js
const handles = new MeshHandles(core);
const [mesh] = handles.fromImportedScene(imported);
mesh.defaultInstance.setTransform(matrix);
const duplicate = await mesh.createInstance(otherMatrix);
duplicate.setType(classificationWords);
await duplicate.destroy();
```
The handle is `[slot, generation]`. A stale object cannot modify a slot that has since been reused.
## Camera and material handles
The camera and materials look conventional, but property updates are writes to `camera.state` and `material.state`.
```js
const camera = new CameraHandle(core);
camera.lookAt([4, 3, 6], [0, 0, 0]);
const materials = new MaterialHandles(core).fromImportedScene(imported);
materials[0].baseColor = [0.2, 0.55, 1, 1];
materials[0].roughness = 0.35;
```
<Playground
id="conventional-handles"
title="Camera and material properties"
description="The gallery is reframed and one PBR row is restyled with direct shared-memory writes."
/>
## Worker-side picking
Picking is lazy. The first `pickRay` starts a separate spatial-query worker over versioned render-data snapshots and returns wrapped instance handles.
```js
const result = await handles.pickRay(origin, direction, {
maxDistance: 10_000,
maxHits: 1,
});
const picked = result.hits[0]?.instance;
```
<Playground
id="picking"
title="Pick the closest shared instance"
description="Build the optional BVH and issue a ray query without adding picking code to core."
/>
Call `handles.dispose()` when the scene ends so its optional picking worker and listeners are released.
+78
View File
@@ -0,0 +1,78 @@
# Render graph frontends
Every frontend ends at `@yawn/render-graph-ast`. Choose the authoring style that fits your tooling; the worker receives the same S-expression either way.
## Plain-object authoring
`graphFromObject` validates and freezes ordinary JavaScript data. Pipeline declarations, compute dispatches, nodes, and DAG references all become canonical AST fields.
```js
import { graphFromObject } from "@yawn/render-graph-js";
const graph = graphFromObject({
id: "main",
revision: 1,
pipelines: { render: [scenePipeline], compute: [preparePipeline] },
nodes: [
{
id: "mesh",
state: "enabled",
executor: { key: "mesh", version: 2 },
parameters: {},
inputs: {},
},
],
});
```
<Playground
id="jso-graph"
title="A complete JSO graph"
description="The playground compiles a plain object through AST serialization and activates the returned loadout."
/>
## Fluent authoring
Use `RenderGraph` when a small mutable builder makes generated graphs easier to read. Calling `ast()` is the immutable boundary.
```js
import { RenderGraph, ref } from "@yawn/render-graph-js";
const graph = new RenderGraph("generated", 1)
.renderPipeline(scenePipeline)
.node("source", "mesh", { version: 2 })
.node("draw", "scene", {
version: 2,
inputs: { mesh: [ref("source", "mesh")] },
});
const compiled = await graph.load(core);
```
## FXNode export
`@yawn/render-graph-fxnode` translates editor snapshots into the same AST. It owns editor catalog versions and diagnostic mapping; no FXNode shape crosses into core.
```js
import { adaptFxNodeSnapshot } from "@yawn/render-graph-fxnode";
const ast = adaptFxNodeSnapshot(snapshot, revision, {
pipelines: myPipelines,
});
```
Open the <a href="/render-graph-studio/">Render Graph Studio</a> to edit an FXNode graph, compile it beside the JSO preset, and switch prepared loadouts.
## DAG references
A reference is data, not a nested expression. Point several consumers at one output to represent fan-out without repeating the source node.
```js
import { reference } from "@yawn/render-graph-ast";
const shared = reference("sceneColor", "texture");
left.inputs.color = [shared];
right.inputs.color = [shared];
```
The serializer emits `(ref "sceneColor" "texture")` wherever that edge is consumed.
+82
View File
@@ -0,0 +1,82 @@
# Graph authoring recipes
All three authoring styles produce the same canonical immutable AST.
## 01 — Canonical DAG AST
Create references separately from nodes. Reusing `shared` makes one output fan out to two consumers.
```js
import { createGraphAst, reference, serializeGraphAst } from "@yawn/render-graph-ast";
const expression = (id, inputs = {}) => ({
id,
state: "enabled",
executor: { key: "and", version: 2 },
parameters: {},
inputs,
});
const shared = reference("source", "value");
const ast = createGraphAst({
id: "shared_dag",
revision: 1,
nodes: [
expression("source"),
expression("left", { inputs: [shared] }),
expression("right", { inputs: [shared] }),
],
});
const source = serializeGraphAst(ast);
```
## 02 — Plain JavaScript object graph
Let `@yawn/render-graph-js` canonicalize an ordinary object when application code does not need to manipulate AST internals.
```js
import { graphFromObject } from "@yawn/render-graph-js";
const graph = graphFromObject({
id: "jso_graph",
revision: 1,
nodes: [{
id: "mesh",
state: "enabled",
executor: { key: "mesh", version: 2 },
parameters: {},
inputs: {},
}],
});
```
<Playground id="jso-graph" title="Compile a complete JSO graph" />
## 03 — Fluent graph builder
Use the chainable facade for generated graphs, then call `ast()` or `load(core)` at the boundary.
```js
import { RenderGraph, ref } from "@yawn/render-graph-js";
const ast = new RenderGraph("fluent_graph", 1)
.node("source", "and", { version: 2 })
.node("consumer", "not", {
inputs: { operand: [ref("source", "value")] },
})
.ast();
```
## 04 — Export an FXNode snapshot
Keep editor schemas in the FXNode addon. Attach external pipelines during export so the resulting AST is a self-contained loadout description.
```js
import { defaultPipelines } from "@yawn/default-pipelines";
import { adaptFxNodeSnapshot } from "@yawn/render-graph-fxnode";
const ast = adaptFxNodeSnapshot(snapshot, 1, {
pipelines: defaultPipelines,
});
```
Use the <a href="/render-graph-studio/">Render Graph Studio</a> for the interactive FXNode version of this recipe.
+12
View File
@@ -0,0 +1,12 @@
# Recipes
The old source-only cookbook now lives here as guided, copyable snippets. Open an attached playground when you want a complete browser context and live renderer.
<div class="package-grid">
<a href="./graph-authoring"><strong>0104 · Graph authoring</strong><span>Canonical AST, JSO, fluent builder, and FXNode export.</span></a>
<a href="./pipelines"><strong>0508 · Pipelines and loadouts</strong><span>Default programs, custom render/compute WGSL, and activation.</span></a>
<a href="./render-data"><strong>0911 · Assets and render data</strong><span>glTF import, mesh instances, and custom SOA columns.</span></a>
<a href="./runtime"><strong>1217 · Runtime interaction</strong><span>SAB animation, picking, worker clients, scenes, camera, and materials.</span></a>
</div>
Recipes 0107 intentionally demonstrate graph fragments. A renderable loadout also needs compatible resource, scene, and frame-output nodes. Recipe 15 and the playgrounds show complete composition.
+101
View File
@@ -0,0 +1,101 @@
# Pipeline and loadout recipes
WGSL belongs to a graph package or your application. Core contains no scene program.
## 05 — Attach the default pipelines
The optional package exports plain declarations, so copy only the programs your graph uses.
```js
import { defaultPipelines } from "@yawn/default-pipelines";
import { RenderGraph } from "@yawn/render-graph-js";
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);
}
const ast = graph.ast();
```
## 06 — Supply a custom render pipeline
Put source and entry points in the graph declaration. The shader must honor the scene ABI expected by the executor that uses it.
```js
const shader = /* 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);
}`;
const ast = new RenderGraph("custom_render_program", 1)
.renderPipeline({
name: "scene",
shader,
vertexEntry: "vertex_main",
fragmentEntry: "fragment_main",
doubleSided: false,
})
.ast();
```
## 07 — Supply a compute pipeline
Dispatch dimensions are graph data and are allocated with the rest of the loadout.
```js
const shader = /* wgsl */ `
@compute @workgroup_size(8, 1, 1)
fn initialize() {}
`;
const ast = new RenderGraph("compute_program", 1)
.computePipeline({
name: "initialize",
shader,
entry: "initialize",
dispatch: [4, 1, 1],
})
.ast();
```
## 08 — Compile and switch
Compile first, then activate the prepared ID. Drop a candidate when your surrounding transaction fails.
```js
import { loadGraph } from "@yawn/render-graph-js";
const compiled = await loadGraph(core, graph);
try {
await core.switchCompiledGraph(compiled.compiledId);
} catch (error) {
await core.dropCompiledGraph(compiled.compiledId).catch(() => {});
throw error;
}
```
<Playground
id="jso-graph"
title="Compile and activate a pipeline loadout"
description="The full preset contains external render/compute declarations and a transient resource graph."
/>
+54
View File
@@ -0,0 +1,54 @@
# Asset and render-data recipes
Bulk data moves through shared storage. Small descriptors and lifecycle decisions move through messages.
## 09 — Import glTF in a worker
```js
import { GltfImporter } from "@yawn/gltf-import";
import { MeshHandles } from "@yawn/mesh-handles";
const importer = new GltfImporter(core);
try {
const imported = await importer.load(url);
const meshes = new MeshHandles(core).fromImportedScene(imported);
} finally {
importer.dispose();
}
```
<Playground id="gltf-worker" title="Shared-memory glTF import" />
## 10 — Create and mutate mesh instances
Creating or destroying an instance is lifecycle communication. Mutating an existing transform or type is a generation-guarded shared write.
```js
const identity = [
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1,
];
const instance = await mesh.createInstance(identity);
instance.setTransform(nextTransform);
instance.setType(sixteenU32Words);
```
## 11 — Add a custom SOA column
Select the instance domain to keep row count aligned with instance capacity. Use four lanes for one SIMD-width velocity row.
```js
const velocity = await core.allocateArray({
name: "instance.velocity",
domain: "instance",
scalar: "f32",
lanes: 4,
});
velocity.write(instance.handle[0], [x, y, z, 0]);
```
<Playground id="custom-soa" title="Allocate instance velocity data" />
+91
View File
@@ -0,0 +1,91 @@
# Runtime interaction recipes
Once render data exists, keep hot updates on shared rows and leave core free of application policy.
## 12 — Animate directly through the SAB
The instance facade performs the live-generation check and writes `instance.transform`.
```js
function frame(time) {
instance.setTransform(rotationY(time * 0.001));
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
```
<Playground id="shared-animation" title="Frame-rate transform writes" />
## 13 — Pick through the optional BVH worker
```js
const result = await meshHandles.pickRay(origin, direction, {
maxDistance: 10_000,
maxHits: 1,
});
const nearest = result.hits[0]?.instance;
```
The picking addon consumes versioned shared snapshots. Core does not know about rays or BVHs.
<Playground id="picking" title="Pick a shared instance" />
## 14 — Connect worker to worker
`MessagePort` implements the Worker-like methods the core client needs. Start it through the normal `YawnCore` constructor.
```js
import { YawnCore } from "@yawn/core";
const core = new YawnCore({
worker: port,
memory,
ringPtr,
free: () => port.close(),
});
await core.ready;
```
This is why “main thread” is not an architectural role in Yawn: any browser worker can own the client.
## 15 — Compose a complete scene
Use one core instance for every addon and activate the graph only after its complete loadout has compiled.
```js
const importer = new GltfImporter(core);
const imported = await importer.load(gltfUrl);
const meshes = new MeshHandles(core).fromImportedScene(imported);
const compiled = await loadGraph(core, completeGraph);
await core.switchCompiledGraph(compiled.compiledId);
```
<Playground id="first-scene" title="Complete addon composition" />
## 16 — Treat camera input as render data
There is no camera API in core. Read and write the canonical 16-lane row directly from controls or simulation code.
```js
const camera = core.array("camera.state");
const state = camera.read(0);
state.splice(0, 3, ...nextEye);
camera.write(0, state);
```
The row packs eye, target, up, field of view, aspect, near, and far values into 64 bytes.
## 17 — Use conventional camera and material properties
Choose addon handles when a property-oriented workflow is more useful than raw SOA rows.
```js
const camera = new CameraHandle(core);
const materials = new MaterialHandles(core).fromImportedScene(imported);
camera.lookAt([4, 3, 6], [0, 0, 0]);
materials[0].baseColor = [0.2, 0.55, 1, 1];
materials[0].roughness = 0.35;
```
<Playground id="conventional-handles" title="Camera and material handles" />
+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();
},
};
}
+2471 -8
View File
File diff suppressed because it is too large Load Diff
+9 -4
View File
@@ -10,16 +10,20 @@
],
"scripts": {
"examples": "run-s rsw:build examples:watch",
"examples:watch": "run-p rsw:watch examples:vite",
"examples:watch": "run-p rsw:watch examples:vite docs:vite",
"examples:vite": "vite dev",
"docs": "npm run examples",
"docs:vite": "vitepress dev docs --host 127.0.0.1 --port 5174",
"docs:build": "vitepress build docs",
"docs:preview": "vitepress preview docs --host 127.0.0.1 --port 5174",
"rsw:watch": "RUSTFLAGS='-C target-feature=+atomics,+bulk-memory,+mutable-globals -Clink-args=--shared-memory -Clink-args=--max-memory=1073741824 -Clink-args=--import-memory -Clink-args=--export=__wasm_init_tls -Clink-args=--export=__tls_size -Clink-args=--export=__tls_align -Clink-args=--export=__tls_base' rsw watch",
"rsw:build": "RUSTFLAGS='-C target-feature=+atomics,+bulk-memory,+mutable-globals -Clink-args=--shared-memory -Clink-args=--max-memory=1073741824 -Clink-args=--import-memory -Clink-args=--export=__wasm_init_tls -Clink-args=--export=__tls_size -Clink-args=--export=__tls_align -Clink-args=--export=__tls_base' rsw build",
"wasm-dev": "RUSTFLAGS='-C target-feature=+atomics,+bulk-memory,+mutable-globals -Clink-args=--shared-memory -Clink-args=--max-memory=1073741824 -Clink-args=--import-memory -Clink-args=--export=__wasm_init_tls -Clink-args=--export=__tls_size -Clink-args=--export=__tls_align -Clink-args=--export=__tls_base' wasm-pack build --dev --target web renderer -- --features atomics,bulk-memory -Z build-std=panic_abort,std",
"wasm-release": "RUSTFLAGS='-C target-feature=+atomics,+bulk-memory,+mutable-globals -Clink-args=--shared-memory -Clink-args=--max-memory=1073741824 -Clink-args=--import-memory -Clink-args=--export=__wasm_init_tls -Clink-args=--export=__tls_size -Clink-args=--export=__tls_align -Clink-args=--export=__tls_base' wasm-pack build --release --target web renderer -- --features atomics,bulk-memory -Z build-std=panic_abort,std",
"bundle-dev": "vite build --mode development",
"bundle-release": "vite build",
"build": "run-s clean wasm-dev bundle-dev",
"build-release": "run-s clean wasm-release bundle-release",
"build": "run-s clean wasm-dev bundle-dev docs:build",
"build-release": "run-s clean wasm-release bundle-release docs:build",
"test:js": "node --test tests/*.test.js",
"start": "vite preview",
"clean": "rimraf --glob dist **/pkg",
@@ -32,6 +36,7 @@
"rollup-plugin-copy": "^3.5.0",
"vite": "^7.1.10",
"vite-plugin-rsw": "^2.0.11",
"vite-plugin-wasm": "^3.3.0"
"vite-plugin-wasm": "^3.3.0",
"vitepress": "^1.6.4"
}
}
-183
View File
@@ -1,183 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
animateInstance,
canonicalAstExample,
classifyInstance,
compileAndSwitch,
computePipelineExample,
connectFromWorker,
createInstance,
createVelocityColumn,
customRenderPipelineExample,
defaultPipelineExample,
fluentGraphExample,
fxNodeExportExample,
importGltf,
installCameraRenderDataControls,
jsoGraphExample,
loadCompleteScene,
pickNearest,
conventionalSceneHandles,
restyleScene,
setVelocity,
simpleSceneShader,
updateInstance,
} from "../examples/cookbook/index.js";
test("cookbook graph recipes produce canonical addon-owned ASTs", () => {
const canonical = canonicalAstExample();
assert.equal(canonical.ast.id, "shared_dag");
assert.equal(
(canonical.source.match(/\(ref "source" "value"\)/g) ?? []).length,
2,
);
assert.deepEqual(
[jsoGraphExample().id, fluentGraphExample().id],
["jso_graph", "fluent_graph"],
);
const fxnode = fxNodeExportExample();
assert.equal(fxnode.kind, "yawn-render-graph");
assert.equal(fxnode.pipelines.render.length, 4);
const defaults = defaultPipelineExample();
assert.deepEqual(
defaults.pipelines.render.map(({ name }) => name),
[
"ground_plane",
"gltf_standard",
"gltf_standard_double_sided",
"frame_out",
],
);
assert.equal(defaults.pipelines.compute.length, 1);
const custom = customRenderPipelineExample();
assert.equal(custom.pipelines.render[0].shader, simpleSceneShader);
assert.match(simpleSceneShader, /@vertex fn vertex_main/);
const compute = computePipelineExample().pipelines.compute[0];
assert.deepEqual(
[compute.entry, compute.dispatch],
["initialize", [4, 1, 1]],
);
});
test("cookbook graph lifecycle recipe serializes and switches", async () => {
const calls = [];
const core = {
compileGraph(source) {
calls.push(["compile", source]);
return Promise.resolve({ compiledId: [3, 4] });
},
switchCompiledGraph(id) {
calls.push(["switch", id]);
return Promise.resolve();
},
dropCompiledGraph(id) {
calls.push(["drop", id]);
return Promise.resolve();
},
};
const graph = { id: "lifecycle", revision: 1, nodes: [] };
const compiled = await compileAndSwitch(core, graph);
assert.match(calls[0][1], /^\(yawn-graph 1/);
assert.deepEqual(calls.slice(1), [["switch", [3, 4]]]);
});
test("cookbook mutation recipes use handles and SOA writes directly", async () => {
const calls = [];
const column = {
write(slot, values) {
calls.push(["velocity", slot, values]);
},
};
const core = {
allocateArray(layout) {
calls.push(["allocate", layout]);
return Promise.resolve(column);
},
setInstanceTransform(handle, transform) {
calls.push(["transform", handle, transform]);
},
setInstanceType(handle, words) {
calls.push(["type", handle, words]);
},
};
const instance = {
handle: [7, 2],
setTransform(transform) {
calls.push(["wrapped-transform", transform]);
},
setType(words) {
calls.push(["wrapped-type", words]);
},
};
const mesh = {
createInstance(transform) {
calls.push(["create", transform]);
return Promise.resolve(instance);
},
};
const transform = Array.from({ length: 16 }, (_, index) => index);
const words = Array(16).fill(1);
assert.equal(await createInstance(mesh, transform), instance);
updateInstance(instance, transform, words);
const velocity = await createVelocityColumn(core);
setVelocity(velocity, instance, [1, 2, 3]);
animateInstance(core, instance, transform);
classifyInstance(core, instance, words);
assert.deepEqual(calls.find(([name]) => name === "allocate")[1], {
name: "instance.velocity",
domain: "instance",
scalar: "f32",
lanes: 4,
});
assert.deepEqual(
calls.find(([name]) => name === "velocity"),
["velocity", 7, [1, 2, 3, 0]],
);
assert.ok(calls.some(([name]) => name === "transform"));
assert.ok(calls.some(([name]) => name === "type"));
});
test("cookbook picking and worker-to-worker recipes use public facades", async () => {
const picked = await pickNearest(
{
pickRay: async () => ({
epoch: 5,
hits: [{ instance: [9, 3], distance: 2 }],
}),
},
[0, 0, 0],
[0, 0, -1],
);
assert.deepEqual(picked.hits[0].instance, [9, 3]);
class Port extends EventTarget {
postMessage() {}
start() {
queueMicrotask(() =>
this.dispatchEvent(
new MessageEvent("message", {
data: { type: "soa-init", arrays: [] },
}),
),
);
}
terminate() {}
}
const core = await connectFromWorker(new Port());
assert.equal(core.constructor.name, "YawnCore");
core.dispose();
assert.equal(typeof importGltf, "function");
assert.equal(typeof installCameraRenderDataControls, "function");
assert.equal(typeof conventionalSceneHandles, "function");
assert.equal(typeof restyleScene, "function");
assert.equal(typeof loadCompleteScene, "function");
});
+70
View File
@@ -0,0 +1,70 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFile, readdir } from "node:fs/promises";
import path from "node:path";
import {
PLAYGROUND_RECIPES,
playgroundRecipe,
} from "../examples/playground/recipes.js";
const root = path.resolve(import.meta.dirname, "..");
const docsRoot = path.join(root, "docs");
async function markdownFiles(directory) {
const entries = await readdir(directory, { withFileTypes: true });
const nested = await Promise.all(
entries.map((entry) => {
const file = path.join(directory, entry.name);
return entry.isDirectory()
? markdownFiles(file)
: entry.name.endsWith(".md")
? [file]
: [];
}),
);
return nested.flat();
}
test("playground recipes are unique, compilable, and linked to docs", async () => {
const entries = Object.entries(PLAYGROUND_RECIPES);
assert.equal(entries.length, 7);
assert.equal(playgroundRecipe("unknown"), PLAYGROUND_RECIPES["first-scene"]);
const sources = new Set();
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
for (const [id, recipe] of entries) {
assert.match(id, /^[a-z][a-z0-9-]+$/);
assert.ok(!sources.has(recipe.source), `${id} must have unique editable code`);
sources.add(recipe.source);
assert.doesNotThrow(() => new AsyncFunction("yawn", recipe.source));
const relativePage = recipe.docs
.replace(/^\/docs\//, "")
.split("#", 1)[0]
.replace(/\/$/, "/index");
const markdown = await readFile(
path.join(docsRoot, `${relativePage}.md`),
"utf8",
);
assert.ok(markdown.length > 0, `${id} docs page must exist`);
}
});
test("docs contain every former recipe and only link to playground examples", async () => {
const files = await markdownFiles(docsRoot);
const contents = await Promise.all(files.map((file) => readFile(file, "utf8")));
const documentation = contents.join("\n");
for (let number = 1; number <= 17; number++) {
const prefix = String(number).padStart(2, "0");
assert.match(documentation, new RegExp(`^## ${prefix}`, "m"));
}
for (const id of Object.keys(PLAYGROUND_RECIPES)) {
assert.match(documentation, new RegExp(`id=["']${id}["']`));
}
const examplesIndex = await readFile(path.join(root, "examples/index.html"), "utf8");
assert.doesNotMatch(examplesIndex, /cookbook|\.\/[^\s"']+\.js/i);
assert.match(examplesIndex, /\/playground\//);
});
+17 -12
View File
@@ -32,18 +32,8 @@ function freshWasmPackage() {
async buildStart() { await mirror(); },
async writeBundle() {
const wasmBuildDestination = path.resolve(buildOutput, "renderer/pkg");
const cookbookBuildDestination = path.resolve(buildOutput, "cookbook");
await Promise.all([
mkdir(wasmBuildDestination, { recursive: true }),
mkdir(cookbookBuildDestination, { recursive: true }),
]);
await Promise.all([
cp(wasmSource, wasmBuildDestination, { recursive: true, force: true }),
cp(path.resolve(exampleRoot, "cookbook"), cookbookBuildDestination, {
recursive: true,
force: true,
}),
]);
await mkdir(wasmBuildDestination, { recursive: true });
await cp(wasmSource, wasmBuildDestination, { recursive: true, force: true });
},
configureServer(server) {
server.watcher.add(wasmSource);
@@ -63,6 +53,8 @@ export default defineConfig({
rollupOptions: {
input: {
examples: path.resolve(exampleRoot, "index.html"),
playground: path.resolve(exampleRoot, "playground/index.html"),
playgroundRunner: path.resolve(exampleRoot, "playground/runner.html"),
renderGraphStudio: path.resolve(
exampleRoot,
"render-graph-studio/index.html",
@@ -131,6 +123,19 @@ export default defineConfig({
"Cross-Origin-Embedder-Policy": "require-corp",
"Cross-Origin-Opener-Policy": "same-origin",
},
proxy: {
"/docs": {
target: "http://127.0.0.1:5174",
changeOrigin: true,
ws: true,
configure(proxy) {
proxy.on("proxyRes", (response) => {
response.headers["cross-origin-embedder-policy"] = "require-corp";
response.headers["cross-origin-opener-policy"] = "same-origin";
});
},
},
},
fs: {
strict: false,
},
+958 -11
View File
File diff suppressed because it is too large Load Diff