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:
+258
-26
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user