Files
yawn/server/web/assets/playground.js
T
Ampandheaust 6153223fd5 Add FxNode render graph playground
Add persistent render graph loadouts, grouped node authoring, code-appending apply behavior, explicit playground lighting, and resilient worker startup.

Amp-Thread-ID: https://ampcode.com/threads/T-01a033d4-0e44-708e-af0e-6166283416ff
Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
2026-08-24 17:45:48 +00:00

1304 lines
42 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = '<span aria-hidden="true">⌕</span>';
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 = `<span class="rg-menu-group-icon">${group.icon}</span><span>${group.title}<br><small>${group.items.length} nodes</small></span><span class="rg-menu-chevron">›</span>`;
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 =
'<span aria-hidden="true">▶</span> Run <kbd>Ctrl ↵</kbd>';
}
}
}
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);
}