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