Strip core to render data and render graphs
Move glTF, picking, camera controls, and conventional handles into addons. Keep camera and material mutations in SIMD-aligned shared SOA rows and synchronize material updates directly into GPU buffers. 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
-1
@@ -1,9 +1,10 @@
|
||||
# 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.
|
||||
|
||||
Start the full browser example with `npm run dev`. The cookbook modules are plain
|
||||
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.
|
||||
|
||||
@@ -11,9 +11,3 @@ export async function compileAndSwitch(core, graph) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Return to clear-only mode before releasing an active compiled loadout. */
|
||||
export async function dropActiveGraph(core, compiled) {
|
||||
await core.switchToImmediate();
|
||||
await core.dropCompiledGraph(compiled.compiledId);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { MeshHandles } from "@yawn/mesh-handles";
|
||||
|
||||
/** Query the optional snapshot/BVH worker and receive wrapped instance handles. */
|
||||
export function pickNearest(core, origin, direction) {
|
||||
return new MeshHandles(core).pickRay(origin, direction, {
|
||||
export function pickNearest(meshHandles, origin, direction) {
|
||||
return meshHandles.pickRay(origin, direction, {
|
||||
maxDistance: 10_000,
|
||||
maxHits: 1,
|
||||
});
|
||||
|
||||
@@ -6,7 +6,6 @@ export async function connectFromWorker(port, options = {}) {
|
||||
worker: port,
|
||||
memory: options.memory,
|
||||
ringPtr: options.ringPtr,
|
||||
pickingWorkerFactory: options.pickingWorkerFactory,
|
||||
free: options.free,
|
||||
});
|
||||
await core.ready;
|
||||
|
||||
@@ -1,19 +1,10 @@
|
||||
import { culling } from "../render-graph-studio/render-graph/presets.js";
|
||||
import { importGltf } from "./09-gltf-import-worker.js";
|
||||
import { compileAndSwitch, dropActiveGraph } from "./08-compile-and-switch.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);
|
||||
try {
|
||||
const meshes = await importGltf(core, gltfUrl);
|
||||
return {
|
||||
compiled,
|
||||
meshes,
|
||||
dispose: () => dropActiveGraph(core, compiled),
|
||||
};
|
||||
} catch (error) {
|
||||
await dropActiveGraph(core, compiled).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
return { compiled, meshes };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
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.
|
||||
export function installCameraRenderDataControls(core, canvas) {
|
||||
const camera = core.array("camera.state");
|
||||
const abort = new AbortController();
|
||||
const options = { signal: abort.signal };
|
||||
const write = (state) => camera.write(0, state);
|
||||
|
||||
canvas.addEventListener(
|
||||
"pointerdown",
|
||||
(event) => {
|
||||
if (event.pointerType === "mouse" && (event.button === 1 || event.button === 2)) {
|
||||
event.preventDefault();
|
||||
canvas.setPointerCapture(event.pointerId);
|
||||
}
|
||||
},
|
||||
options,
|
||||
);
|
||||
canvas.addEventListener(
|
||||
"pointermove",
|
||||
(event) => {
|
||||
if (
|
||||
event.pointerType !== "mouse" ||
|
||||
!canvas.hasPointerCapture(event.pointerId) ||
|
||||
(event.buttons & 6) === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const state = camera.read(0);
|
||||
const offset = [state[0] - state[4], state[1] - state[5], state[2] - state[6]];
|
||||
const distance = Math.hypot(...offset);
|
||||
if ((event.buttons & 4) !== 0) {
|
||||
const yaw = Math.atan2(offset[0], offset[2]) + event.movementX * 0.005;
|
||||
const pitch = Math.max(
|
||||
-MAX_PITCH,
|
||||
Math.min(
|
||||
MAX_PITCH,
|
||||
Math.asin(offset[1] / distance) + event.movementY * 0.005,
|
||||
),
|
||||
);
|
||||
const horizontal = Math.cos(pitch) * distance;
|
||||
state[0] = state[4] + Math.sin(yaw) * horizontal;
|
||||
state[1] = state[5] + Math.sin(pitch) * distance;
|
||||
state[2] = state[6] + Math.cos(yaw) * horizontal;
|
||||
} else {
|
||||
const forward = [
|
||||
(state[4] - state[0]) / distance,
|
||||
(state[5] - state[1]) / distance,
|
||||
(state[6] - state[2]) / distance,
|
||||
];
|
||||
const right = [
|
||||
forward[1] * state[10] - forward[2] * state[9],
|
||||
forward[2] * state[8] - forward[0] * state[10],
|
||||
forward[0] * state[9] - forward[1] * state[8],
|
||||
];
|
||||
const rightLength = Math.hypot(...right);
|
||||
right.forEach((value, index) => (right[index] = value / rightLength));
|
||||
const up = [
|
||||
right[1] * forward[2] - right[2] * forward[1],
|
||||
right[2] * forward[0] - right[0] * forward[2],
|
||||
right[0] * forward[1] - right[1] * forward[0],
|
||||
];
|
||||
const units =
|
||||
(2 * distance * Math.tan(state[12] * 0.5)) /
|
||||
Math.max(1, canvas.clientHeight);
|
||||
const translation = right.map(
|
||||
(value, index) =>
|
||||
-value * event.movementX * units + up[index] * event.movementY * units,
|
||||
);
|
||||
for (let index = 0; index < 3; index++) {
|
||||
state[index] += translation[index];
|
||||
state[index + 4] += translation[index];
|
||||
}
|
||||
}
|
||||
write(state);
|
||||
},
|
||||
options,
|
||||
);
|
||||
for (const type of ["pointerup", "pointercancel"]) {
|
||||
canvas.addEventListener(
|
||||
type,
|
||||
(event) => {
|
||||
if (canvas.hasPointerCapture(event.pointerId)) {
|
||||
canvas.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
},
|
||||
options,
|
||||
);
|
||||
}
|
||||
canvas.addEventListener(
|
||||
"wheel",
|
||||
(event) => {
|
||||
event.preventDefault();
|
||||
const delta =
|
||||
event.deltaMode === WheelEvent.DOM_DELTA_LINE
|
||||
? event.deltaY * 16
|
||||
: event.deltaMode === WheelEvent.DOM_DELTA_PAGE
|
||||
? event.deltaY * canvas.clientHeight
|
||||
: event.deltaY;
|
||||
if (!Number.isFinite(delta) || delta === 0) return;
|
||||
const state = camera.read(0);
|
||||
const offset = [state[0] - state[4], state[1] - state[5], state[2] - state[6]];
|
||||
const distance = Math.hypot(...offset);
|
||||
const nextDistance = Math.max(
|
||||
MIN_DISTANCE,
|
||||
Math.min(state[15] * 0.95, distance * Math.exp(0.002 * delta)),
|
||||
);
|
||||
for (let index = 0; index < 3; index++) {
|
||||
state[index] = state[index + 4] + offset[index] * (nextDistance / distance);
|
||||
}
|
||||
write(state);
|
||||
},
|
||||
{ ...options, passive: false },
|
||||
);
|
||||
canvas.addEventListener("contextmenu", (event) => event.preventDefault(), options);
|
||||
|
||||
return () => abort.abort();
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ Import the function you need and pass the same `YawnCore` instance to every addo
|
||||
| `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, activating, and safely cleaning up a graph |
|
||||
| `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 |
|
||||
@@ -20,6 +20,8 @@ Import the function you need and pass the same `YawnCore` instance to every addo
|
||||
| `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
|
||||
|
||||
@@ -13,3 +13,5 @@ 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";
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
<!doctype html>
|
||||
<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>
|
||||
</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>
|
||||
</body>
|
||||
</html>
|
||||
@@ -21,7 +21,7 @@ export function encodeGeometryGlb({positions,normals,texcoords,indices}) {
|
||||
const vertexCount=streams[0].length/3, bounds=finiteMinMax(streams[0],3);
|
||||
if(indices.some(value=>value>=vertexCount))throw new TypeError("Invalid demo geometry");
|
||||
const nodes=[]; for(let z=-1;z<=1;z++)for(let x=-1;x<=1;x++)nodes.push({mesh:0,translation:[x*3,0,z*3]});
|
||||
const json={asset:{version:"2.0",generator:"yawn-phase8"},scene:0,scenes:[{nodes:nodes.map((_,i)=>i)}],nodes,meshes:[{primitives:[{attributes:{POSITION:0,NORMAL:1,TEXCOORD_0:2},indices:3}]}],buffers:[{byteLength}],bufferViews:views,accessors:[
|
||||
const json={asset:{version:"2.0",generator:"yawn-demo"},scene:0,scenes:[{nodes:nodes.map((_,i)=>i)}],nodes,meshes:[{primitives:[{attributes:{POSITION:0,NORMAL:1,TEXCOORD_0:2},indices:3}]}],buffers:[{byteLength}],bufferViews:views,accessors:[
|
||||
{bufferView:0,componentType:5126,count:vertexCount,type:"VEC3",min:bounds.min,max:bounds.max},
|
||||
{bufferView:1,componentType:5126,count:vertexCount,type:"VEC3"},
|
||||
{bufferView:2,componentType:5126,count:vertexCount,type:"VEC2"},
|
||||
@@ -57,7 +57,7 @@ const galleryPngBase64=Object.freeze({
|
||||
});
|
||||
const decodeBase64=value=>{const binary=atob(value),bytes=new Uint8Array(binary.length);for(let i=0;i<binary.length;i++)bytes[i]=binary.charCodeAt(i);return bytes;};
|
||||
|
||||
/** Build the deterministic Phase 6 PBR shader validation gallery. */
|
||||
/** Build a deterministic PBR shader validation gallery. */
|
||||
export function createMaterialGalleryGlb(){
|
||||
// A modest shared sphere keeps the embedded GLB compact while making roughness
|
||||
// and normal-map responses much easier to compare than the former cubes.
|
||||
@@ -78,7 +78,7 @@ export function createMaterialGalleryGlb(){
|
||||
];
|
||||
const nodes=materials.map((material,index)=>({name:material.name,mesh:index,translation:[(index%4-1.5)*2.5,(1.5-Math.floor(index/4))*2.5,0],...(index===15?{scale:[-1.25,0.7,1.1]}:{})}));
|
||||
const meshes=materials.map((material,index)=>({name:material.name,primitives:[{attributes:{POSITION:0,NORMAL:1,TEXCOORD_0:2},indices:3,material:index}]}));
|
||||
const json={asset:{version:"2.0",generator:"yawn-phase6-pbr-gallery"},extensionsUsed:["KHR_materials_ior"],scene:0,scenes:[{name:"Phase 6 deterministic PBR gallery",nodes:nodes.map((_,i)=>i)}],nodes,meshes,materials,
|
||||
const json={asset:{version:"2.0",generator:"yawn-pbr-gallery"},extensionsUsed:["KHR_materials_ior"],scene:0,scenes:[{name:"Deterministic PBR gallery",nodes:nodes.map((_,i)=>i)}],nodes,meshes,materials,
|
||||
samplers:[{magFilter:9728,minFilter:9728,wrapS:10497,wrapT:10497}],images:images.map((_,i)=>({name:["Odd-width sRGB base color and emissive","Odd-width linear MR and AO","Odd-width OpenGL normal map"][i],bufferView:i+4,mimeType:"image/png"})),textures:images.map((_,i)=>({sampler:0,source:i})),
|
||||
buffers:[{byteLength}],bufferViews,accessors:[{bufferView:0,componentType:5126,count:vertexCount,type:"VEC3",min:bounds.min,max:bounds.max},{bufferView:1,componentType:5126,count:vertexCount,type:"VEC3"},{bufferView:2,componentType:5126,count:vertexCount,type:"VEC2"},{bufferView:3,componentType:5125,count:geometry.indices.length,type:"SCALAR"}]};
|
||||
let jsonBytes=encoder.encode(JSON.stringify(json));const jsonLength=align4(jsonBytes.length),total=12+8+jsonLength+8+byteLength,out=new ArrayBuffer(total),view=new DataView(out),bytes=new Uint8Array(out);
|
||||
|
||||
@@ -71,11 +71,6 @@
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
#profile-menu { min-width: 210px; color: #9da9ba; }
|
||||
#profile-menu summary { cursor: pointer; color: #eef2f8; }
|
||||
#profile-menu table { width: 100%; font-size: 11px; }
|
||||
#profile-menu th { text-align: left; font-weight: 500; }
|
||||
#profile-menu td { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
select,
|
||||
button {
|
||||
font: inherit;
|
||||
@@ -165,7 +160,7 @@
|
||||
>Scene loadout<select id="loadout-select">
|
||||
<option value="cubes">Cubes</option>
|
||||
<option value="spheres">UV spheres</option>
|
||||
<option value="materials">Phase 6 PBR gallery</option>
|
||||
<option value="materials">PBR material gallery</option>
|
||||
</select></label
|
||||
><label class="field" for="graph-select"
|
||||
>Graph preset<select id="graph-select">
|
||||
@@ -175,7 +170,7 @@
|
||||
>
|
||||
</div>
|
||||
<canvas id="canvas0"></canvas
|
||||
><output id="demo-status" aria-live="polite">Starting Phase 8…</output>
|
||||
><output id="demo-status" aria-live="polite">Starting renderer…</output>
|
||||
</section>
|
||||
<section class="editor" aria-label="Render graph editor">
|
||||
<div class="editor-bar">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { YawnCore, RendererError } from "@yawn/core";
|
||||
import { MeshHandles, createPickingWorker } from "@yawn/mesh-handles";
|
||||
import { MeshHandles } from "@yawn/mesh-handles";
|
||||
import { installCameraRenderDataControls } from "../cookbook/16-camera-render-data.js";
|
||||
import { loadDemoLoadout } from "./demo-loadouts.js";
|
||||
import { adaptFxNodeSnapshot } from "@yawn/render-graph-fxnode";
|
||||
import { createGraphAst } from "@yawn/render-graph-ast";
|
||||
@@ -37,11 +38,7 @@ const state = {
|
||||
compiled: {},
|
||||
telemetry: null,
|
||||
};
|
||||
export const profileRequested = (search) =>
|
||||
new URLSearchParams(search).get("profile") === "1";
|
||||
const profileEnabled = profileRequested(location.search);
|
||||
|
||||
function createWorkerTransport(profile) {
|
||||
function createWorkerTransport() {
|
||||
const canvas = document.querySelector("#canvas0");
|
||||
const dpr = devicePixelRatio;
|
||||
canvas.width = Math.round(Math.max(1, canvas.clientWidth) * dpr);
|
||||
@@ -62,16 +59,6 @@ function createWorkerTransport(profile) {
|
||||
kind,
|
||||
values: new Float64Array(values),
|
||||
});
|
||||
const mouseValues = (event) => [
|
||||
devicePixelRatio,
|
||||
event.buttons,
|
||||
event.movementX,
|
||||
event.movementY,
|
||||
event.offsetX,
|
||||
event.offsetY,
|
||||
Math.max(1, canvas.clientHeight),
|
||||
];
|
||||
|
||||
addEventListener(
|
||||
"resize",
|
||||
() =>
|
||||
@@ -82,106 +69,19 @@ function createWorkerTransport(profile) {
|
||||
]),
|
||||
options,
|
||||
);
|
||||
canvas.addEventListener(
|
||||
"pointerdown",
|
||||
(event) => {
|
||||
if (event.pointerType === "mouse" && (event.button === 1 || event.button === 2)) {
|
||||
event.preventDefault();
|
||||
canvas.setPointerCapture(event.pointerId);
|
||||
}
|
||||
},
|
||||
options,
|
||||
);
|
||||
canvas.addEventListener(
|
||||
"pointermove",
|
||||
(event) => {
|
||||
if (
|
||||
event.pointerType === "mouse" &&
|
||||
canvas.hasPointerCapture(event.pointerId) &&
|
||||
(event.buttons & 6) !== 0
|
||||
) {
|
||||
event.preventDefault();
|
||||
post(1, mouseValues(event));
|
||||
}
|
||||
},
|
||||
options,
|
||||
);
|
||||
for (const type of ["pointerup", "pointercancel"]) {
|
||||
canvas.addEventListener(
|
||||
type,
|
||||
(event) => {
|
||||
if (canvas.hasPointerCapture(event.pointerId)) {
|
||||
canvas.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
},
|
||||
options,
|
||||
);
|
||||
}
|
||||
canvas.addEventListener(
|
||||
"click",
|
||||
(event) => {
|
||||
if (event.button === 0) post(2, mouseValues(event));
|
||||
},
|
||||
options,
|
||||
);
|
||||
canvas.addEventListener(
|
||||
"wheel",
|
||||
(event) => {
|
||||
event.preventDefault();
|
||||
const delta =
|
||||
event.deltaMode === 0
|
||||
? event.deltaY
|
||||
: event.deltaMode === 1
|
||||
? event.deltaY * 16
|
||||
: event.deltaMode === 2
|
||||
? event.deltaY * Math.max(1, canvas.clientHeight)
|
||||
: null;
|
||||
if (Number.isFinite(delta)) post(3, [delta]);
|
||||
},
|
||||
{ ...options, passive: false },
|
||||
);
|
||||
canvas.addEventListener("contextmenu", (event) => event.preventDefault(), options);
|
||||
|
||||
const offscreen = canvas.transferControlToOffscreen();
|
||||
worker.postMessage({ type: "init", canvas: offscreen, profile }, [offscreen]);
|
||||
worker.postMessage({ type: "init", canvas: offscreen }, [offscreen]);
|
||||
return {
|
||||
worker,
|
||||
pickingWorkerFactory: createPickingWorker,
|
||||
free() {
|
||||
abort.abort();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function installProfileMenu() {
|
||||
if (!profileEnabled) return;
|
||||
const menu = document.createElement("details");
|
||||
menu.id = "profile-menu";
|
||||
menu.innerHTML =
|
||||
'<summary>GPU profile</summary><div id="profile-status">Waiting for GPU timestamps…</div><table><tbody id="profile-passes"></tbody></table>';
|
||||
document.querySelector(".toolbar")?.append(menu);
|
||||
on(renderer, "renderer-profile", (event) => {
|
||||
const p = event.detail;
|
||||
document.querySelector("#profile-status").textContent = p.available
|
||||
? `${p.graph} · epoch ${p.epoch} · ${p.dropped} dropped`
|
||||
: "GPU timestamps unavailable";
|
||||
document.querySelector("#profile-passes").replaceChildren(
|
||||
...Object.entries(p.passes || {}).map(([id, ms]) => {
|
||||
const row = document.createElement("tr"),
|
||||
name = document.createElement("th"),
|
||||
value = document.createElement("td");
|
||||
name.textContent = id;
|
||||
value.textContent = `${Number(ms).toFixed(3)} ms`;
|
||||
row.append(name, value);
|
||||
return row;
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function publish(telemetry) {
|
||||
state.telemetry = telemetry;
|
||||
document.documentElement.dataset.phase8State = JSON.stringify({
|
||||
document.documentElement.dataset.yawnState = JSON.stringify({
|
||||
activeLoadout: state.loadout,
|
||||
activeGraph: state.graph,
|
||||
renderDataRevision: telemetry.revision,
|
||||
@@ -195,7 +95,6 @@ function publish(telemetry) {
|
||||
draws: telemetry.draws,
|
||||
instances: telemetry.instances,
|
||||
indices: telemetry.indices,
|
||||
framingRadius: telemetry.framingRadius,
|
||||
gpuError: telemetry.gpuError,
|
||||
});
|
||||
}
|
||||
@@ -252,9 +151,9 @@ async function transaction(label, operation, rollback) {
|
||||
try {
|
||||
await rollback?.();
|
||||
} catch (rollbackError) {
|
||||
console.error("Phase 8 rollback failed", rollbackError);
|
||||
console.error("Render graph rollback failed", rollbackError);
|
||||
}
|
||||
console.error("Phase 8 transaction failed", error);
|
||||
console.error("Render graph transaction failed", error);
|
||||
status(`Failed · ${error?.code ?? error?.message ?? error}`);
|
||||
}
|
||||
return false;
|
||||
@@ -328,6 +227,7 @@ async function cleanup() {
|
||||
await editor?.destroy();
|
||||
} finally {
|
||||
gltfImporter?.dispose();
|
||||
meshHandles?.dispose();
|
||||
renderer?.dispose();
|
||||
}
|
||||
}
|
||||
@@ -337,12 +237,15 @@ const pagehide = () => {
|
||||
|
||||
async function start() {
|
||||
addEventListener("pagehide", pagehide, { once: true });
|
||||
delete document.documentElement.dataset.phase8Ready;
|
||||
renderer = new YawnCore(createWorkerTransport(profileEnabled));
|
||||
delete document.documentElement.dataset.yawnReady;
|
||||
const transport = createWorkerTransport();
|
||||
renderer = new YawnCore(transport);
|
||||
meshHandles = new MeshHandles(renderer);
|
||||
gltfImporter = new GltfImporter(renderer);
|
||||
installProfileMenu();
|
||||
await renderer.ready;
|
||||
listeners.push(
|
||||
installCameraRenderDataControls(renderer, document.querySelector("#canvas0")),
|
||||
);
|
||||
const nextEditor = await createRenderGraphEditor(
|
||||
document.querySelector("#graph-editor"),
|
||||
);
|
||||
@@ -465,11 +368,11 @@ async function start() {
|
||||
},
|
||||
);
|
||||
if (!initialized) throw new Error("Initial demo transaction failed");
|
||||
document.documentElement.dataset.phase8Ready = "true";
|
||||
document.documentElement.dataset.yawnReady = "true";
|
||||
}
|
||||
const startupError = (error) => {
|
||||
if (cleaned) return;
|
||||
console.error("Phase 8 startup failed", error);
|
||||
console.error("Render graph startup failed", error);
|
||||
status(`Startup failed · ${error?.code ?? error}`);
|
||||
void cleanup();
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user