const defaultSource = `import { ArcRotateCamera, ColorGrading, FXAA, Mesh, PBRMaterial, PointLight, Scene, } from "@yawn/handles"; const scene = new Scene(canvas, { hdr: true, fps: 60 }); await scene.ready; const material = new PBRMaterial(scene, { baseColor: [0.12, 0.58, 1, 1], metallic: 0.15, roughness: 0.35, }); await material.ready; const mesh = new Mesh(scene, { material, vertexData: { positions: [-0.72, -0.62, 0, 0.72, -0.62, 0, 0, 0.76, 0], normals: [0, 0, 1, 0, 0, 1, 0, 0, 1], indices: [0, 1, 2], }, }); await mesh.ready; log("Scene ready — drag your pointer across the preview."); const move = (event: PointerEvent) => { const bounds = canvas.getBoundingClientRect(); mesh.position.x = ((event.clientX - bounds.left) / bounds.width - 0.5) * 1.2; mesh.position.y = (0.5 - (event.clientY - bounds.top) / bounds.height) * 0.8; }; canvas.addEventListener("pointermove", move); export default { scene, mesh, dispose() { canvas.removeEventListener("pointermove", move); scene.dispose(); }, };`; const snippets = { camera: `// Orbit camera — drag to orbit and use the wheel to zoom. const camera = new ArcRotateCamera(scene, { target: mesh, alpha: 0, beta: Math.PI / 2, radius: 3, controls: { element: canvas, pointer: true }, }); await camera.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 = mesh.clone({ position: [x * 0.35, 0, 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", "clear-console", "console-count", "console-empty", "console-lines", "cursor-status", "dirty-indicator", "editor", "editor-diagnostics", "format-button", "fps-status", "fullscreen-button", "inspector", "inspector-button", "preview", "preview-message", "profile-adapter", "profile-empty", "profile-latency", "profile-passes", "profile-results", "profile-total", "project-title", "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", "workbench", ].map((id) => [id, document.getElementById(id)]), ); const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor; const packages = Promise.all([import("/pkg/handles.js"), import("/pkg/core.js")]).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 dirty = false; let activePanel = "console"; let stopProfile; let profileCore; let toastTimer; let frameRequest; let sampledFrame = 0; let sampledAt = 0; function savedLocation() { const match = location.pathname.match(/^\/playground\/([a-z0-9]{6,24})\/(\d+)\/?$/); return match ? { id: match[1], revision: Number(match[2]) } : undefined; } 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; document.title = `${result.title} — Yawn Playground`; return result.code; } catch (error) { history.replaceState(null, "", "/playground"); setRunStatus(error.message, "failed"); showToast(error.message); } } return localStorage.getItem("yawn:playground:draft") || 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: "yawn", 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 }) => { elements["cursor-status"].textContent = `Ln ${position.lineNumber}, Col ${position.column}`; }); model.onDidChangeContent(() => { markDirty(true); if (!currentID) localStorage.setItem("yawn:playground:draft", model.getValue()); }); 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; 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) { dirty = value; elements["dirty-indicator"].classList.toggle("visible", value); elements["tab-dirty"].classList.toggle("visible", value); } 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."); return 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+/, "return "); } 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; 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", code)( ...names.map((name) => api[name]), canvas, (...values) => appendConsole(values.map(formatValue).join(" ")), ); if (runID !== generation) { if (result?.dispose) await result.dispose(); return; } current = result; window.__yawnPlayground = result; elements["preview-message"].hidden = true; setRunStatus(`Running · ${Math.round(performance.now() - started)} ms`, "ready"); if (activePanel === "profile") await attachProfiler(); } 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"); } } finally { if (runID === generation) { elements["run-button"].disabled = false; elements["run-button"].innerHTML = ' Run Ctrl ↵'; } } } function currentCore() { return current?.scene?.core ?? current?.core; } async function attachProfiler() { stopProfile?.(); stopProfile = undefined; profileCore = currentCore(); if (!profileCore?.onProfile || !profileCore?.setProfiler) { setProfileMessage("Run a scene to begin profiling."); return; } stopProfile = profileCore.onProfile(renderProfile); const supported = await profileCore.setProfiler(true); if (!supported) setProfileMessage("Timestamp queries are not supported by this GPU adapter."); } function setProfileMessage(message) { elements["profile-empty"].querySelector("strong").textContent = message; elements["profile-empty"].hidden = false; elements["profile-results"].hidden = true; } function renderProfile(profile) { elements["profile-empty"].hidden = true; elements["profile-results"].hidden = false; elements["profile-total"].textContent = `${profile.milliseconds.toFixed(2)} ms`; elements["profile-latency"].textContent = `${profile.readbackMilliseconds.toFixed(2)} ms`; elements["profile-adapter"].textContent = profile.adapter || "WebGPU adapter"; elements["profile-adapter"].title = profile.adapter || "WebGPU adapter"; elements["profile-passes"].replaceChildren( ...profile.passes.map((pass) => { const row = document.createElement("tr"); const name = document.createElement("td"); const time = document.createElement("td"); name.textContent = pass.name; time.textContent = `${pass.milliseconds.toFixed(2)} ms`; row.append(name, time); return row; }), ); } function formatValue(value) { if (typeof value === "string") return value; if (value instanceof Error) return value.message; try { return JSON.stringify(value); } catch { return String(value); } } function appendConsole(message, error = false) { elements["console-empty"].hidden = true; const line = document.createElement("li"); line.textContent = message; line.classList.toggle("error", error); elements["console-lines"].append(line); elements["console-count"].textContent = elements["console-lines"].children.length; line.scrollIntoView({ block: "nearest" }); } function clearConsole() { elements["console-lines"].replaceChildren(); elements["console-empty"].hidden = false; elements["console-count"].textContent = "0"; } function setRunStatus(message, state) { elements["run-status"].className = state === "running" ? "running" : state === "failed" ? "failed" : ""; elements["run-status"].lastChild.textContent = ` ${message}`; } function showPreviewMessage(title, detail) { elements["preview-message"].hidden = false; elements["preview-message"].querySelector("strong").textContent = title; elements["preview-message"].querySelector("small").textContent = detail; } function openInspector(panel) { activePanel = panel; elements.inspector.classList.remove("collapsed"); elements["inspector-button"].setAttribute("aria-pressed", "true"); for (const button of document.querySelectorAll(".inspector-tabs [data-panel]")) { const active = button.dataset.panel === panel; button.classList.toggle("active", active); button.setAttribute("aria-selected", String(active)); } for (const target of document.querySelectorAll(".inspector-panel")) { target.classList.toggle("active", target.id === `${panel}-panel`); } if (panel === "profile") void attachProfiler(); else if (profileCore) { stopProfile?.(); stopProfile = undefined; void profileCore.setProfiler(false).catch(() => undefined); profileCore = undefined; } } async function save() { if (!editor || elements["save-button"].disabled) return; elements["save-button"].disabled = true; try { const 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(), }), }); 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; history.pushState(null, "", `/playground/${result.id}/${result.revision}`); elements["revision-label"].textContent = `Revision ${result.revision}`; elements["share-button"].hidden = false; localStorage.removeItem("yawn:playground:draft"); markDirty(false); 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["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", () => { cancelAnimationFrame(frameRequest); void disposeCurrent(); }); try { const source = await loadInitialPlayground(); await createEditor(source); 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); }