import {
compileFxNodeRenderGraph,
createRenderGraphEditor,
renderGraphNodeGroups,
renderGraphPresets,
} from "@yawn/fxnode-rg";
const renderGraphComment = "/* Render graph loadout:";
const defaultSource = `import {
AmbientLight,
ArcRotateCamera,
ColorGrading,
FXAA,
Icosphere,
PBRMaterial,
Plane,
PointLight,
Scene,
SpotLight,
} from "@yawn/handles";
const scene = new Scene(canvas, {
hdr: true,
fps: 60,
clearColor: [0.018, 0.035, 0.055, 1],
});
await scene.ready;
await scene.reserve({ nodes: 6, materials: 2 });
const objectMaterial = new PBRMaterial(scene, {
baseColor: [0.82, 0.86, 0.9, 1],
metallic: 0.12,
roughness: 0.32,
});
const groundMaterial = new PBRMaterial(scene, {
baseColor: [0.24, 0.28, 0.34, 1],
metallic: 0,
roughness: 0.82,
});
await Promise.all([objectMaterial.ready, groundMaterial.ready]);
const icosphere = new Icosphere(scene, {
material: objectMaterial,
subdivisions: 2,
position: [0, 0.2, 0],
});
const ground = new Plane(scene, {
material: groundMaterial,
size: 2.6,
position: [0, -0.55, 0],
});
await Promise.all([icosphere.ready, ground.ready]);
icosphere.rotateY(Math.PI / 5);
const camera = new ArcRotateCamera(scene, {
target: icosphere,
alpha: Math.PI / 4,
beta: Math.PI / 3,
radius: 4,
aspect: canvas.width / canvas.height,
controls: { element: canvas, pointer: true },
});
await camera.ready;
const lights = [
new AmbientLight(scene, { color: [0.02, 0.45, 0.48], intensity: 0.5 }),
new SpotLight(scene, {
position: [-2.8, 3, -2.8],
rotor: [-0.628, -0.628, 0, 0.4597],
color: [1, 0.22, 0.03],
intensity: 220,
range: 7,
innerAngle: 0.32,
outerAngle: 0.72,
}),
new PointLight(scene, {
position: [3.4, 1.8, 3.6],
color: [0.9, 0.04, 0.38],
intensity: 45,
range: 14,
radius: 1.5,
}),
];
await Promise.all(lights.map((light) => light.ready));
log("Icosphere ready — teal ambience, orange rim, and soft magenta fill are active.");
export default {
scene,
icosphere,
ground,
lights,
dispose() {
camera.detachControls();
scene.dispose();
},
};`;
const snippets = {
camera: `// Orbit camera — drag to orbit and use the wheel to zoom.
const orbitCamera = new ArcRotateCamera(scene, {
target: icosphere,
alpha: 0,
beta: Math.PI / 2,
radius: 3,
controls: { element: canvas, pointer: true },
});
await orbitCamera.ready;`,
light: `// A warm point light backed by shared rows.
const light = new PointLight(scene, {
position: [0, 0.7, 1],
color: [1, 0.68, 0.4],
intensity: 12,
range: 8,
});
await light.ready;`,
instances: `// Clones share geometry until one clone changes its vertex data.
for (let x = -2; x <= 2; x++) {
const instance = icosphere.clone({ position: [x * 0.7, 0.2, 0] });
await instance.ready;
}`,
rows: `// Application-owned hot data in the same shared arena.
const velocity = await scene.ensureRows("app.velocity", 1024, 16, "f32");
velocity.row(0).set([1, 0, 0, 0]);
log("velocity[0]", velocity.read(0));`,
post: `// Batch graph changes so the loadout is rebuilt once.
await scene.batchGraphUpdates(async () => {
const grade = new ColorGrading(scene, { toneMap: "aces", amount: 1 });
const fxaa = new FXAA(scene);
await Promise.all([grade.ready, fxaa.ready]);
});`,
};
const elements = Object.fromEntries(
[
"canvas",
"apply-loadout-button",
"clear-console",
"console-count",
"console-empty",
"console-lines",
"copy-loadout-code-button",
"cursor-status",
"dirty-indicator",
"editor",
"editor-diagnostics",
"editor-mode-status",
"encoding-status",
"format-button",
"fps-status",
"fullscreen-button",
"graph-tab-dirty",
"inspector",
"inspector-button",
"loadout-name",
"loadout-select",
"loadout-status",
"preview",
"preview-message",
"profile-adapter",
"profile-empty",
"profile-latency",
"profile-passes",
"profile-results",
"profile-total",
"project-title",
"render-graph-canvas",
"render-graph-editor",
"render-graph-hint",
"reset-button",
"resize-handle",
"resolution-status",
"revision-label",
"run-button",
"run-status",
"save-button",
"share-button",
"snippets-button",
"snippets-dialog",
"tab-dirty",
"toast",
"type-status",
"workspace-language",
"workbench",
].map((id) => [id, document.getElementById(id)]),
);
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
const nativeSetTimeout = window.setTimeout.bind(window);
const nativeSetInterval = window.setInterval.bind(window);
const nativeClearTimeout = window.clearTimeout.bind(window);
const nativeClearInterval = window.clearInterval.bind(window);
const playgroundTimerIDs = new Set();
function trackedSetTimeout(callback, delay, ...arguments_) {
let id;
const wrapped =
typeof callback === "function"
? (...callbackArguments) => {
playgroundTimerIDs.delete(id);
callback.apply(window, callbackArguments);
}
: callback;
id = nativeSetTimeout(wrapped, delay, ...arguments_);
playgroundTimerIDs.add(id);
return id;
}
function trackedSetInterval(callback, delay, ...arguments_) {
const id = nativeSetInterval(callback, delay, ...arguments_);
playgroundTimerIDs.add(id);
return id;
}
function trackedClearTimer(id) {
playgroundTimerIDs.delete(id);
nativeClearTimeout(id);
nativeClearInterval(id);
}
function clearPlaygroundTimers() {
for (const id of playgroundTimerIDs) {
nativeClearTimeout(id);
nativeClearInterval(id);
}
playgroundTimerIDs.clear();
}
window.__yawnPlaygroundTimers = {
ids: playgroundTimerIDs,
setTimeout: trackedSetTimeout,
setInterval: trackedSetInterval,
clearTimeout: trackedClearTimer,
clearInterval: trackedClearTimer,
clear: clearPlaygroundTimers,
};
const packages = Promise.all([
import("@yawn/handles"),
import("@yawn/core"),
]).then(([handles, core]) => ({ ...handles, ...core }));
let monaco;
let editor;
let model;
let current;
let currentID = "";
let currentRevision = 0;
let initialSource = defaultSource;
let generation = 0;
let lastRunSource = "";
let dirty = false;
let activePanel = "console";
let activeCodingTab = "scene";
let stopProfile;
let profileCore;
let toastTimer;
let frameRequest;
let sampledFrame = 0;
let sampledAt = 0;
let renderGraphEditor;
let renderGraphMenu;
let renderGraphMenuOutsideListener;
let graphDirty = false;
let loadoutsDirty = false;
let suppressGraphChanges = false;
let customLoadouts = [];
let currentLoadoutID = "beauty";
let compiledRenderGraph = "";
localStorage.removeItem("yawn:playground:loadouts");
localStorage.removeItem("yawn:playground:active-loadout");
localStorage.removeItem("yawn:playground:draft");
addEventListener("yawn-theme-change", (event) => {
monaco?.editor.setTheme(event.detail.resolved === "dark" ? "yawn" : "vs");
});
function savedLocation() {
const match = location.pathname.match(
/^\/playground\/([a-z0-9]{6,24})\/(\d+)\/?$/,
);
return match ? { id: match[1], revision: Number(match[2]) } : undefined;
}
function validCustomLoadouts(value) {
if (!Array.isArray(value)) return [];
return value
.filter(
(loadout) =>
loadout &&
typeof loadout.id === "string" &&
/^custom-[a-z0-9-]+$/u.test(loadout.id) &&
typeof loadout.name === "string" &&
loadout.name.trim() &&
loadout.layout &&
Array.isArray(loadout.layout.nodes) &&
Array.isArray(loadout.layout.links),
)
.slice(0, 40)
.map((loadout) => ({
id: loadout.id,
name: loadout.name.trim().slice(0, 64),
builtIn: false,
layout: loadout.layout,
}));
}
async function loadInitialPlayground() {
const saved = savedLocation();
if (saved) {
try {
const response = await fetch(
`/api/playgrounds/${saved.id}/${saved.revision}`,
);
if (!response.ok)
throw new Error("This saved playground could not be found.");
const result = await response.json();
currentID = result.id;
currentRevision = result.revision;
elements["project-title"].value = result.title;
elements["revision-label"].textContent = `Revision ${result.revision}`;
elements["share-button"].hidden = false;
initialSource = result.code;
customLoadouts = validCustomLoadouts(result.loadouts);
document.title = `${result.title} — Yawn Playground`;
return result.code;
} catch (error) {
history.replaceState(null, "", "/playground");
setRunStatus(error.message, "failed");
showToast(error.message);
}
}
customLoadouts = [];
return defaultSource;
}
function loadMonaco() {
return new Promise((resolve, reject) => {
if (!window.require?.config) {
reject(new Error("The TypeScript editor could not be loaded."));
return;
}
const monacoBase = `${window.location.origin}/assets/monaco/`;
const workerSource = `
self.MonacoEnvironment = { baseUrl: ${JSON.stringify(monacoBase)} };
importScripts(${JSON.stringify(`${monacoBase}vs/base/worker/workerMain.js`)});
`;
const workerURL = URL.createObjectURL(
new Blob([workerSource], { type: "text/javascript" }),
);
window.MonacoEnvironment = { getWorkerUrl: () => workerURL };
window.require.config({ paths: { vs: `${monacoBase}vs` } });
window.require(
["vs/editor/editor.main"],
() => resolve(window.monaco),
reject,
);
});
}
async function createEditor(source) {
monaco = await loadMonaco();
const types = await fetch("/assets/yawn.d.ts").then((response) =>
response.text(),
);
const defaults = monaco.languages.typescript.typescriptDefaults;
defaults.setEagerModelSync(true);
defaults.setCompilerOptions({
allowNonTsExtensions: true,
module: monaco.languages.typescript.ModuleKind.ESNext,
moduleResolution: monaco.languages.typescript.ModuleResolutionKind.NodeJs,
noEmit: false,
strict: true,
target: monaco.languages.typescript.ScriptTarget.ESNext,
});
defaults.setDiagnosticsOptions({
noSemanticValidation: false,
noSyntaxValidation: false,
});
defaults.addExtraLib(types, "file:///types/yawn.d.ts");
monaco.editor.defineTheme("yawn", {
base: "vs-dark",
inherit: true,
rules: [
{ token: "comment", foreground: "658198", fontStyle: "italic" },
{ token: "keyword", foreground: "C792EA" },
{ token: "string", foreground: "8FD5B6" },
{ token: "number", foreground: "F6AD7B" },
{ token: "type.identifier", foreground: "69C7F4" },
],
colors: {
"editor.background": "#0B1927",
"editor.foreground": "#C8D8E5",
"editor.lineHighlightBackground": "#102235",
"editor.selectionBackground": "#1D5B8066",
"editorCursor.foreground": "#55BDF4",
"editorGutter.background": "#0B1927",
"editorLineNumber.foreground": "#405B70",
"editorLineNumber.activeForeground": "#89A4B8",
"editorIndentGuide.background1": "#1B3143",
"editorWidget.background": "#102538",
"editorSuggestWidget.background": "#102538",
},
});
elements.editor.textContent = "";
model = monaco.editor.createModel(
source,
"typescript",
monaco.Uri.parse("file:///playground/scene.ts"),
);
editor = monaco.editor.create(elements.editor, {
model,
theme: document.documentElement.dataset.theme === "dark" ? "yawn" : "vs",
automaticLayout: true,
fontFamily:
"ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
fontLigatures: true,
fontSize: 12,
lineHeight: 20,
minimap: { enabled: false },
padding: { top: 12, bottom: 12 },
renderLineHighlight: "all",
scrollBeyondLastLine: false,
smoothScrolling: true,
tabSize: 2,
wordWrap: "off",
});
editor.addAction({
id: "yawn.run",
label: "Run playground",
keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter],
run,
});
editor.onDidChangeCursorPosition(({ position }) => {
if (activeCodingTab !== "scene") return;
elements["cursor-status"].textContent =
`Ln ${position.lineNumber}, Col ${position.column}`;
});
model.onDidChangeContent(() => {
markDirty(true);
});
monaco.editor.onDidChangeMarkers(([resource]) => {
if (resource.toString() === model.uri.toString()) updateDiagnostics();
});
elements["type-status"].textContent = "TypeScript language service ready";
updateDiagnostics();
}
function updateDiagnostics() {
if (!monaco || !model) return;
if (activeCodingTab !== "scene") return;
const markers = monaco.editor.getModelMarkers({ resource: model.uri });
const errors = markers.filter(
(marker) => marker.severity === monaco.MarkerSeverity.Error,
).length;
const warnings = markers.filter(
(marker) => marker.severity === monaco.MarkerSeverity.Warning,
).length;
const total = errors + warnings;
elements["editor-diagnostics"].classList.toggle("has-errors", errors > 0);
elements["editor-diagnostics"].lastChild.textContent = total
? ` ${errors} error${errors === 1 ? "" : "s"}, ${warnings} warning${warnings === 1 ? "" : "s"}`
: " No problems";
elements["type-status"].textContent = errors
? `${errors} TypeScript error${errors === 1 ? "" : "s"}`
: "TypeScript language service ready";
}
function markDirty(value, target = "code") {
dirty = value;
elements["dirty-indicator"].classList.toggle("visible", value);
if (!value) {
elements["tab-dirty"].classList.remove("visible");
elements["graph-tab-dirty"].classList.remove("visible");
graphDirty = false;
loadoutsDirty = false;
} else if (target === "graph") {
elements["graph-tab-dirty"].classList.add("visible");
} else {
elements["tab-dirty"].classList.add("visible");
}
}
function allLoadouts() {
return [...renderGraphPresets, ...customLoadouts];
}
function activeLoadout() {
return allLoadouts().find((loadout) => loadout.id === currentLoadoutID);
}
function loadoutLayout(loadout = activeLoadout()) {
if (!loadout) throw new Error("The selected render graph loadout is missing");
return loadout.layout;
}
function renderLoadoutOptions() {
const builtIn = document.createElement("optgroup");
const saved = document.createElement("optgroup");
builtIn.label = "Prepared loadouts";
saved.label = "Playground loadouts";
for (const loadout of renderGraphPresets) {
const option = document.createElement("option");
option.value = loadout.id;
option.textContent = loadout.name;
builtIn.append(option);
}
for (const loadout of customLoadouts) {
const option = document.createElement("option");
option.value = loadout.id;
option.textContent = loadout.name;
saved.append(option);
}
elements["loadout-select"].replaceChildren(
builtIn,
...(customLoadouts.length ? [saved] : []),
);
elements["loadout-select"].value = currentLoadoutID;
const currentLoadout = activeLoadout();
elements["loadout-name"].value = currentLoadout?.name ?? "";
}
function setLoadoutStatus(message, state = "ready") {
elements["loadout-status"].className =
state === "busy" ? "busy" : state === "failed" ? "failed" : "";
elements["loadout-status"].title = message;
elements["loadout-status"].lastChild.textContent = ` ${message}`;
}
function markGraphChanged() {
if (suppressGraphChanges) return;
compiledRenderGraph = "";
graphDirty = true;
loadoutsDirty = true;
markDirty(true, "graph");
setLoadoutStatus("Unsaved graph changes");
}
function stageActiveLoadout(layout) {
const currentLoadout = activeLoadout();
if (!currentLoadout)
throw new Error("The selected render graph loadout is missing");
const name =
elements["loadout-name"].value.trim() ||
`${currentLoadout.name || "Render graph"} custom`;
let id = currentLoadout.builtIn ? "" : currentLoadout.id;
if (!id) id = `custom-${crypto.randomUUID().slice(0, 8)}`;
const changed =
currentLoadout.builtIn || graphDirty || currentLoadout.name !== name;
const staged = changed
? {
id,
name: name.slice(0, 64),
builtIn: false,
layout,
}
: currentLoadout;
if (changed) {
const index = customLoadouts.findIndex((loadout) => loadout.id === id);
if (index < 0) customLoadouts.push(staged);
else customLoadouts[index] = staged;
loadoutsDirty = true;
}
currentLoadoutID = id;
graphDirty = false;
renderLoadoutOptions();
if (changed) markDirty(true, "graph");
else elements["graph-tab-dirty"].classList.toggle("visible", loadoutsDirty);
return staged;
}
function generatedRenderGraphBlock(name, serialized) {
const safeName = name.replace(/[\r\n]+/gu, " ").replaceAll("*/", "* /");
return `${renderGraphComment} ${safeName} */ { const renderGraph = ${JSON.stringify(serialized)}; const loadoutId = await scene.compileRenderGraph(renderGraph); await scene.setLoadout(loadoutId); }`;
}
function withAppendedRenderGraph(source, name, serialized) {
const block = generatedRenderGraphBlock(name, serialized);
return `${source.trimEnd()}\n\n${block}\n`;
}
async function compileLoadout(layout) {
const scene = current?.scene;
if (!scene?.getRenderGraph)
throw new Error("Run a Scene before compiling a render graph loadout");
return compileFxNodeRenderGraph(layout, await scene.getRenderGraph());
}
function closeRenderGraphMenu() {
if (renderGraphMenuOutsideListener) {
document.removeEventListener(
"pointerdown",
renderGraphMenuOutsideListener,
true,
);
renderGraphMenuOutsideListener = undefined;
}
renderGraphMenu?.remove();
renderGraphMenu = undefined;
}
function renderGraphOption(item, request, groupTitle) {
const button = document.createElement("button");
button.type = "button";
button.className = "rg-menu-option";
button.dataset.typeId = item.typeId;
button.dataset.search =
`${item.title} ${item.description} ${item.keywords || ""} ${groupTitle}`.toLowerCase();
const title = document.createElement("span");
const add = document.createElement("b");
const description = document.createElement("small");
title.textContent = item.title;
add.textContent = "+";
description.textContent = item.description;
button.append(title, add, description);
button.addEventListener("click", async () => {
closeRenderGraphMenu();
try {
await request.add(item.typeId);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setLoadoutStatus(message, "failed");
showToast(message);
}
});
return button;
}
function openRenderGraphMenu(request) {
closeRenderGraphMenu();
const menu = document.createElement("div");
menu.className = "rg-node-menu";
menu.dataset.renderGraphMenu = "";
menu.setAttribute("role", "menu");
menu.setAttribute("aria-label", "Add render graph node");
const search = document.createElement("label");
search.className = "rg-menu-search";
search.innerHTML = '⌕';
const input = document.createElement("input");
input.type = "search";
input.placeholder = "Search nodes";
input.setAttribute("aria-label", "Search render graph nodes");
search.append(input);
const groups = document.createElement("div");
groups.className = "rg-menu-groups";
const results = document.createElement("div");
results.className = "rg-menu-results";
results.hidden = true;
const empty = document.createElement("div");
empty.className = "rg-menu-empty";
empty.textContent = "No render graph nodes match.";
empty.hidden = true;
const openGroup = (target) => {
for (const group of groups.children)
group.classList.toggle("open", group === target);
};
for (const group of renderGraphNodeGroups) {
const host = document.createElement("div");
host.className = "rg-menu-group";
host.dataset.group = group.id;
const trigger = document.createElement("button");
trigger.type = "button";
trigger.className = "rg-menu-group-trigger";
trigger.setAttribute("aria-haspopup", "menu");
trigger.innerHTML = `${group.title}
${group.items.length} nodes`;
const submenu = document.createElement("div");
submenu.className = "rg-menu-submenu";
submenu.setAttribute("role", "menu");
const heading = document.createElement("strong");
heading.textContent = group.title;
submenu.append(
heading,
...group.items.map((item) =>
renderGraphOption(item, request, group.title),
),
);
trigger.addEventListener("pointerenter", () => openGroup(host));
trigger.addEventListener("click", () => openGroup(host));
host.append(trigger, submenu);
groups.append(host);
}
input.addEventListener("input", () => {
const query = input.value.trim().toLowerCase();
groups.hidden = Boolean(query);
results.hidden = !query;
results.replaceChildren();
if (query) {
const matches = renderGraphNodeGroups.flatMap((group) =>
group.items
.map((item) => renderGraphOption(item, request, group.title))
.filter((button) => button.dataset.search.includes(query)),
);
results.append(...matches);
empty.hidden = matches.length > 0;
} else {
empty.hidden = true;
}
});
input.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
event.preventDefault();
closeRenderGraphMenu();
elements["render-graph-canvas"].focus();
}
});
menu.append(search, groups, results, empty);
document.body.append(menu);
const canvasBounds = elements["render-graph-canvas"].getBoundingClientRect();
const menuBounds = menu.getBoundingClientRect();
const margin = 8;
const x = canvasBounds.left + request.viewPosition.x;
const y = canvasBounds.top + request.viewPosition.y;
const needsLeftSubmenu = x + menuBounds.width + 270 > innerWidth - margin;
menu.classList.toggle("open-left", needsLeftSubmenu);
menu.style.left = `${Math.max(margin, Math.min(x, innerWidth - menuBounds.width - margin))}px`;
menu.style.top = `${Math.max(margin, Math.min(y, innerHeight - menuBounds.height - margin))}px`;
menu.style.visibility = "visible";
renderGraphMenu = menu;
input.focus();
renderGraphMenuOutsideListener = (event) => {
if (!menu.contains(event.target)) closeRenderGraphMenu();
};
document.addEventListener(
"pointerdown",
renderGraphMenuOutsideListener,
true,
);
}
async function switchLoadout(id) {
const next = allLoadouts().find((loadout) => loadout.id === id);
if (!next || !renderGraphEditor) return;
if (graphDirty) stageActiveLoadout(await renderGraphEditor.save());
suppressGraphChanges = true;
try {
currentLoadoutID = next.id;
await renderGraphEditor.load(loadoutLayout(next));
graphDirty = false;
elements["graph-tab-dirty"].classList.toggle("visible", loadoutsDirty);
renderLoadoutOptions();
setLoadoutStatus(`${next.name} selected · Apply to update scene.ts`);
window.__yawnRenderGraph.selected = next.id;
window.__yawnRenderGraph.layout = loadoutLayout(next);
} finally {
suppressGraphChanges = false;
}
}
async function applyLoadout() {
if (!renderGraphEditor || elements["apply-loadout-button"].disabled) return;
elements["apply-loadout-button"].disabled = true;
try {
const layout = await renderGraphEditor.save();
const staged = stageActiveLoadout(layout);
if (model.getValue() !== lastRunSource && !(await run()))
throw new Error(
"The Scene must run successfully before applying a loadout",
);
setLoadoutStatus(`Compiling ${staged.name}…`, "busy");
compiledRenderGraph = await compileLoadout(layout);
const source = withAppendedRenderGraph(
model.getValue(),
staged.name,
compiledRenderGraph,
);
if (source !== model.getValue()) model.setValue(source);
window.__yawnRenderGraph.selected = staged.id;
window.__yawnRenderGraph.layout = structuredClone(layout);
window.__yawnRenderGraph.serialized = compiledRenderGraph;
if (!(await run()))
throw new Error("The generated render graph loadout could not run");
setLoadoutStatus(`${staged.name} applied from scene.ts`);
showToast(`Appended ${staged.name} to scene.ts and reran the playground`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setLoadoutStatus(message, "failed");
showToast(message);
} finally {
elements["apply-loadout-button"].disabled = false;
}
}
async function copyLoadoutCode() {
const button = elements["copy-loadout-code-button"];
if (!renderGraphEditor || button.disabled) return;
button.disabled = true;
try {
const layout = await renderGraphEditor.save();
compiledRenderGraph = await compileLoadout(layout);
const name = elements["loadout-name"].value.trim() || activeLoadout().name;
const code = generatedRenderGraphBlock(name, compiledRenderGraph);
await navigator.clipboard.writeText(code);
window.__yawnRenderGraph.serialized = compiledRenderGraph;
showToast(`${activeLoadout().name} loadout code copied`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setLoadoutStatus(message, "failed");
showToast(message);
} finally {
button.disabled = false;
}
}
async function initializeRenderGraphWorkspace() {
renderLoadoutOptions();
renderGraphEditor = await createRenderGraphEditor({
canvas: elements["render-graph-canvas"],
initialLayout: loadoutLayout(),
workerUrl: "/pkg/fxnode-worker.js",
onChange: markGraphChanged,
onAddNodeMenu: openRenderGraphMenu,
onError(error) {
const message = error instanceof Error ? error.message : String(error);
setLoadoutStatus(message, "failed");
},
});
window.__yawnRenderGraph = {
editor: renderGraphEditor,
layout: loadoutLayout(),
presets: renderGraphPresets.map(({ id, name }) => ({ id, name })),
selected: currentLoadoutID,
serialized: compiledRenderGraph,
switchLoadout,
};
setLoadoutStatus(`${activeLoadout().name} selected`);
}
function activateCodingTab(tab) {
activeCodingTab = tab;
closeRenderGraphMenu();
for (const button of document.querySelectorAll("[data-editor-tab]")) {
const active = button.dataset.editorTab === tab;
button.classList.toggle("active", active);
button.setAttribute("aria-selected", String(active));
}
elements.editor.hidden = tab !== "scene";
elements["render-graph-editor"].hidden = tab !== "render-graph";
for (const target of document.querySelectorAll("[data-code-tool]"))
target.hidden = tab !== "scene";
for (const target of document.querySelectorAll("[data-graph-tool]"))
target.hidden = tab !== "render-graph";
if (tab === "render-graph") {
elements["workspace-language"].textContent = "Render graph";
elements["type-status"].textContent = "FxNode worker ready";
elements["cursor-status"].textContent = "Node canvas";
elements["editor-mode-status"].textContent = "FxNode";
elements["encoding-status"].textContent = "S-expression v1";
elements["editor-diagnostics"].lastChild.textContent =
" Graph valid on save";
elements["editor-diagnostics"].classList.remove("has-errors");
requestAnimationFrame(() => void renderGraphEditor?.syncViewport());
} else {
elements["workspace-language"].textContent = "TypeScript";
const position = editor?.getPosition();
elements["cursor-status"].textContent = position
? `Ln ${position.lineNumber}, Col ${position.column}`
: "Ln 1, Col 1";
elements["editor-mode-status"].textContent = "Spaces: 2";
elements["encoding-status"].textContent = "UTF-8";
updateDiagnostics();
editor?.layout();
editor?.focus();
}
}
async function transpile() {
const worker = await monaco.languages.typescript.getTypeScriptWorker();
const client = await worker(model.uri);
const emitted = await client.getEmitOutput(model.uri.toString());
const output = emitted.outputFiles.find((file) => file.name.endsWith(".js"));
if (emitted.emitSkipped || !output)
throw new Error("TypeScript could not emit this scene.");
const code = output.text
.replace(/^\s*import\s+(?:[\s\S]*?\s+from\s+)?["'][^"']+["'];?\s*$/gm, "")
.replace(/^\s*export\s*\{\s*\};?\s*$/gm, "")
.replace(/\bexport\s+default\s+/, "__yawnPlaygroundDefaultExport = ");
return `let __yawnPlaygroundDefaultExport;\n${code}\nreturn __yawnPlaygroundDefaultExport;`;
}
async function disposeCurrent() {
stopProfile?.();
stopProfile = undefined;
if (profileCore) {
try {
await profileCore.setProfiler(false);
} catch {
// A disposed core cannot disable a profiler that is already gone.
}
profileCore = undefined;
}
const value = current;
current = undefined;
delete window.__yawnPlayground;
if (!value) return;
if (typeof value === "function") await value();
else if (typeof value.dispose === "function") await value.dispose();
else if (value.scene?.dispose) value.scene.dispose();
}
function replaceCanvas() {
const previous = elements.canvas;
const canvas = document.createElement("canvas");
canvas.id = "canvas";
canvas.setAttribute("aria-label", "Yawn WebGPU preview");
const scale = Math.min(devicePixelRatio || 1, 2);
canvas.width = Math.max(1, Math.round(elements.preview.clientWidth * scale));
canvas.height = Math.max(
1,
Math.round(elements.preview.clientHeight * scale),
);
previous.replaceWith(canvas);
elements.canvas = canvas;
elements["resolution-status"].textContent =
`${canvas.width} × ${canvas.height}`;
return canvas;
}
async function run() {
if (!editor || elements["run-button"].disabled) return false;
clearPlaygroundTimers();
const runID = ++generation;
elements["run-button"].disabled = true;
elements["run-button"].lastChild.textContent = " Running";
clearConsole();
setRunStatus("Compiling", "running");
showPreviewMessage(
"Compiling TypeScript",
"Checking types and preparing the render graph.",
);
try {
if (!crossOriginIsolated)
throw new Error("Cross-origin isolation is disabled on this page.");
if (!navigator.gpu)
throw new Error("WebGPU is not available in this browser.");
const [code, api] = await Promise.all([transpile(), packages]);
await disposeCurrent();
const canvas = replaceCanvas();
const names = Object.keys(api);
const started = performance.now();
const result = await new AsyncFunction(
...names,
"canvas",
"log",
"setTimeout",
"setInterval",
"clearTimeout",
"clearInterval",
code,
)(
...names.map((name) => api[name]),
canvas,
(...values) => appendConsole(values.map(formatValue).join(" ")),
trackedSetTimeout,
trackedSetInterval,
trackedClearTimer,
trackedClearTimer,
);
if (runID !== generation) {
if (result?.dispose) await result.dispose();
return false;
}
current = result;
window.__yawnPlayground = result;
lastRunSource = model.getValue();
elements["preview-message"].hidden = true;
setRunStatus(
`Running · ${Math.round(performance.now() - started)} ms`,
"ready",
);
if (activePanel === "profile") await attachProfiler();
return true;
} catch (error) {
if (runID === generation) {
const message = error instanceof Error ? error.message : String(error);
appendConsole(message, true);
setRunStatus("Run failed", "failed");
showPreviewMessage("Scene could not start", message);
openInspector("console");
}
return false;
} finally {
if (runID === generation) {
elements["run-button"].disabled = false;
elements["run-button"].innerHTML =
'▶ Run Ctrl ↵';
}
}
}
function currentCore() {
return current?.scene?.core ?? current?.core;
}
async function attachProfiler() {
stopProfile?.();
stopProfile = undefined;
profileCore = currentCore();
if (!profileCore?.onProfile || !profileCore?.setProfiler) {
setProfileMessage("Run a scene to begin profiling.");
return;
}
stopProfile = profileCore.onProfile(renderProfile);
const supported = await profileCore.setProfiler(true);
if (!supported)
setProfileMessage(
"Timestamp queries are not supported by this GPU adapter.",
);
}
function setProfileMessage(message) {
elements["profile-empty"].querySelector("strong").textContent = message;
elements["profile-empty"].hidden = false;
elements["profile-results"].hidden = true;
}
function renderProfile(profile) {
elements["profile-empty"].hidden = true;
elements["profile-results"].hidden = false;
elements["profile-total"].textContent =
`${profile.milliseconds.toFixed(2)} ms`;
elements["profile-latency"].textContent =
`${profile.readbackMilliseconds.toFixed(2)} ms`;
elements["profile-adapter"].textContent = profile.adapter || "WebGPU adapter";
elements["profile-adapter"].title = profile.adapter || "WebGPU adapter";
elements["profile-passes"].replaceChildren(
...profile.passes.map((pass) => {
const row = document.createElement("tr");
const name = document.createElement("td");
const time = document.createElement("td");
name.textContent = pass.name;
time.textContent = `${pass.milliseconds.toFixed(2)} ms`;
row.append(name, time);
return row;
}),
);
}
function formatValue(value) {
if (typeof value === "string") return value;
if (value instanceof Error) return value.message;
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
function appendConsole(message, error = false) {
elements["console-empty"].hidden = true;
const line = document.createElement("li");
line.textContent = message;
line.classList.toggle("error", error);
elements["console-lines"].append(line);
elements["console-count"].textContent =
elements["console-lines"].children.length;
line.scrollIntoView({ block: "nearest" });
}
function clearConsole() {
elements["console-lines"].replaceChildren();
elements["console-empty"].hidden = false;
elements["console-count"].textContent = "0";
}
function setRunStatus(message, state) {
elements["run-status"].className =
state === "running" ? "running" : state === "failed" ? "failed" : "";
elements["run-status"].lastChild.textContent = ` ${message}`;
}
function showPreviewMessage(title, detail) {
elements["preview-message"].hidden = false;
elements["preview-message"].querySelector("strong").textContent = title;
elements["preview-message"].querySelector("small").textContent = detail;
}
function openInspector(panel) {
activePanel = panel;
elements.inspector.classList.remove("collapsed");
elements["inspector-button"].setAttribute("aria-pressed", "true");
for (const button of document.querySelectorAll(
".inspector-tabs [data-panel]",
)) {
const active = button.dataset.panel === panel;
button.classList.toggle("active", active);
button.setAttribute("aria-selected", String(active));
}
for (const target of document.querySelectorAll(".inspector-panel")) {
target.classList.toggle("active", target.id === `${panel}-panel`);
}
if (panel === "profile") void attachProfiler();
else if (profileCore) {
stopProfile?.();
stopProfile = undefined;
void profileCore.setProfiler(false).catch(() => undefined);
profileCore = undefined;
}
}
async function save() {
if (!editor || elements["save-button"].disabled) return;
elements["save-button"].disabled = true;
try {
const hadLoadoutChanges = loadoutsDirty;
let stagedLoadout;
if (graphDirty && renderGraphEditor)
stagedLoadout = stageActiveLoadout(await renderGraphEditor.save());
const response = await fetch("/api/playgrounds", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: currentID,
title: elements["project-title"].value.trim() || "Untitled playground",
code: model.getValue(),
loadouts: customLoadouts,
}),
});
const result = await response.json();
if (!response.ok)
throw new Error(result.error || "The playground could not be saved.");
currentID = result.id;
currentRevision = result.revision;
initialSource = result.code;
customLoadouts = validCustomLoadouts(result.loadouts);
renderLoadoutOptions();
history.pushState(null, "", `/playground/${result.id}/${result.revision}`);
elements["revision-label"].textContent = `Revision ${result.revision}`;
elements["share-button"].hidden = false;
markDirty(false);
if (stagedLoadout) setLoadoutStatus("Saved · Apply to update scene.ts");
else if (hadLoadoutChanges)
setLoadoutStatus("Applied code · saved with playground");
showToast(`Saved revision ${result.revision}`);
} catch (error) {
showToast(error instanceof Error ? error.message : String(error));
} finally {
elements["save-button"].disabled = false;
}
}
function showToast(message) {
clearTimeout(toastTimer);
elements.toast.textContent = message;
elements.toast.classList.add("visible");
toastTimer = setTimeout(
() => elements.toast.classList.remove("visible"),
2400,
);
}
function insertSnippet(name) {
const selection = editor.getSelection();
const prefix = model.getValueInRange(selection).endsWith("\n") ? "" : "\n\n";
editor.executeEdits("snippet", [
{
range: selection,
text: `${prefix}${snippets[name]}\n`,
forceMoveMarkers: true,
},
]);
elements["snippets-dialog"].close();
editor.focus();
}
function sampleFPS(now = performance.now()) {
try {
const core = currentCore();
if (!core) throw new Error();
const frame = Number(core.array("signals").row(0)[1]);
if (!sampledAt || frame < sampledFrame) {
sampledAt = now;
sampledFrame = frame;
} else if (now - sampledAt >= 500) {
const fps = ((frame - sampledFrame) * 1000) / (now - sampledAt);
elements["fps-status"].textContent =
`${fps < 10 ? fps.toFixed(1) : Math.round(fps)} FPS`;
sampledAt = now;
sampledFrame = frame;
}
} catch {
sampledAt = now;
sampledFrame = 0;
elements["fps-status"].textContent = "0 FPS";
}
frameRequest = requestAnimationFrame(sampleFPS);
}
elements["run-button"].addEventListener("click", run);
elements["save-button"].addEventListener("click", save);
elements["apply-loadout-button"].addEventListener("click", applyLoadout);
elements["copy-loadout-code-button"].addEventListener("click", copyLoadoutCode);
elements["loadout-name"].addEventListener("input", markGraphChanged);
elements["loadout-select"].addEventListener(
"change",
(event) =>
void switchLoadout(event.target.value).catch((error) => {
const message = error instanceof Error ? error.message : String(error);
setLoadoutStatus(message, "failed");
showToast(message);
}),
);
for (const button of document.querySelectorAll("[data-editor-tab]")) {
button.addEventListener("click", () =>
activateCodingTab(button.dataset.editorTab),
);
}
elements["share-button"].addEventListener("click", async () => {
await navigator.clipboard.writeText(location.href);
showToast("Playground URL copied");
});
elements["project-title"].addEventListener("input", () => markDirty(true));
elements["reset-button"].addEventListener("click", () => {
model?.setValue(initialSource);
void run();
});
elements["format-button"].addEventListener("click", () =>
editor?.getAction("editor.action.formatDocument").run(),
);
elements["snippets-button"].addEventListener("click", () =>
elements["snippets-dialog"].showModal(),
);
elements["snippets-dialog"]
.querySelector("[data-close-dialog]")
.addEventListener("click", () => elements["snippets-dialog"].close());
elements["snippets-dialog"].addEventListener("click", (event) => {
if (event.target === elements["snippets-dialog"])
elements["snippets-dialog"].close();
});
for (const button of document.querySelectorAll("[data-snippet]")) {
button.addEventListener("click", () => insertSnippet(button.dataset.snippet));
}
elements["snippets-dialog"]
.querySelector("input")
.addEventListener("input", (event) => {
const query = event.target.value.trim().toLowerCase();
for (const button of document.querySelectorAll("[data-snippet]")) {
button.hidden =
Boolean(query) && !button.textContent.toLowerCase().includes(query);
}
});
for (const button of document.querySelectorAll(
".inspector-tabs [data-panel]",
)) {
button.addEventListener("click", () => openInspector(button.dataset.panel));
}
elements["clear-console"].addEventListener("click", clearConsole);
elements["inspector-button"].addEventListener("click", () => {
const collapsed = elements.inspector.classList.toggle("collapsed");
elements["inspector-button"].setAttribute("aria-pressed", String(!collapsed));
});
elements["fullscreen-button"].addEventListener("click", () => {
if (document.fullscreenElement) void document.exitFullscreen();
else void document.documentElement.requestFullscreen();
});
elements["resize-handle"].addEventListener("pointerdown", (event) => {
if (innerWidth <= 800) return;
elements["resize-handle"].setPointerCapture(event.pointerId);
elements["resize-handle"].classList.add("dragging");
});
elements["resize-handle"].addEventListener("pointermove", (event) => {
if (!elements["resize-handle"].hasPointerCapture(event.pointerId)) return;
const bounds = elements.workbench.getBoundingClientRect();
const percentage = Math.min(
70,
Math.max(30, ((event.clientX - bounds.left) / bounds.width) * 100),
);
elements.workbench.style.setProperty("--editor-width", `${percentage}%`);
});
elements["resize-handle"].addEventListener("pointerup", (event) => {
elements["resize-handle"].releasePointerCapture(event.pointerId);
elements["resize-handle"].classList.remove("dragging");
});
addEventListener("popstate", () => location.reload());
addEventListener("beforeunload", (event) => {
if (!dirty) return;
event.preventDefault();
event.returnValue = "";
});
addEventListener("pagehide", (event) => {
if (event.persisted) return;
cancelAnimationFrame(frameRequest);
closeRenderGraphMenu();
void renderGraphEditor?.destroy();
void disposeCurrent();
});
try {
const source = await loadInitialPlayground();
await createEditor(source);
await initializeRenderGraphWorkspace();
markDirty(false);
sampleFPS();
await run();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
elements.editor.textContent = message;
setRunStatus("Editor failed", "failed");
showPreviewMessage("Editor could not start", message);
}