Replace addons with conventional handles

Provide the single-loadout Scene API, SAB-backed meshes, cameras, materials, lights, workers, post effects, and tutorial playgrounds. Batch matching row growth so active GPU loadouts refresh once.

Amp-Thread-ID: https://ampcode.com/threads/T-01a01380-b478-77d0-84a0-102880a5c5ae
Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
This commit is contained in:
Amp
2026-08-20 06:39:30 +00:00
co-authored by heaust
parent 239b1d25b0
commit 9768765d74
62 changed files with 2749 additions and 798 deletions
+55 -19
View File
@@ -1,19 +1,26 @@
<script setup>
import { onMounted, onUnmounted, ref } from "vue";
import { YawnCore } from "@yawn/core";
import { loadGraph } from "@yawn/render-graph-js";
import { triangleGraph } from "@yawn/default-pipelines";
import {
ComputePass,
FXAA,
Mesh,
PBRMaterial,
PointLight,
Scene,
} from "@yawn/handles";
const props = defineProps({ example: { type: String, default: "triangle" } });
const canvas = ref();
const status = ref("Starting…");
const failed = ref(false);
let core;
let color;
let scene;
let accent;
function move(event) {
if (!color) return;
if (!accent) return;
const bounds = canvas.value.getBoundingClientRect();
const row = color.row(0);
const row = accent.row(0);
row[0] = (event.clientX - bounds.left) / bounds.width;
row[1] = 1 - (event.clientY - bounds.top) / bounds.height;
}
@@ -23,18 +30,47 @@ onMounted(async () => {
if (!crossOriginIsolated) throw new Error("Cross-origin isolation is disabled");
canvas.value.width = 960;
canvas.value.height = 540;
core = new YawnCore(canvas.value);
await core.ready;
color = await core.createRows({
name: "triangle.color",
rows: 1,
stride: 16,
format: "f32",
scene = new Scene(canvas.value, { hdr: true });
await scene.ready;
accent = scene.array("sceneAccent");
const material = new PBRMaterial(scene, {
baseColor: props.example === "lights" ? [1, 0.55, 0.18, 1] : [0.75, 0.9, 1, 1],
metallic: props.example === "materials" ? 0.9 : 0.1,
roughness: 0.38,
});
color.write(0, [0.2, 0.65, 1, 1]);
await loadGraph(core, triangleGraph());
window.__yawnPlayground = { core, color };
status.value = "Running · move the pointer to write the shared color row";
await material.ready;
const mesh = new Mesh(scene, {
material,
vertexData: {
positions: [-0.7, -0.6, 0, 0.7, -0.6, 0, 0, 0.72, 0],
indices: [0, 1, 2],
},
});
await mesh.ready;
if (props.example === "instances") {
mesh.position = [-0.45, 0, 0];
mesh.scale = [0.65, 0.65, 1];
const clone = mesh.clone({ position: [0.45, 0, 0], scale: [0.65, 0.65, 1] });
await clone.ready;
} else if (props.example === "lights") {
await new PointLight(scene, { position: [0, 0.4, 0.2], color: [1, 0.4, 0.1], intensity: 8 }).ready;
} else if (props.example === "post") {
await new FXAA(scene).ready;
} else if (props.example === "compute") {
await scene.ensureRows("playgroundCompute", 1, 16, "u32");
const compute = new ComputePass({
id: "playground-compute",
code: "@group(0) @binding(0) var<storage, read_write> value: array<u32>; @compute @workgroup_size(1) fn main() { value[0] = value[0] + 1u; }",
buffers: [{ id: "playground-value", array: "playgroundCompute", usage: ["storage"] }],
bindings: [{ group: 0, binding: 0, resource: "playground-value" }],
});
await scene.addComputePass(compute);
}
window.__yawnPlayground = { scene, mesh, material, accent };
status.value = `${props.example} running · pointer movement writes sceneAccent in the SAB`;
} catch (error) {
failed.value = true;
status.value = error.message;
@@ -43,7 +79,7 @@ onMounted(async () => {
onUnmounted(() => {
delete window.__yawnPlayground;
core?.dispose();
scene?.dispose();
});
</script>
+21 -1
View File
@@ -23,9 +23,29 @@ export default defineConfig({
cleanUrls: true,
themeConfig: {
nav: [
{ text: "Architecture", link: "/" },
{ text: "Guide", link: "/guide/getting-started" },
{ text: "Core", link: "/guide/core" },
{ text: "Playground", link: "/playground" },
],
sidebar: {
"/guide/": [
{
text: "Learn Yawn",
items: [
{ text: "Getting started", link: "/guide/getting-started" },
{ text: "Scene and shared data", link: "/guide/scene-and-sab" },
{ text: "Cameras and controls", link: "/guide/cameras" },
{ text: "Meshes and instances", link: "/guide/meshes-and-instances" },
{ text: "Materials and textures", link: "/guide/materials" },
{ text: "Clustered lights", link: "/guide/lights" },
{ text: "Compute passes", link: "/guide/compute" },
{ text: "Post processing", link: "/guide/post-processing" },
{ text: "glTF and picking", link: "/guide/importing-and-picking" },
{ text: "Core boundary", link: "/guide/core" },
],
},
],
},
},
vite: {
plugins: [isolation],
+75
View File
@@ -0,0 +1,75 @@
# Cameras and controls
Every camera allocates a generic `cameras` slot and a transform node. Projection, lens, controller state, and transforms are direct SAB rows after construction.
## Shared lens and projection controls
```ts
const camera = new Camera(scene, {
fov: Math.PI / 3,
near: 0.05,
far: 2000,
focalLength: 50,
aperture: 2.8,
focusDistance: 8,
});
await camera.ready;
camera.projection = "orthographic";
camera.orthoSize = 12;
camera.projection = "perspective";
```
`fov`, `aspect`, `near`, `far`, `orthoSize`, `focalLength`, `aperture`, `focusDistance`, and `sensorWidth` all write the camera's shared row.
## Arc rotate
```ts
const orbit = new ArcRotateCamera(scene, {
alpha: 0,
beta: Math.PI / 3,
radius: 6,
target: mesh,
controls: {
element: canvas,
pointer: true, // left orbit, right pan, wheel zoom
controller: true, // sticks + triggers
},
});
await orbit.ready;
```
## Free spectator camera
```ts
const free = new FreeCamera(scene, {
position: [0, 1, 5],
controls: {
element: canvas,
keyboard: true, // WASD + Space/Ctrl
pointer: true, // click for pointer lock, mouse to look
controller: true,
speed: 6,
},
});
await free.ready;
```
## Follow a character
```ts
const follow = new FollowCamera(scene, {
target: player,
distance: 5,
height: 1.8,
smoothing: 0.12,
});
await follow.ready;
follow.target = anotherPlayer;
follow.distance = 7;
follow.stop();
follow.start();
```
The input and follow loops never post camera updates to core: they read and mutate the same camera, position, and quaternion rows that any other worker can use.
+42
View File
@@ -0,0 +1,42 @@
# Compute passes
Compute shaders are graph data, not core code. Declare the shared rows/resources a shader needs and attach the pass to `Scene`.
```ts
const velocity = await scene.ensureRows("velocity", 4096, 16, "f32");
const simulation = new ComputePass({
id: "integrate",
code: `
@group(0) @binding(0)
var<storage, read_write> velocity: array<vec4<f32>>;
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) id: vec3<u32>) {
if (id.x < arrayLength(&velocity)) {
velocity[id.x].y -= 0.001;
}
}
`,
buffers: [{ id: "velocity", array: "velocity", usage: ["storage"] }],
bindings: [{ group: 0, binding: 0, resource: "velocity" }],
dispatch: [64, 1, 1],
});
await scene.addComputePass(simulation);
```
<Playground example="compute" />
The graph DAG places an unqualified custom compute pass after light clustering and makes forward rendering depend on all custom compute passes. Use `after` to specify other dependencies.
```ts
await simulation.update({ dispatch: [128, 1, 1] });
await scene.removeComputePass(simulation);
```
Both operations are infrequent graph/loadout messages. Existing source row changes are direct SAB writes.
<script setup>
import Playground from "../.vitepress/Playground.vue";
</script>
+44
View File
@@ -0,0 +1,44 @@
# Core boundary
`@yawn/core` deliberately contains no scene types and no WGSL. It owns two things:
1. a 64-byte-aligned arena of named f32/u32/i32 SOA rows in one `SharedArrayBuffer`;
2. render-graph compilation, up-front WebGPU loadouts, transient resource aliasing, render bundles, and the paced render loop.
```text
infrequent worker messages hot shared mutations
┌──────────────────────────────┐ ┌──────────────────────┐
│ create/delete rows │ │ transforms │
│ allocate/delete object slot │ │ cameras/materials │
│ compile/switch graph │ │ lights/app data │
│ play/pause/set FPS │ │ info.skipRender │
└──────────────┬───────────────┘ └──────────┬───────────┘
└─────────────────┬─────────────────────┘
┌─────────────────┐
│ Rust/WASM core │
└─────────────────┘
```
## Use core directly
```ts
import { YawnCore } from "@yawn/core";
const core = new YawnCore(canvas, { arenaBytes: 64 * 1024 * 1024 });
await core.ready;
const values = await core.createRows({
name: "application.values",
rows: 1024,
stride: 16,
format: "f32",
});
const id = await core.allocateObject("application.values");
values.row(id).set([1, 2, 3, 4]);
```
Graph frontends serialize plain data to `(yawn-graph 1 ...)`. Named `after` edges preserve DAG fan-out; Rust sorts passes, detects cycles, culls unused declarations, plans compatible transient lifetimes, and allocates the active loadout.
The `Scene` addon is one such frontend. It is replaceable and has no privileged core API.
+57
View File
@@ -0,0 +1,57 @@
# Getting started
Yawn separates the data/render engine from optional scene conventions. Most applications begin with `@yawn/handles`; specialized engines can use `@yawn/core` directly.
## 1. Serve with isolation headers
`SharedArrayBuffer` requires `Cross-Origin-Opener-Policy: same-origin` and `Cross-Origin-Embedder-Policy: require-corp`. The included VitePress server already sends both.
```html
<canvas id="view"></canvas>
<script type="module" src="/src/app.ts"></script>
```
## 2. Start a Scene
```ts
import { Scene } from "@yawn/handles";
const canvas = document.querySelector<HTMLCanvasElement>("#view")!;
canvas.width = 1280;
canvas.height = 720;
const scene = new Scene(canvas, { hdr: true, fps: 60 });
await scene.ready;
```
`Scene` initializes conventional SOA rows and loads one clustered-forward HDR render graph. The core itself still starts with only its eight-float `info` row.
## 3. Add a triangle
```ts
import { Mesh, PBRMaterial } from "@yawn/handles";
const blue = new PBRMaterial(scene, {
baseColor: [0.15, 0.55, 1, 1],
metallic: 0.15,
roughness: 0.4,
});
await blue.ready;
const triangle = new Mesh(scene, {
material: blue,
vertexData: {
positions: [-0.7, -0.6, 0, 0.7, -0.6, 0, 0, 0.72, 0],
indices: [0, 1, 2],
},
});
await triangle.ready;
```
Constructors use worker messages only to reserve slots or rebuild the graph. Once `ready` resolves, ordinary property writes mutate shared memory.
<Playground />
<script setup>
import Playground from "../.vitepress/Playground.vue";
</script>
+35
View File
@@ -0,0 +1,35 @@
# glTF import and picking
## Import off-thread
The shared importer worker fetches and parses glTF/GLB, then the response hydrates `Mesh` and `PBRMaterial` handles attached to your scene.
```ts
import { importGltf } from "@yawn/handles";
const meshes = await importGltf(scene, "/models/helmet.glb");
meshes[0].position[1] = 0.5;
```
The importer handles triangle primitives, external/data buffers, standard vertex attributes, indices, node transforms, and metallic-roughness values. Application-specific extensions remain application policy.
## Pick in the BVH worker
```ts
const picking = new Picking(scene);
await picking.ready;
canvas.addEventListener("click", async () => {
const hits = await picking.pick([0, 0, 4], [0, 0, -1]);
const nearest = hits[0];
if (nearest) console.log(nearest.id, nearest.distance);
});
```
The worker reads shared node positions and mesh bounds, updates its BVH when the shared frame counter changes, and returns **all** AABB hits sorted by distance. You can shortlist or run an exact test afterward.
If row allocations relocated since `Picking` was created, refresh its shared descriptors:
```ts
await picking.refresh();
```
+47
View File
@@ -0,0 +1,47 @@
# Clustered lights
The default `Scene` graph runs a clustered compute pass before its HDR forward passes. Light handles write flat rows consumed by that pass.
```ts
const point = new PointLight(scene, {
position: [0, 1, 1],
color: [1, 0.25, 0.05],
intensity: 20,
range: 8,
});
const sun = new DirectionalLight(scene, {
quaternion: [0.2, 0, 0, 0.98],
color: [1, 0.95, 0.8],
intensity: 3,
});
const fill = new AmbientLight(scene, { color: [0.1, 0.2, 0.4], intensity: 0.2 });
await Promise.all([point.ready, sun.ready, fill.ready]);
```
<Playground example="lights" />
## Rectangles and spots
```ts
const panel = new RectAreaLight(scene, {
position: [0, 2, 0],
width: 2,
height: 0.5,
intensity: 12,
});
const spot = new SpotLight(scene, {
position: [0, 1, 1],
innerAngle: 0.25,
outerAngle: 0.6,
range: 15,
});
```
The rectangle handle selects the default graph's linearly transformed cosine (`ltc`) path. Position, orientation, intensity, angles, and colors remain direct SAB mutations after allocation.
<script setup>
import Playground from "../.vitepress/Playground.vue";
</script>
+59
View File
@@ -0,0 +1,59 @@
# Materials and textures
`PBRMaterial` is a conventional view over `materials` and `materialTextures` SOA rows.
```ts
const paint = new PBRMaterial(scene, {
baseColor: [0.8, 0.05, 0.03, 1],
metallic: 0.65,
roughness: 0.22,
emissive: [0, 0, 0],
});
await paint.ready;
paint.roughness = 0.5; // direct SAB write
paint.baseColor[1] = 0.35; // direct SAB write
mesh.material = paint;
```
<Playground example="materials" />
## Graph textures
```ts
const albedo = new Texture(scene, {
source: "/textures/paint.ktx2",
size: [2048, 2048, 1],
format: "rgba8unorm-srgb",
usage: ["sampled", "copyDst"],
});
await albedo.ready;
const textured = new PBRMaterial(scene, { baseColorTexture: albedo });
```
Creating or removing a `Texture` rebuilds the single graph loadout so the GPU resource is allocated up front. The source pointer stays on the handle for an importer or application uploader; core never owns image-loading policy.
## Custom WGSL
`ShaderMaterial` adds its external WGSL pipeline to the scene graph. Updating the code updates the loadout.
```ts
const shader = new ShaderMaterial(scene, {
code: `
struct Out { @builtin(position) position: vec4<f32> }
@vertex fn vertex(@builtin(vertex_index) id: u32) -> Out {
let p = array(vec2(-.2, -.2), vec2(.2, -.2), vec2(0., .2));
var out: Out; out.position = vec4(p[id], 0., 1.); return out;
}
@fragment fn fragment() -> @location(0) vec4<f32> {
return vec4(1., .2, .7, 1.);
}
`,
});
await shader.ready;
```
<script setup>
import Playground from "../.vitepress/Playground.vue";
</script>
+49
View File
@@ -0,0 +1,49 @@
# Meshes and instances
Every `Mesh` is an instance. `clone()` shares geometry and allocates only a new node/mesh slot.
```ts
const source = new Mesh(scene, {
vertexData: {
positions: [-0.2, -0.2, 0, 0.2, -0.2, 0, 0, 0.25, 0],
indices: [0, 1, 2],
},
});
await source.ready;
for (let x = -4; x <= 4; x++) {
const instance = source.clone({ position: [x * 0.2, 0, 0] });
await instance.ready;
}
```
<Playground example="instances" />
## Copy-on-write geometry
The default vertex kinds are `positions`, `normals`, `tangents`, `uvs`, `colors`, and `indices`. Mutating any kind on an instanced clone first makes that mesh's geometry unique.
```ts
const clone = source.clone();
await clone.ready;
await clone.setVertexData("positions", [
-0.4, -0.2, 0,
0.4, -0.2, 0,
0.0, 0.5, 0,
]);
```
## Per-face materials and visibility
```ts
await mesh.setMaterialForFaces(red, [0, 2, 4]);
mesh.material = blue;
mesh.isVisible = false; // one direct u32 write
```
Face rows store optional material pointers; a zero lane falls back to `mesh.material`.
<script setup>
import Playground from "../.vitepress/Playground.vue";
</script>
+36
View File
@@ -0,0 +1,36 @@
# Post processing
Post-process handles insert or remove graph passes. Intermediate HDR textures are transient and compatible non-overlapping lifetimes alias the same physical allocation.
```ts
const ssao = new SSAO(scene, { amount: 0.8 });
const exposure = new DynamicExposure(scene, { exposure: 1.1 });
const grade = new ColorGrading(scene, { toneMap: "aces", amount: 1.0 });
const fxaa = new FXAA(scene);
await Promise.all([ssao.ready, exposure.ready, grade.ready, fxaa.ready]);
```
<Playground example="post" />
Every effect is optional:
```ts
await ssao.setEnabled(false);
await grade.update({ toneMap: "reinhard" });
await fxaa.dispose();
```
Also available:
```ts
const outline = new Silhouette(scene, { amount: 1 });
const edgeImage = new Edges(scene, { amount: 2, enabled: false });
await edgeImage.setEnabled(true);
```
The final present pass tone-maps HDR to the canvas. `ColorGrading` supports `aces`, `reinhard`, and `linear` tone maps.
<script setup>
import Playground from "../.vitepress/Playground.vue";
</script>
+44
View File
@@ -0,0 +1,44 @@
# Scene and shared data
Think of every handle as an array index, not an object mirrored into core. `Node.position`, `Node.quaternion`, and `Node.scale` are views into separate flat SOA arrays.
## Direct transform movement
```ts
import { Node } from "@yawn/handles";
const pivot = new Node(scene, { position: [0, 1, 0] });
await pivot.ready;
canvas.addEventListener("pointermove", (event) => {
pivot.position[0] += event.movementX * 0.002;
pivot.position[1] -= event.movementY * 0.002;
});
```
The pointer handler sends no messages. The typed-array view points directly into the arena shared with the render worker. The camera helpers use this same pattern; see [Cameras and controls](/guide/cameras).
## Add an application-specific row
```ts
const particles = await scene.ensureRows("particleVelocity", 10_000, 16, "f32");
particles.row(42).set([1, 0, 0, 0]);
```
Rows are 16-byte-stride-aligned and arena allocations are 64-byte aligned. Formats are `f32`, `u32`, or `i32`.
## Timing and render skipping
Core always creates `info` as:
```text
[deltaTime, frameCount, elapsedTime, targetFps, skipRender, 0, 0, 0]
```
```ts
const info = scene.array("info").row(0);
info[4] = 1; // keep timing, skip GPU work
info[4] = 0; // resume rendering
```
Use messages for rare control changes (`setFps`, graph updates, allocation); use SAB writes for existing hot state.
+32 -24
View File
@@ -2,41 +2,49 @@
layout: home
hero:
name: Yawn
text: Shared render data and a render graph.
tagline: One Rust/WASM core, one fixed arena, no built-in scene model or shader.
text: Render graphs over shared data.
tagline: A small Rust/WASM core with optional conventional TypeScript handles.
actions:
- theme: brand
text: Open the playground
text: Start the tutorial
link: /guide/getting-started
- theme: alt
text: Open playground
link: /playground
features:
- title: Shared rows
details: Allocate an SOA row array once by message, then mutate its SAB views directly from any thread.
- title: External graphs
details: JSO and FXNode addons serialize DAGs to the S-expression AST consumed by the worker.
- title: Up-front loadouts
details: Pipelines, GPU resources, pass order, and compatible transient aliases are prepared before activation.
- title: Hot state stays shared
details: Transform, material, light, and camera changes are direct SharedArrayBuffer writes from any thread.
- title: Graph-authored GPU work
details: WGSL, pipelines, compute, HDR, and post effects live in one externally supplied DAG loadout.
- title: Conventional when wanted
details: The handles addon supplies Scene, Mesh, materials, lights, glTF import, and BVH picking without adding core semantics.
---
## The entire boundary
## The shortest useful scene
```js
const color = await core.allocateRows({
name: "triangle.color",
rows: 1,
stride: 16,
format: "f32",
```ts
import { Mesh, PBRMaterial, Scene } from "@yawn/handles";
const scene = new Scene(document.querySelector("canvas"), { hdr: true });
await scene.ready;
const material = new PBRMaterial(scene, { baseColor: [0.2, 0.7, 1, 1] });
await material.ready;
const mesh = new Mesh(scene, {
material,
vertexData: {
positions: [-0.7, -0.6, 0, 0.7, -0.6, 0, 0, 0.7, 0],
indices: [0, 1, 2],
},
});
await mesh.ready;
color.write(0, [0.2, 0.65, 1, 1]);
color.row(0)[0] = 0.8; // direct SharedArrayBuffer write
await loadGraph(core, graph); // infrequent message
mesh.position[0] = 0.25; // direct SAB mutation
```
`@yawn/core` contains only the Rust/WASM render-data arena and graph compiler plus the browser worker required to execute WebGPU. Rust owns the fixed 64-byte-aligned shared arena, DAG ordering, resource culling, and transient texture planning; the worker materializes the resulting loadout. Every scene convention and every byte of WGSL comes from an addon or application.
`Scene` installs one HDR clustered-forward loadout. Adding compute, custom shaders, textures, or post effects rebuilds that same loadout; changing values already present in shared rows does not send a message.
```text
JSO / FXNode ──▶ AST ──▶ S-expression ──▶ Rust/WASM ──▶ worker ──▶ WebGPU
any JS thread ────────────── direct SAB row writes ────────────────────┘
handles ──▶ graph AST ──▶ S-expression ──▶ core worker ──▶ Rust/WebGPU
any JS thread ────────────── direct SAB row writes ────────────────┘
```
The addon packages provide graph serialization, optional WGSL, glTF import directly into shared rows, and conventional camera/material/mesh handles. None of them add semantics to core.
+1 -1
View File
@@ -1,6 +1,6 @@
# Minimal playground
This is the one runnable example. It allocates a single `f32` row, sends an externally authored JSO render graph through the AST codec, and changes color by writing the shared row directly on pointer movement.
This page creates an HDR `Scene`, one PBR material, and one indexed mesh. Move the pointer over the canvas: the handler writes the `sceneAccent` shared row directly without messaging the renderer.
<Playground />