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