Files
yawn/server/web/assets/playground.js
T
Ampandheaust 7070799862 Add Go CDN and playground server
Build Core and Handles into in-memory CDN modules, add the marketing site and tutorial docs, and provide a SQLite-backed WebGPU playground with TypeScript tooling and profiling.

Amp-Thread-ID: https://ampcode.com/threads/T-01a02485-5574-707c-bff4-5668d83bee8a
Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
2026-08-21 16:42:02 +00:00

626 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = '<span aria-hidden="true">▶</span> Run <kbd>Ctrl ↵</kbd>';
}
}
}
function currentCore() {
return current?.scene?.core ?? current?.core;
}
async function attachProfiler() {
stopProfile?.();
stopProfile = undefined;
profileCore = currentCore();
if (!profileCore?.onProfile || !profileCore?.setProfiler) {
setProfileMessage("Run a scene to begin profiling.");
return;
}
stopProfile = profileCore.onProfile(renderProfile);
const supported = await profileCore.setProfiler(true);
if (!supported) setProfileMessage("Timestamp queries are not supported by this GPU adapter.");
}
function setProfileMessage(message) {
elements["profile-empty"].querySelector("strong").textContent = message;
elements["profile-empty"].hidden = false;
elements["profile-results"].hidden = true;
}
function renderProfile(profile) {
elements["profile-empty"].hidden = true;
elements["profile-results"].hidden = false;
elements["profile-total"].textContent = `${profile.milliseconds.toFixed(2)} ms`;
elements["profile-latency"].textContent = `${profile.readbackMilliseconds.toFixed(2)} ms`;
elements["profile-adapter"].textContent = profile.adapter || "WebGPU adapter";
elements["profile-adapter"].title = profile.adapter || "WebGPU adapter";
elements["profile-passes"].replaceChildren(
...profile.passes.map((pass) => {
const row = document.createElement("tr");
const name = document.createElement("td");
const time = document.createElement("td");
name.textContent = pass.name;
time.textContent = `${pass.milliseconds.toFixed(2)} ms`;
row.append(name, time);
return row;
}),
);
}
function formatValue(value) {
if (typeof value === "string") return value;
if (value instanceof Error) return value.message;
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
function appendConsole(message, error = false) {
elements["console-empty"].hidden = true;
const line = document.createElement("li");
line.textContent = message;
line.classList.toggle("error", error);
elements["console-lines"].append(line);
elements["console-count"].textContent = elements["console-lines"].children.length;
line.scrollIntoView({ block: "nearest" });
}
function clearConsole() {
elements["console-lines"].replaceChildren();
elements["console-empty"].hidden = false;
elements["console-count"].textContent = "0";
}
function setRunStatus(message, state) {
elements["run-status"].className = state === "running" ? "running" : state === "failed" ? "failed" : "";
elements["run-status"].lastChild.textContent = ` ${message}`;
}
function showPreviewMessage(title, detail) {
elements["preview-message"].hidden = false;
elements["preview-message"].querySelector("strong").textContent = title;
elements["preview-message"].querySelector("small").textContent = detail;
}
function openInspector(panel) {
activePanel = panel;
elements.inspector.classList.remove("collapsed");
elements["inspector-button"].setAttribute("aria-pressed", "true");
for (const button of document.querySelectorAll(".inspector-tabs [data-panel]")) {
const active = button.dataset.panel === panel;
button.classList.toggle("active", active);
button.setAttribute("aria-selected", String(active));
}
for (const target of document.querySelectorAll(".inspector-panel")) {
target.classList.toggle("active", target.id === `${panel}-panel`);
}
if (panel === "profile") void attachProfiler();
else if (profileCore) {
stopProfile?.();
stopProfile = undefined;
void profileCore.setProfiler(false).catch(() => undefined);
profileCore = undefined;
}
}
async function save() {
if (!editor || elements["save-button"].disabled) return;
elements["save-button"].disabled = true;
try {
const 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);
}