Files

1536 lines
49 KiB
JavaScript
Raw Permalink 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 {
buildFxNodeRenderGraph,
compileFxNodeRenderGraph,
createRenderGraphEditor,
materializeRenderGraph,
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",
"reset-button",
"resize-handle",
"resolution-status",
"revision-label",
"run-button",
"run-status",
"save-button",
"shader-editor",
"shader-empty",
"shader-node-name",
"shader-tab",
"shader-workspace",
"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 shaderEditor;
let shaderModel;
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 suppressShaderChanges = false;
let customLoadouts = [];
let preparedLoadouts = [];
let currentLoadoutID = "beauty";
let compiledRenderGraph = "";
let selectedShaderNodeID = "";
let shaderSelectionGeneration = 0;
let shaderWrite = Promise.resolve();
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",
},
});
if (
!monaco.languages.getLanguages().some((language) => language.id === "wgsl")
) {
monaco.languages.register({ id: "wgsl", extensions: [".wgsl"] });
monaco.languages.setMonarchTokensProvider("wgsl", {
keywords: [
"alias",
"break",
"case",
"const",
"continue",
"continuing",
"default",
"discard",
"else",
"enable",
"false",
"fn",
"for",
"if",
"let",
"loop",
"override",
"requires",
"return",
"struct",
"switch",
"true",
"var",
"while",
],
typeKeywords: [
"array",
"atomic",
"bool",
"f16",
"f32",
"i32",
"mat2x2",
"mat3x3",
"mat4x4",
"ptr",
"sampler",
"texture_2d",
"texture_cube",
"u32",
"vec2",
"vec3",
"vec4",
],
tokenizer: {
root: [
[/@[a-zA-Z_]\w*/u, "annotation"],
[
/[a-zA-Z_]\w*/u,
{
cases: {
"@keywords": "keyword",
"@typeKeywords": "type",
"@default": "identifier",
},
},
],
[/0[xX][0-9a-fA-F]+[iu]?/u, "number.hex"],
[/(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?[fihu]?/u, "number"],
[/\/\*/u, "comment", "@comment"],
[/\/\/.*$/u, "comment"],
[/[{}()[\]]/u, "delimiter.bracket"],
[/[;,.]/u, "delimiter"],
[/[+\-*\/%=!<>|&^~?:]+/u, "operator"],
],
comment: [
[/[^/*]+/u, "comment"],
[/\/\*/u, "comment", "@push"],
[/\*\//u, "comment", "@pop"],
[/[/*]/u, "comment"],
],
},
});
}
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();
});
shaderModel = monaco.editor.createModel(
"",
"wgsl",
monaco.Uri.parse("file:///playground/shader.wgsl"),
);
shaderEditor = monaco.editor.create(elements["shader-editor"], {
model: shaderModel,
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: true, maxColumn: 80, renderCharacters: false },
padding: { top: 12, bottom: 12 },
renderLineHighlight: "all",
scrollBeyondLastLine: false,
smoothScrolling: true,
tabSize: 2,
wordWrap: "off",
});
shaderEditor.onDidChangeCursorPosition(({ position }) => {
if (activeCodingTab !== "shader") return;
elements["cursor-status"].textContent =
`Ln ${position.lineNumber}, Col ${position.column}`;
});
shaderModel.onDidChangeContent(() => {
if (!suppressShaderChanges) queueShaderEdit();
});
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 reportShaderError(error) {
const message = error instanceof Error ? error.message : String(error);
setLoadoutStatus(message, "failed");
showToast(message);
}
function queueShaderEdit() {
if (!selectedShaderNodeID || !shaderModel || !renderGraphEditor) return;
const nodeId = selectedShaderNodeID;
const code = shaderModel.getValue();
shaderWrite = shaderWrite
.then(async () => {
await renderGraphEditor.setParameter(nodeId, "code", {
kind: "string",
value: code,
});
})
.catch(reportShaderError);
}
async function flushShaderEdits() {
await shaderWrite;
}
async function updateShaderSelection(nodeIds) {
const generation = ++shaderSelectionGeneration;
await flushShaderEdits();
const layout = renderGraphEditor ? await renderGraphEditor.save() : undefined;
if (generation !== shaderSelectionGeneration) return;
const node =
nodeIds.length === 1
? layout?.nodes.find((candidate) => candidate.id === nodeIds[0])
: undefined;
const code = node?.parameters?.code;
const shader = code?.kind === "string" ? code.value : undefined;
selectedShaderNodeID = shader === undefined ? "" : String(node.id);
elements["shader-tab"].disabled = !selectedShaderNodeID;
elements["shader-tab"].title = selectedShaderNodeID
? `Edit ${node.label}`
: "Select a render or compute pipeline to edit its WGSL";
elements["shader-node-name"].textContent =
node?.label || "Select a pipeline node";
elements["shader-empty"].hidden = Boolean(selectedShaderNodeID);
suppressShaderChanges = true;
try {
shaderModel?.setValue(shader ?? "");
} finally {
suppressShaderChanges = false;
}
if (activeCodingTab === "shader") {
shaderEditor?.layout();
shaderEditor?.focus();
}
}
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 [...preparedLoadouts, ...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;
}
async function refreshPreparedLoadouts() {
const scene = current?.scene;
if (!scene?.getRenderGraph)
throw new Error("Run a Scene before opening its physical render graph");
const baseGraph = await scene.getRenderGraph();
preparedLoadouts = renderGraphPresets.map((preset) => ({
id: preset.id,
name: preset.name,
builtIn: true,
layout: materializeRenderGraph(
buildFxNodeRenderGraph(preset.layout, baseGraph),
preset.id,
),
}));
}
function renderLoadoutOptions() {
const builtIn = document.createElement("optgroup");
const saved = document.createElement("optgroup");
builtIn.label = "Prepared loadouts";
saved.label = "Playground loadouts";
for (const loadout of preparedLoadouts) {
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");
let name =
elements["loadout-name"].value.trim() ||
`${currentLoadout.name || "Render graph"} custom`;
const changed = graphDirty || currentLoadout.name !== name;
if (!changed) {
graphDirty = false;
renderLoadoutOptions();
elements["graph-tab-dirty"].classList.toggle("visible", loadoutsDirty);
return currentLoadout;
}
if (currentLoadout.builtIn && name === currentLoadout.name)
name = `${name} custom`;
let id = currentLoadout.builtIn ? "" : currentLoadout.id;
if (!id) id = `custom-${crypto.randomUUID().slice(0, 8)}`;
const staged = {
id,
name: name.slice(0, 64),
builtIn: false,
layout,
};
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();
markDirty(true, "graph");
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) {
let next = allLoadouts().find((loadout) => loadout.id === id);
if (!next || !renderGraphEditor) return;
await flushShaderEdits();
if (graphDirty) stageActiveLoadout(await renderGraphEditor.save());
if (next.builtIn) {
await refreshPreparedLoadouts();
next = allLoadouts().find((loadout) => loadout.id === id);
if (!next) throw new Error("The selected prepared loadout is missing");
}
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 {
await flushShaderEdits();
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 {
await flushShaderEdits();
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() {
await refreshPreparedLoadouts();
renderLoadoutOptions();
renderGraphEditor = await createRenderGraphEditor({
canvas: elements["render-graph-canvas"],
initialLayout: loadoutLayout(),
workerUrl: "/pkg/fxnode-worker.js",
onChange: markGraphChanged,
onSelectionChange(nodeIds) {
void updateShaderSelection(nodeIds).catch(reportShaderError);
},
onAddNodeMenu: openRenderGraphMenu,
onError(error) {
const message = error instanceof Error ? error.message : String(error);
setLoadoutStatus(message, "failed");
},
});
window.__yawnRenderGraph = {
editor: renderGraphEditor,
layout: loadoutLayout(),
presets: preparedLoadouts.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";
elements["shader-workspace"].hidden = tab !== "shader";
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 if (tab === "shader") {
elements["workspace-language"].textContent = "WGSL";
elements["type-status"].textContent = selectedShaderNodeID
? elements["shader-node-name"].textContent
: "Select a pipeline node";
const position = shaderEditor?.getPosition();
elements["cursor-status"].textContent = position
? `Ln ${position.lineNumber}, Col ${position.column}`
: "Ln 1, Col 1";
elements["editor-mode-status"].textContent = "WGSL";
elements["encoding-status"].textContent = "UTF-8";
elements["editor-diagnostics"].lastChild.textContent = " Compiled on Apply";
elements["editor-diagnostics"].classList.remove("has-errors");
shaderEditor?.layout();
shaderEditor?.focus();
} 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 {
await flushShaderEdits();
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();
shaderEditor?.dispose();
shaderModel?.dispose();
void disposeCurrent();
});
try {
const source = await loadInitialPlayground();
await createEditor(source);
markDirty(false);
sampleFPS();
if (await run()) await initializeRenderGraphWorkspace();
else
setLoadoutStatus(
"Run a Scene to inspect its physical render graph",
"failed",
);
markDirty(false);
} 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);
}