Rewrite core around shared rows and render graphs

Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
Amp-Thread-ID: https://ampcode.com/threads/T-01a01380-b478-77d0-84a0-102880a5c5ae
This commit is contained in:
Amp
2026-08-19 15:30:32 +00:00
co-authored by heaust
parent d34722d66d
commit f12c7c9603
118 changed files with 687 additions and 34500 deletions
+62
View File
@@ -0,0 +1,62 @@
<script setup>
import { onMounted, onUnmounted, ref } from "vue";
import { YawnCore } from "@yawn/core";
import { loadGraph } from "@yawn/render-graph-js";
import { triangleGraph } from "@yawn/default-pipelines";
const canvas = ref();
const status = ref("Starting…");
const failed = ref(false);
let core;
let color;
function move(event) {
if (!color) return;
const bounds = canvas.value.getBoundingClientRect();
const row = color.row(0);
row[0] = (event.clientX - bounds.left) / bounds.width;
row[1] = 1 - (event.clientY - bounds.top) / bounds.height;
}
onMounted(async () => {
try {
if (!crossOriginIsolated) throw new Error("Cross-origin isolation is disabled");
canvas.value.width = 960;
canvas.value.height = 540;
core = new YawnCore(canvas.value);
await core.ready;
color = await core.allocateRows({
name: "triangle.color",
rows: 1,
stride: 16,
format: "f32",
});
color.write(0, [0.2, 0.65, 1, 1]);
await loadGraph(core, triangleGraph());
window.__yawnPlayground = { core, color };
status.value = "Running · move the pointer to write the shared color row";
} catch (error) {
failed.value = true;
status.value = error.message;
}
});
onUnmounted(() => {
delete window.__yawnPlayground;
core?.dispose();
});
</script>
<template>
<div class="playground">
<canvas ref="canvas" aria-label="Yawn WebGPU output" @pointermove="move" />
<p :class="{ failed }" data-playground-status>{{ status }}</p>
</div>
</template>
<style scoped>
.playground { margin: 24px 0; }
canvas { width: 100%; aspect-ratio: 16 / 9; display: block; background: #111827; border-radius: 12px; }
p { color: var(--vp-c-text-2); }
.failed { color: var(--vp-c-danger-1); }
</style>
+26 -57
View File
@@ -1,67 +1,36 @@
import { defineConfig } from "vitepress";
const headers = {
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Embedder-Policy": "require-corp",
};
const isolation = {
name: "cross-origin-isolation",
configureServer(server) {
server.middlewares.use((_, response, next) => {
for (const [name, value] of Object.entries(headers)) {
response.setHeader(name, value);
}
next();
});
},
};
export default defineConfig({
title: "Yawn",
description: "Worker-native WebGPU rendering with shared render data.",
base: "/docs/",
outDir: "../dist/docs",
description: "Shared render data and a render graph.",
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/" },
{ text: "Architecture", link: "/" },
{ 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.",
},
},
vite: {
plugins: [isolation],
worker: { format: "es" },
server: { allowedHosts: true, headers },
preview: { allowedHosts: true, headers },
},
});
-31
View File
@@ -1,31 +0,0 @@
<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
@@ -1,90 +0,0 @@
: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
@@ -1,10 +0,0 @@
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
@@ -1,40 +0,0 @@
# 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
@@ -1,84 +0,0 @@
# 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).
+31 -24
View File
@@ -1,35 +1,42 @@
---
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.
text: Shared render data and a render graph.
tagline: Two core files, one fixed arena, no built-in scene model or shader.
actions:
- theme: brand
text: Build your first scene
link: /guide/first-scene
- theme: alt
text: Open the playground
link: /../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: Shared rows
details: Allocate an SOA row array once by message, then mutate its SAB views directly from any thread.
- title: External graphs
details: JSO and FXNode addons serialize DAGs to the S-expression AST consumed by the worker.
- 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.
details: Pipelines, GPU resources, pass order, and compatible transient aliases are prepared before activation.
---
<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."
/>
## The entire boundary
```js
const color = await core.allocateRows({
name: "triangle.color",
rows: 1,
stride: 16,
format: "f32",
});
color.write(0, [0.2, 0.65, 1, 1]);
color.row(0)[0] = 0.8; // direct SharedArrayBuffer write
await loadGraph(core, graph); // infrequent message
```
`@yawn/core` contains only the public shared-row client and its worker. The worker owns the fixed 64-byte-aligned arena, S-expression graph compiler, WebGPU loadout, and transient texture aliasing. Every scene convention and every byte of WGSL comes from an addon or application.
```text
JSO / FXNode ──▶ AST ──▶ S-expression ──▶ core worker ──▶ WebGPU
any JS thread ───────────── direct SAB row writes ────────────┘
```
The addon packages provide graph serialization, optional WGSL, glTF import directly into shared rows, and conventional camera/material/mesh handles. None of them add semantics to core.
-66
View File
@@ -1,66 +0,0 @@
# 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
@@ -1,55 +0,0 @@
# 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
@@ -1,26 +0,0 @@
# 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
@@ -1,59 +0,0 @@
# 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
@@ -1,78 +0,0 @@
# 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.
+9
View File
@@ -0,0 +1,9 @@
# Minimal playground
This is the one runnable example. It allocates a single `f32` row, sends an externally authored JSO render graph through the AST codec, and changes color by writing the shared row directly on pointer movement.
<Playground />
<script setup>
import Playground from "./.vitepress/Playground.vue";
</script>
-82
View File
@@ -1,82 +0,0 @@
# 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
@@ -1,12 +0,0 @@
# 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
@@ -1,101 +0,0 @@
# 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
@@ -1,54 +0,0 @@
# 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
@@ -1,91 +0,0 @@
# 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" />