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 = ''; 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.icon}${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) { 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 = ' 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 { 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); }