Expand render graph playground editing

Amp-Thread-ID: https://ampcode.com/threads/T-01a033d4-0e44-708e-af0e-6166283416ff
Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
This commit is contained in:
Amp
2026-08-25 05:18:14 +00:00
co-authored by heaust
parent d71da85307
commit e7546e4b78
20 changed files with 3058 additions and 303 deletions
+258 -26
View File
@@ -1,6 +1,8 @@
import {
buildFxNodeRenderGraph,
compileFxNodeRenderGraph,
createRenderGraphEditor,
materializeRenderGraph,
renderGraphNodeGroups,
renderGraphPresets,
} from "@yawn/fxnode-rg";
@@ -167,7 +169,6 @@ const elements = Object.fromEntries(
"project-title",
"render-graph-canvas",
"render-graph-editor",
"render-graph-hint",
"reset-button",
"resize-handle",
"resolution-status",
@@ -175,6 +176,11 @@ const elements = Object.fromEntries(
"run-button",
"run-status",
"save-button",
"shader-editor",
"shader-empty",
"shader-node-name",
"shader-tab",
"shader-workspace",
"share-button",
"snippets-button",
"snippets-dialog",
@@ -243,6 +249,8 @@ const packages = Promise.all([
let monaco;
let editor;
let model;
let shaderEditor;
let shaderModel;
let current;
let currentID = "";
let currentRevision = 0;
@@ -264,9 +272,14 @@ 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");
@@ -403,6 +416,86 @@ async function createEditor(source) {
"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,
@@ -443,6 +536,36 @@ async function createEditor(source) {
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();
}
@@ -467,6 +590,61 @@ function updateDiagnostics() {
: "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);
@@ -483,7 +661,7 @@ function markDirty(value, target = "code") {
}
function allLoadouts() {
return [...renderGraphPresets, ...customLoadouts];
return [...preparedLoadouts, ...customLoadouts];
}
function activeLoadout() {
@@ -495,12 +673,28 @@ function loadoutLayout(loadout = activeLoadout()) {
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 renderGraphPresets) {
for (const loadout of preparedLoadouts) {
const option = document.createElement("option");
option.value = loadout.id;
option.textContent = loadout.name;
@@ -541,32 +735,34 @@ function stageActiveLoadout(layout) {
const currentLoadout = activeLoadout();
if (!currentLoadout)
throw new Error("The selected render graph loadout is missing");
const name =
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 changed =
currentLoadout.builtIn || graphDirty || currentLoadout.name !== name;
const staged = changed
? {
id,
name: name.slice(0, 64),
builtIn: false,
layout,
}
: currentLoadout;
if (changed) {
const index = customLoadouts.findIndex((loadout) => loadout.id === id);
if (index < 0) customLoadouts.push(staged);
else customLoadouts[index] = staged;
loadoutsDirty = true;
}
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();
if (changed) markDirty(true, "graph");
else elements["graph-tab-dirty"].classList.toggle("visible", loadoutsDirty);
markDirty(true, "graph");
return staged;
}
@@ -731,9 +927,15 @@ function openRenderGraphMenu(request) {
}
async function switchLoadout(id) {
const next = allLoadouts().find((loadout) => loadout.id === 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;
@@ -753,6 +955,7 @@ 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()))
@@ -788,6 +991,7 @@ async function copyLoadoutCode() {
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;
@@ -805,12 +1009,16 @@ async function copyLoadoutCode() {
}
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);
@@ -820,7 +1028,7 @@ async function initializeRenderGraphWorkspace() {
window.__yawnRenderGraph = {
editor: renderGraphEditor,
layout: loadoutLayout(),
presets: renderGraphPresets.map(({ id, name }) => ({ id, name })),
presets: preparedLoadouts.map(({ id, name }) => ({ id, name })),
selected: currentLoadoutID,
serialized: compiledRenderGraph,
switchLoadout,
@@ -838,6 +1046,7 @@ function activateCodingTab(tab) {
}
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]"))
@@ -852,6 +1061,21 @@ function activateCodingTab(tab) {
" 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();
@@ -1104,6 +1328,7 @@ 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)
@@ -1285,16 +1510,23 @@ addEventListener("pagehide", (event) => {
cancelAnimationFrame(frameRequest);
closeRenderGraphMenu();
void renderGraphEditor?.destroy();
shaderEditor?.dispose();
shaderModel?.dispose();
void disposeCurrent();
});
try {
const source = await loadInitialPlayground();
await createEditor(source);
await initializeRenderGraphWorkspace();
markDirty(false);
sampleFPS();
await run();
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;