Enhance CDN packages and playground

Amp-Thread-ID: https://ampcode.com/threads/T-01a02485-5574-707c-bff4-5668d83bee8a
Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
This commit is contained in:
Amp
2026-08-22 06:21:39 +00:00
co-authored by heaust
parent 7070799862
commit d95e099fa4
17 changed files with 1870 additions and 247 deletions
+255 -75
View File
@@ -1,60 +1,98 @@
const defaultSource = `import {
AmbientLight,
ArcRotateCamera,
ColorGrading,
FXAA,
Mesh,
Icosphere,
PBRMaterial,
Plane,
PointLight,
Scene,
SpotLight,
} from "@yawn/handles";
const scene = new Scene(canvas, { hdr: true, fps: 60 });
await scene.ready;
await scene.reserve({ nodes: 6, materials: 2 });
const material = new PBRMaterial(scene, {
baseColor: [0.12, 0.58, 1, 1],
metallic: 0.15,
roughness: 0.35,
const objectMaterial = new PBRMaterial(scene, {
baseColor: [0.82, 0.86, 0.9, 1],
metallic: 0.12,
roughness: 0.32,
});
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],
},
const groundMaterial = new PBRMaterial(scene, {
baseColor: [0.24, 0.28, 0.34, 1],
metallic: 0,
roughness: 0.82,
});
await mesh.ready;
await Promise.all([objectMaterial.ready, groundMaterial.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);
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: [2.2, 0.9, 1.8],
color: [1, 0.02, 0.45],
intensity: 16,
range: 6,
}),
];
await Promise.all(lights.map((light) => light.ready));
log("Icosphere ready — teal ambience, orange rim, and magenta accent are active.");
export default {
scene,
mesh,
icosphere,
ground,
lights,
dispose() {
canvas.removeEventListener("pointermove", move);
camera.detachControls();
scene.dispose();
},
};`;
const snippets = {
camera: `// Orbit camera — drag to orbit and use the wheel to zoom.
const camera = new ArcRotateCamera(scene, {
target: mesh,
const orbitCamera = new ArcRotateCamera(scene, {
target: icosphere,
alpha: 0,
beta: Math.PI / 2,
radius: 3,
controls: { element: canvas, pointer: true },
});
await camera.ready;`,
await orbitCamera.ready;`,
light: `// A warm point light backed by shared rows.
const light = new PointLight(scene, {
position: [0, 0.7, 1],
@@ -65,7 +103,7 @@ const light = new PointLight(scene, {
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] });
const instance = icosphere.clone({ position: [x * 0.7, 0.2, 0] });
await instance.ready;
}`,
rows: `// Application-owned hot data in the same shared arena.
@@ -123,9 +161,59 @@ const elements = Object.fromEntries(
);
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
const packages = Promise.all([import("/pkg/handles.js"), import("/pkg/core.js")]).then(
([handles, core]) => ({ ...handles, ...core }),
);
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;
@@ -143,8 +231,14 @@ let frameRequest;
let sampledFrame = 0;
let sampledAt = 0;
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+)\/?$/);
const match = location.pathname.match(
/^\/playground\/([a-z0-9]{6,24})\/(\d+)\/?$/,
);
return match ? { id: match[1], revision: Number(match[2]) } : undefined;
}
@@ -152,8 +246,11 @@ 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 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;
@@ -188,13 +285,19 @@ importScripts(${JSON.stringify(`${monacoBase}vs/base/worker/workerMain.js`)});
);
window.MonacoEnvironment = { getWorkerUrl: () => workerURL };
window.require.config({ paths: { vs: `${monacoBase}vs` } });
window.require(["vs/editor/editor.main"], () => resolve(window.monaco), reject);
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 types = await fetch("/assets/yawn.d.ts").then((response) =>
response.text(),
);
const defaults = monaco.languages.typescript.typescriptDefaults;
defaults.setEagerModelSync(true);
defaults.setCompilerOptions({
@@ -205,7 +308,10 @@ async function createEditor(source) {
strict: true,
target: monaco.languages.typescript.ScriptTarget.ESNext,
});
defaults.setDiagnosticsOptions({ noSemanticValidation: false, noSyntaxValidation: false });
defaults.setDiagnosticsOptions({
noSemanticValidation: false,
noSyntaxValidation: false,
});
defaults.addExtraLib(types, "file:///types/yawn.d.ts");
monaco.editor.defineTheme("yawn", {
base: "vs-dark",
@@ -232,12 +338,17 @@ async function createEditor(source) {
},
});
elements.editor.textContent = "";
model = monaco.editor.createModel(source, "typescript", monaco.Uri.parse("file:///playground/scene.ts"));
model = monaco.editor.createModel(
source,
"typescript",
monaco.Uri.parse("file:///playground/scene.ts"),
);
editor = monaco.editor.create(elements.editor, {
model,
theme: "yawn",
theme: document.documentElement.dataset.theme === "dark" ? "yawn" : "vs",
automaticLayout: true,
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
fontFamily:
"ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
fontLigatures: true,
fontSize: 12,
lineHeight: 20,
@@ -256,11 +367,13 @@ async function createEditor(source) {
run,
});
editor.onDidChangeCursorPosition(({ position }) => {
elements["cursor-status"].textContent = `Ln ${position.lineNumber}, Col ${position.column}`;
elements["cursor-status"].textContent =
`Ln ${position.lineNumber}, Col ${position.column}`;
});
model.onDidChangeContent(() => {
markDirty(true);
if (!currentID) localStorage.setItem("yawn:playground:draft", model.getValue());
if (!currentID)
localStorage.setItem("yawn:playground:draft", model.getValue());
});
monaco.editor.onDidChangeMarkers(([resource]) => {
if (resource.toString() === model.uri.toString()) updateDiagnostics();
@@ -272,8 +385,12 @@ async function createEditor(source) {
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 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
@@ -295,7 +412,8 @@ async function transpile() {
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.");
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, "")
@@ -329,33 +447,56 @@ function replaceCanvas() {
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));
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}`;
elements["resolution-status"].textContent =
`${canvas.width} × ${canvas.height}`;
return canvas;
}
async function run() {
if (!editor || elements["run-button"].disabled) return;
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.");
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.");
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)(
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();
@@ -364,7 +505,10 @@ async function run() {
current = result;
window.__yawnPlayground = result;
elements["preview-message"].hidden = true;
setRunStatus(`Running · ${Math.round(performance.now() - started)} ms`, "ready");
setRunStatus(
`Running · ${Math.round(performance.now() - started)} ms`,
"ready",
);
if (activePanel === "profile") await attachProfiler();
} catch (error) {
if (runID === generation) {
@@ -377,7 +521,8 @@ async function run() {
} finally {
if (runID === generation) {
elements["run-button"].disabled = false;
elements["run-button"].innerHTML = '<span aria-hidden="true">▶</span> Run <kbd>Ctrl ↵</kbd>';
elements["run-button"].innerHTML =
'<span aria-hidden="true">▶</span> Run <kbd>Ctrl ↵</kbd>';
}
}
}
@@ -396,7 +541,10 @@ async function attachProfiler() {
}
stopProfile = profileCore.onProfile(renderProfile);
const supported = await profileCore.setProfiler(true);
if (!supported) setProfileMessage("Timestamp queries are not supported by this GPU adapter.");
if (!supported)
setProfileMessage(
"Timestamp queries are not supported by this GPU adapter.",
);
}
function setProfileMessage(message) {
@@ -408,8 +556,10 @@ function setProfileMessage(message) {
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-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(
@@ -441,7 +591,8 @@ function appendConsole(message, error = false) {
line.textContent = message;
line.classList.toggle("error", error);
elements["console-lines"].append(line);
elements["console-count"].textContent = elements["console-lines"].children.length;
elements["console-count"].textContent =
elements["console-lines"].children.length;
line.scrollIntoView({ block: "nearest" });
}
@@ -452,7 +603,8 @@ function clearConsole() {
}
function setRunStatus(message, state) {
elements["run-status"].className = state === "running" ? "running" : state === "failed" ? "failed" : "";
elements["run-status"].className =
state === "running" ? "running" : state === "failed" ? "failed" : "";
elements["run-status"].lastChild.textContent = ` ${message}`;
}
@@ -466,7 +618,9 @@ 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]")) {
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));
@@ -497,7 +651,8 @@ async function save() {
}),
});
const result = await response.json();
if (!response.ok) throw new Error(result.error || "The playground could not be saved.");
if (!response.ok)
throw new Error(result.error || "The playground could not be saved.");
currentID = result.id;
currentRevision = result.revision;
initialSource = result.code;
@@ -518,13 +673,22 @@ function showToast(message) {
clearTimeout(toastTimer);
elements.toast.textContent = message;
elements.toast.classList.add("visible");
toastTimer = setTimeout(() => elements.toast.classList.remove("visible"), 2400);
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 }]);
editor.executeEdits("snippet", [
{
range: selection,
text: `${prefix}${snippets[name]}\n`,
forceMoveMarkers: true,
},
]);
elements["snippets-dialog"].close();
editor.focus();
}
@@ -539,7 +703,8 @@ function sampleFPS(now = performance.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`;
elements["fps-status"].textContent =
`${fps < 10 ? fps.toFixed(1) : Math.round(fps)} FPS`;
sampledAt = now;
sampledFrame = frame;
}
@@ -562,22 +727,34 @@ 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["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();
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]")) {
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);
@@ -598,7 +775,10 @@ elements["resize-handle"].addEventListener("pointerdown", (event) => {
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));
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) => {