feat: add render graph driven renderer architecture

Amp-Thread-ID: https://ampcode.com/threads/T-019f9d91-77c1-7206-a60f-ed6554ce92ab

Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
This commit is contained in:
Amp
2026-07-27 02:53:44 +00:00
co-authored by heaust
parent 8a8369706b
commit d4e8634f67
290 changed files with 48804 additions and 1995 deletions
+45
View File
@@ -0,0 +1,45 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width" />
<title>fxnode multi-view example</title>
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<main>
<header>
<div>
<h1>One graph, two views</h1>
<p>Selection and camera are local to each canvas; graph edits render in both.</p>
</div>
<div id="toolbar" role="toolbar" aria-label="Active view tools">
<label
>Node
<select id="node-type">
<option value="fxnode.shader.value">Value</option>
<option value="fxnode.shader.color">Color</option>
<option value="fxnode.shader.math">Math</option>
<option value="fxnode.shader.noise-texture">Noise Texture</option>
</select></label
>
<button data-action="add">Add</button>
<button data-action="delete" disabled>Delete</button>
<button data-action="mute" aria-pressed="false" disabled>Mute</button>
<output id="toolbar-status" aria-live="polite"></output>
</div>
</header>
<section class="views">
<article class="active">
<h2>Material detail</h2>
<canvas id="view-a"></canvas>
</article>
<article>
<h2>Output overview</h2>
<canvas id="view-b"></canvas>
</article>
</section>
</main>
<script type="module" src="./main.ts"></script>
</body>
</html>
+124
View File
@@ -0,0 +1,124 @@
import type { FxNodeView } from "@lib/index.js";
import { createApplicationFxNode } from "../blender/application-browser.js";
import initialLayout from "../blender/all-supported/initialLayout.json" with { type: "json" };
import { prepareFxNodeBrowserHost } from "../shared/browser-host.js";
const canvases = [
document.querySelector<HTMLCanvasElement>("#view-a")!,
document.querySelector<HTMLCanvasElement>("#view-b")!,
];
const toolbar = document.querySelector<HTMLElement>("#toolbar")!;
const nodeType = document.querySelector<HTMLSelectElement>("#node-type")!;
const addButton = toolbar.querySelector<HTMLButtonElement>("[data-action=add]")!;
const deleteButton = toolbar.querySelector<HTMLButtonElement>("[data-action=delete]")!;
const muteButton = toolbar.querySelector<HTMLButtonElement>("[data-action=mute]")!;
const status = toolbar.querySelector<HTMLOutputElement>("#toolbar-status")!;
let activeIndex = 0,
toolbarPending = false,
cleaned = false,
root: Awaited<ReturnType<typeof createApplicationFxNode>> | null = null;
const views: FxNodeView[] = [],
unsubscribers: (() => void)[] = [];
const hosts = canvases.map((canvas) => prepareFxNodeBrowserHost({ canvas, lifecycle: "detach-on-disconnect" }));
const renderCounts = [0, 0];
function updateToolbar() {
const snapshot = views[activeIndex]?.getHostSnapshot();
const muted = snapshot?.selection.mute.enabled === true && snapshot.selection.mute.state === "all-muted";
addButton.disabled = toolbarPending || !views[activeIndex];
deleteButton.disabled = toolbarPending || !snapshot?.selection.canRemove;
muteButton.disabled = toolbarPending || snapshot?.selection.mute.enabled !== true;
muteButton.setAttribute("aria-pressed", String(muted));
muteButton.textContent = muted ? "Unmute" : "Mute";
}
function activate(index: number) {
activeIndex = index;
canvases.forEach((canvas, candidate) => canvas.parentElement?.classList.toggle("active", candidate === index));
updateToolbar();
}
const pointerListeners = canvases
.map((canvas, index) => {
const listener = () => activate(index);
canvas.addEventListener("pointerdown", listener);
return [{ canvas, listener }];
})
.flat();
const toolbarListener = async (event: Event) => {
const action = (event.target as HTMLElement).closest<HTMLButtonElement>("button")?.dataset.action,
view = views[activeIndex];
if (!action || !view || toolbarPending) return;
toolbarPending = true;
status.textContent = "";
updateToolbar();
try {
const viewport = hosts[activeIndex]!.currentViewport;
await (action === "add"
? view.addNode({
typeId: nodeType.value,
viewPosition: { x: viewport.width / 2, y: viewport.height / 2 },
})
: action === "delete"
? view.removeSelected()
: view.setSelectedMuted(muteButton.getAttribute("aria-pressed") !== "true"));
} catch (error) {
status.textContent = error instanceof Error ? error.message : "Action failed";
} finally {
toolbarPending = false;
if (!cleaned) updateToolbar();
}
};
toolbar.addEventListener("click", toolbarListener);
async function cleanup() {
if (cleaned) return;
cleaned = true;
window.removeEventListener("pagehide", cleanup);
toolbar.removeEventListener("click", toolbarListener);
for (const { canvas, listener } of pointerListeners) canvas.removeEventListener("pointerdown", listener);
unsubscribers.forEach((unsubscribe) => unsubscribe());
hosts.forEach((host) => host.destroy());
await Promise.allSettled(views.map((view) => view.detach()));
root?.destroy();
root = null;
handle.root = null;
handle.views = [];
}
const handle: MultiViewExampleHandle = { root: null, views, ready: Promise.resolve(), cleanup, renderCounts };
window.fxnodeMultiView = handle;
window.addEventListener("pagehide", cleanup);
handle.ready = (async () => {
try {
const created = await createApplicationFxNode();
if (cleaned) {
created.destroy();
return;
}
root = created;
handle.root = created;
await root.setState(initialLayout);
for (const [index, canvas] of canvases.entries()) {
const host = hosts[index]!,
viewport = host.initialViewport;
const context = canvas.getContext("2d")!,
drawImage = context.drawImage.bind(context);
context.drawImage = ((...args: Parameters<CanvasRenderingContext2D["drawImage"]>) => {
renderCounts[index]!++;
Reflect.apply(drawImage, context, args);
}) as typeof context.drawImage;
const view = await root.attachView({
canvas,
viewport,
initialCamera: index
? { center: { x: 2080, y: -400 }, zoom: 0.45 }
: { center: { x: 480, y: -550 }, zoom: 0.5 },
});
views.push(view);
host.attach(root, view);
unsubscribers.push(view.subscribeHost(updateToolbar));
}
await Promise.all(views.map((view) => view.whenRendered()));
updateToolbar();
} catch (error) {
await cleanup();
throw error;
}
})();
+84
View File
@@ -0,0 +1,84 @@
:root {
color-scheme: dark;
font-family: Inter, system-ui, sans-serif;
background: #101216;
color: #f1f3f5;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
}
main {
width: 1160px;
margin: 24px auto;
}
header {
display: flex;
align-items: end;
justify-content: space-between;
margin-bottom: 16px;
}
h1 {
margin: 0 0 5px;
font-size: 25px;
}
p {
margin: 0;
color: #adb5bd;
}
#toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 9px;
background: #1d1f23;
border: 1px solid #343a40;
border-radius: 7px;
}
select,
button {
padding: 7px 10px;
color: inherit;
background: #292c31;
border: 1px solid #495057;
border-radius: 4px;
}
button:hover {
border-color: #748ffc;
}
button:disabled {
opacity: 0.45;
}
#toolbar-status {
max-width: 180px;
color: #ff8787;
font-size: 12px;
}
.views {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
article {
padding: 8px;
background: #181a1f;
border: 2px solid transparent;
border-radius: 8px;
}
article.active {
border-color: #5c7cfa;
}
h2 {
margin: 2px 3px 9px;
font-size: 14px;
color: #ced4da;
}
canvas {
display: block;
width: 554px;
height: 520px;
background: #1d1f23;
touch-action: none;
}