Rebuild core around render graph AST and shared memory
Amp-Thread-ID: https://ampcode.com/threads/T-01a01380-b478-77d0-84a0-102880a5c5ae Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
import { NODE_TITLE_OVERRIDES, semanticCatalog } from "@yawn/render-graph-fxnode/catalog";
|
||||
|
||||
// Example-only DOM menu for the FXNode frontend.
|
||||
|
||||
const GROUPS = Object.freeze([
|
||||
["source", "Source"],
|
||||
["expression", "Expression"],
|
||||
["compute", "Compute"],
|
||||
["cpu_preparation", "CPU preparation"],
|
||||
["render", "Render / post"],
|
||||
["frame", "Frame"],
|
||||
]);
|
||||
|
||||
const title = (typeId) => typeId.replaceAll("_", " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||
|
||||
/** Application-owned, immutable add-node catalog model. */
|
||||
export const addNodeItems = Object.freeze(
|
||||
GROUPS.flatMap(([execution, group]) =>
|
||||
Object.entries(semanticCatalog)
|
||||
.filter(([, definition]) => definition.execution === execution)
|
||||
.map(([typeId]) => Object.freeze({ typeId, title: NODE_TITLE_OVERRIDES[typeId] ?? title(typeId), group })),
|
||||
),
|
||||
);
|
||||
|
||||
export function searchAddNodeItems(query, items = addNodeItems) {
|
||||
const terms = query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean);
|
||||
return items.filter((item) => terms.every((term) =>
|
||||
`${item.title} ${item.typeId} ${item.group}`.toLocaleLowerCase().includes(term),
|
||||
));
|
||||
}
|
||||
|
||||
export function moveAddNodeSelection(index, delta, length) {
|
||||
return length ? ((Math.max(0, index) + delta) % length + length) % length : -1;
|
||||
}
|
||||
|
||||
/** Creates one transient DOM menu owned by the application rather than fxnode. */
|
||||
export function createAddNodeMenu(ownerDocument = document) {
|
||||
const ownerWindow = ownerDocument.defaultView;
|
||||
const root = ownerDocument.createElement("div");
|
||||
root.className = "fxnode-add-menu";
|
||||
root.hidden = true;
|
||||
root.setAttribute("role", "dialog");
|
||||
root.setAttribute("aria-label", "Add render graph node");
|
||||
const input = ownerDocument.createElement("input");
|
||||
input.type = "search";
|
||||
input.placeholder = "Search nodes…";
|
||||
input.setAttribute("aria-label", "Search nodes");
|
||||
input.setAttribute("aria-controls", "fxnode-add-options");
|
||||
input.setAttribute("aria-autocomplete", "list");
|
||||
const list = ownerDocument.createElement("div");
|
||||
list.id = "fxnode-add-options";
|
||||
list.className = "fxnode-add-menu__list";
|
||||
list.setAttribute("role", "listbox");
|
||||
root.append(input, list);
|
||||
ownerDocument.body.append(root);
|
||||
let resolve, filtered = addNodeItems, selected = 0, serial = 0, previousFocus;
|
||||
|
||||
const close = (value = null) => {
|
||||
if (root.hidden) return;
|
||||
root.hidden = true;
|
||||
const done = resolve;
|
||||
resolve = undefined;
|
||||
previousFocus?.focus?.();
|
||||
previousFocus = undefined;
|
||||
done?.(value);
|
||||
};
|
||||
const render = () => {
|
||||
filtered = searchAddNodeItems(input.value);
|
||||
selected = filtered.length ? Math.min(Math.max(selected, 0), filtered.length - 1) : -1;
|
||||
list.replaceChildren();
|
||||
let group;
|
||||
for (const [index, item] of filtered.entries()) {
|
||||
if (item.group !== group) {
|
||||
group = item.group;
|
||||
const heading = ownerDocument.createElement("div");
|
||||
heading.className = "fxnode-add-menu__group";
|
||||
heading.textContent = group;
|
||||
heading.setAttribute("role", "presentation");
|
||||
list.append(heading);
|
||||
}
|
||||
const option = ownerDocument.createElement("button");
|
||||
option.type = "button";
|
||||
option.id = `fxnode-add-option-${serial}-${index}`;
|
||||
option.className = "fxnode-add-menu__option";
|
||||
option.dataset.typeId = item.typeId;
|
||||
option.textContent = item.title;
|
||||
option.setAttribute("role", "option");
|
||||
option.setAttribute("aria-selected", String(index === selected));
|
||||
option.tabIndex = -1;
|
||||
option.addEventListener("pointermove", () => { selected = index; render(); });
|
||||
option.addEventListener("click", () => close(item.typeId));
|
||||
list.append(option);
|
||||
}
|
||||
const active = selected >= 0 ? list.querySelector(`[data-type-id="${filtered[selected].typeId}"]`) : null;
|
||||
input.setAttribute("aria-activedescendant", active?.id ?? "");
|
||||
active?.scrollIntoView({ block: "nearest" });
|
||||
};
|
||||
const reposition = () => {
|
||||
if (root.hidden) return;
|
||||
const margin = 8, box = root.getBoundingClientRect();
|
||||
root.style.left = `${Math.max(margin, Math.min(Number(root.dataset.x), ownerWindow.innerWidth - box.width - margin))}px`;
|
||||
root.style.top = `${Math.max(margin, Math.min(Number(root.dataset.y), ownerWindow.innerHeight - box.height - margin))}px`;
|
||||
};
|
||||
input.addEventListener("input", () => { selected = 0; render(); });
|
||||
input.addEventListener("keydown", (event) => {
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
event.preventDefault(); selected = moveAddNodeSelection(selected, event.key === "ArrowDown" ? 1 : -1, filtered.length); render();
|
||||
} else if (event.key === "Enter" && selected >= 0) {
|
||||
event.preventDefault(); close(filtered[selected].typeId);
|
||||
} else if (event.key === "Escape") { event.preventDefault(); close(); }
|
||||
});
|
||||
const outside = (event) => { if (!root.hidden && !root.contains(event.target)) close(); };
|
||||
ownerDocument.addEventListener("pointerdown", outside, true);
|
||||
ownerWindow.addEventListener("resize", close);
|
||||
ownerWindow.addEventListener("blur", close);
|
||||
return {
|
||||
open({ x, y }) {
|
||||
close();
|
||||
serial++;
|
||||
previousFocus = ownerDocument.activeElement;
|
||||
root.dataset.x = String(x); root.dataset.y = String(y);
|
||||
input.value = ""; selected = 0; root.hidden = false; render(); reposition(); input.focus();
|
||||
return new Promise((done) => { resolve = done; });
|
||||
},
|
||||
close,
|
||||
destroy() {
|
||||
close();
|
||||
ownerDocument.removeEventListener("pointerdown", outside, true);
|
||||
ownerWindow.removeEventListener("resize", close);
|
||||
ownerWindow.removeEventListener("blur", close);
|
||||
root.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { mapAuthoringDiagnostic } from "@yawn/render-graph-fxnode";
|
||||
import { loadGraph } from "@yawn/render-graph-js";
|
||||
|
||||
// Example lifecycle glue; package frontends remain independent of this controller.
|
||||
|
||||
export class AuthoringController {
|
||||
#renderer; #adapt; #revision = 0; #nextRevision = 1; #generation = 0;
|
||||
#current; #lastGood; #applyPromise; #listeners = new Set();
|
||||
#owned = new Map(); #drops = new Map(); #activeCompiles = new Set();
|
||||
#disposed = false; #applyingRecord; #scheduler; #debounceMs; #timer; #destroyPromise;
|
||||
|
||||
constructor({ renderer, adapt, scheduler = globalThis, debounceMs = 150 }) {
|
||||
this.#renderer = renderer; this.#adapt = adapt;
|
||||
this.#scheduler = scheduler; this.#debounceMs = debounceMs;
|
||||
}
|
||||
get revision() { return this.#revision; }
|
||||
get dirty() { return !!this.#current; }
|
||||
get applying() { return !!this.#applyPromise; }
|
||||
get staged() { return this.#current?.candidate ?? null; }
|
||||
get canApply() { return !this.#disposed && !!this.#current?.candidate && !this.#applyPromise; }
|
||||
subscribe(fn) {
|
||||
if (this.#disposed) return () => {};
|
||||
this.#listeners.add(fn); fn(this.#state());
|
||||
return () => this.#listeners.delete(fn);
|
||||
}
|
||||
#state() { return { revision: this.#revision, dirty: this.dirty, applying: this.applying, staged: this.staged, canApply: this.canApply, error: this.#current?.diagnostic ?? null }; }
|
||||
#emit() { if (!this.#disposed) for (const fn of this.#listeners) fn(this.#state()); }
|
||||
#key(id) { return JSON.stringify(id); }
|
||||
#drop(candidate) {
|
||||
if (!candidate) return Promise.resolve();
|
||||
const key = this.#key(candidate.compiledId);
|
||||
if (!this.#owned.has(key)) return this.#drops.get(key) ?? Promise.resolve();
|
||||
if (this.#drops.has(key)) return this.#drops.get(key);
|
||||
let result;
|
||||
try { result = this.#renderer.dropCompiledGraph(candidate.compiledId); }
|
||||
catch (error) { result = Promise.reject(error); }
|
||||
const dropping = Promise.resolve(result)
|
||||
.then(() => { this.#owned.delete(key); })
|
||||
.finally(() => { this.#drops.delete(key); });
|
||||
this.#drops.set(key, dropping);
|
||||
return dropping;
|
||||
}
|
||||
#retire(candidate) { if (candidate) void this.#drop(candidate).catch(() => {}); }
|
||||
#start(record) {
|
||||
if (!record.compile) {
|
||||
record.compile = this.#compile(record);
|
||||
this.#activeCompiles.add(record.compile);
|
||||
record.compile.finally(() => this.#activeCompiles.delete(record.compile));
|
||||
}
|
||||
return record.compile;
|
||||
}
|
||||
#flush(record = this.#current) {
|
||||
if (this.#timer) { this.#scheduler.clearTimeout(this.#timer); this.#timer = undefined; }
|
||||
return record ? this.#start(record) : null;
|
||||
}
|
||||
markDirty(snapshot) {
|
||||
if (this.#disposed) return;
|
||||
const previous = this.#current;
|
||||
const record = { generation: ++this.#generation, snapshot, candidate: null, error: null, diagnostic: null, compile: null };
|
||||
this.#current = record;
|
||||
if (previous?.candidate && previous !== this.#applyingRecord && previous.candidate !== this.#lastGood) this.#retire(previous.candidate);
|
||||
if (this.#timer) this.#scheduler.clearTimeout(this.#timer);
|
||||
this.#timer = this.#scheduler.setTimeout(() => { this.#timer = undefined; if (!this.#disposed) this.#start(record); }, this.#debounceMs);
|
||||
this.#emit();
|
||||
}
|
||||
async #compile(record) {
|
||||
let candidate, ir;
|
||||
try {
|
||||
ir = this.#adapt(record.snapshot, this.#nextRevision++);
|
||||
candidate = await loadGraph(this.#renderer, ir);
|
||||
this.#owned.set(this.#key(candidate.compiledId), candidate);
|
||||
if (this.#disposed || this.#current !== record) { this.#retire(candidate); return null; }
|
||||
record.candidate = candidate; record.error = record.diagnostic = null; this.#emit(); return candidate;
|
||||
} catch (error) {
|
||||
if (candidate) this.#retire(candidate);
|
||||
record.error = error;
|
||||
record.diagnostic = ir ? mapAuthoringDiagnostic(ir, error) : error;
|
||||
if (this.#current === record) this.#emit();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
apply() {
|
||||
if (this.#disposed) return Promise.resolve(null);
|
||||
if (this.#applyPromise) return this.#applyPromise;
|
||||
const record = this.#current;
|
||||
if (!record) return Promise.resolve(this.#lastGood);
|
||||
this.#applyingRecord = record; this.#flush(record);
|
||||
this.#applyPromise = this.#applyRecord(record); this.#emit(); return this.#applyPromise;
|
||||
}
|
||||
async #applyRecord(record) {
|
||||
try {
|
||||
const candidate = record.candidate ?? (await record.compile);
|
||||
if (!candidate) throw record.error ?? new Error("Graph compilation failed");
|
||||
if (this.#disposed || this.#current !== record) { this.#retire(candidate); return null; }
|
||||
await this.#renderer.switchCompiledGraph(candidate.compiledId);
|
||||
const old = this.#lastGood; this.#lastGood = candidate;
|
||||
this.#revision = candidate.revision ?? this.#revision + 1;
|
||||
if (this.#current === record) this.#current = undefined;
|
||||
if (old && old !== candidate) this.#retire(old);
|
||||
return candidate;
|
||||
} finally {
|
||||
if (this.#current !== record && record.candidate && record.candidate !== this.#lastGood) this.#retire(record.candidate);
|
||||
this.#applyPromise = null; this.#applyingRecord = undefined; this.#emit();
|
||||
}
|
||||
}
|
||||
destroy() {
|
||||
if (this.#destroyPromise) return this.#destroyPromise;
|
||||
this.#disposed = true;
|
||||
if (this.#timer) { this.#scheduler.clearTimeout(this.#timer); this.#timer = undefined; }
|
||||
this.#listeners.clear();
|
||||
this.#destroyPromise = this.#finish();
|
||||
return this.#destroyPromise;
|
||||
}
|
||||
async #finish() {
|
||||
const applying = this.#applyPromise;
|
||||
await Promise.allSettled([...(applying ? [applying] : []), ...this.#activeCompiles, ...this.#drops.values()]);
|
||||
await Promise.allSettled([...this.#owned.values()].map((candidate) => this.#drop(candidate)));
|
||||
this.#current = this.#lastGood = undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Browser input host used only by the interactive package example.
|
||||
const viewport = (canvas, ownerWindow) => ({
|
||||
width: Math.max(1, canvas.clientWidth),
|
||||
height: Math.max(1, canvas.clientHeight),
|
||||
dpr: Math.min(4, Math.max(1, ownerWindow.devicePixelRatio || 1)),
|
||||
});
|
||||
const sameViewport = (a, b) => a.width === b.width && a.height === b.height && a.dpr === b.dpr;
|
||||
const sizeCanvas = (canvas, value) => {
|
||||
canvas.width = Math.round(value.width * value.dpr);
|
||||
canvas.height = Math.round(value.height * value.dpr);
|
||||
};
|
||||
const mods = e => ({ alt:e.altKey, control:e.ctrlKey, meta:e.metaKey, shift:e.shiftKey });
|
||||
|
||||
export function prepareBrowserHost(canvas, { onError=console.error, requestAddNode }={}) {
|
||||
const ownerDocument=canvas.ownerDocument, ownerWindow=ownerDocument.defaultView ?? window;
|
||||
const originalTabIndex=canvas.getAttribute("tabindex"), originalTouchAction=canvas.style.touchAction;
|
||||
let view, root, dead=false, generation=0, requestEpoch=0, resizing=false, pending, appliedViewport, menuPending=false, menuPoint, unsubscribeHost=()=>{};
|
||||
const rootSubscriptions=[];
|
||||
const invalidateAddNode=()=>{requestEpoch++;menuPending=false;requestAddNode?.close?.()};
|
||||
const captured=new Set();
|
||||
const initialViewport=viewport(canvas,ownerWindow); appliedViewport=initialViewport; sizeCanvas(canvas,initialViewport);
|
||||
canvas.tabIndex=0; canvas.style.touchAction="none";
|
||||
const point=e=>{const r=canvas.getBoundingClientRect();return{x:e.clientX-r.left,y:e.clientY-r.top}};
|
||||
const input=e=>{
|
||||
if(!view)return;
|
||||
if(e instanceof ownerWindow.PointerEvent){
|
||||
const phase=e.type==="pointerdown"?"down":e.type==="pointermove"?"move":e.type==="pointerup"?"up":"cancel";
|
||||
if(phase==="down"){invalidateAddNode();menuPending=e.button===2&&!e.ctrlKey&&!e.altKey&&!e.metaKey&&!e.shiftKey&&(e.buttons&1)===0;menuPoint={x:e.clientX,y:e.clientY};canvas.focus();try{canvas.setPointerCapture(e.pointerId);captured.add(e.pointerId)}catch{}}
|
||||
if((phase==="up"||phase==="cancel")&&captured.delete(e.pointerId))try{if(canvas.hasPointerCapture(e.pointerId))canvas.releasePointerCapture(e.pointerId)}catch{}
|
||||
view.feedInput({kind:"pointer",phase,pointerId:e.pointerId,pointerType:e.pointerType,position:point(e),button:e.button,buttons:e.buttons,modifiers:mods(e)});
|
||||
}else if(e instanceof ownerWindow.WheelEvent){
|
||||
e.preventDefault(); invalidateAddNode();
|
||||
const scale=e.deltaMode===ownerWindow.WheelEvent.DOM_DELTA_LINE?16:e.deltaMode===ownerWindow.WheelEvent.DOM_DELTA_PAGE?Math.max(1,canvas.clientHeight):1;
|
||||
view.feedInput({kind:"wheel",position:point(e),delta:{x:e.deltaX*scale,y:e.deltaY*scale},modifiers:mods(e)});
|
||||
}else if(e instanceof ownerWindow.KeyboardEvent){invalidateAddNode();view.feedInput({kind:"key",phase:e.type==="keydown"?"down":"up",key:e.key,code:e.code,repeat:e.repeat,modifiers:mods(e)});
|
||||
}else view.feedInput({kind:"focus",phase:e.type==="focus"?"focus":"blur"});
|
||||
};
|
||||
const names=["pointerdown","pointermove","pointerup","pointercancel","wheel","keydown","keyup","focus","blur"];
|
||||
const pump=()=>{
|
||||
if(!view||resizing||!pending||dead)return;
|
||||
const next=pending, currentGeneration=generation;pending=undefined;
|
||||
if(sameViewport(next,appliedViewport)){sizeCanvas(canvas,next);pump();return}
|
||||
resizing=true;
|
||||
Promise.resolve(view.setViewport(next)).then(()=>{if(dead||currentGeneration!==generation)return;appliedViewport=next;sizeCanvas(canvas,next)}).catch(error=>{if(!dead&¤tGeneration===generation)onError(error)}).finally(()=>{if(dead||currentGeneration!==generation)return;resizing=false;pump()});
|
||||
};
|
||||
const resize=()=>{if(dead)return;invalidateAddNode();pending=viewport(canvas,ownerWindow);pump()};
|
||||
const outside=e=>{if(view&&e.button===0&&e.target!==canvas&&!canvas.contains(e.target)&&view.getHostSnapshot().colorPickerOpen)view.feedInput({kind:"outside-pointer",button:0})};
|
||||
const lost=e=>captured.delete(e.pointerId);
|
||||
const observer=new ownerWindow.ResizeObserver(resize);
|
||||
return {initialViewport,attach(_root,next){
|
||||
root=_root;view=next;
|
||||
for(const n of names)canvas.addEventListener(n,input,{passive:n!=="wheel"});
|
||||
canvas.addEventListener("contextmenu",prevent);canvas.addEventListener("lostpointercapture",lost);
|
||||
ownerDocument.addEventListener("pointerdown",outside,true);ownerWindow.addEventListener("resize",resize);
|
||||
unsubscribeHost=view.onHostRequests(request=>{if(request.kind!=="add-node-menu"||!menuPending||request.compositionRevision!==view.getHostSnapshot().compositionRevision){invalidateAddNode();return}menuPending=false;const epoch=requestEpoch;requestAddNode?.(request,menuPoint,()=>!dead&&epoch===requestEpoch);});
|
||||
rootSubscriptions.push(root.onMutations(invalidateAddNode),root.onCompositionChanges(invalidateAddNode));
|
||||
observer.observe(canvas);resize();
|
||||
},destroy(){
|
||||
if(dead)return;dead=true;generation++;pending=undefined;invalidateAddNode();observer.disconnect();unsubscribeHost();for(const unsubscribe of rootSubscriptions)unsubscribe();rootSubscriptions.length=0;ownerWindow.removeEventListener("resize",resize);ownerDocument.removeEventListener("pointerdown",outside,true);
|
||||
for(const n of names)canvas.removeEventListener(n,input);canvas.removeEventListener("contextmenu",prevent);canvas.removeEventListener("lostpointercapture",lost);
|
||||
for(const id of captured)try{if(canvas.hasPointerCapture(id))canvas.releasePointerCapture(id)}catch{}captured.clear();
|
||||
if(originalTabIndex===null)canvas.removeAttribute("tabindex");else canvas.setAttribute("tabindex",originalTabIndex);canvas.style.touchAction=originalTouchAction;view=null;root=null;
|
||||
}};
|
||||
}
|
||||
function prevent(e){e.preventDefault()}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { createFxNode } from "@fxnode/index.ts";
|
||||
import { CATALOG_VERSION, GRAPH_ID, fxNodeComposition } from "@yawn/render-graph-fxnode/catalog";
|
||||
import { prepareBrowserHost } from "./browser-host.js";
|
||||
import { createAddNodeMenu } from "./add-node-menu.js";
|
||||
import { createNodeIdAllocator, spawnRequestedNode } from "./node-spawn.js";
|
||||
import { culling } from "./presets.js";
|
||||
|
||||
// Seeds a user-facing FXNode document before exporting it through the addon.
|
||||
|
||||
async function seed(root) {
|
||||
await root.setState({
|
||||
graphId: GRAPH_ID,
|
||||
catalogVersion: CATALOG_VERSION,
|
||||
nodes: [],
|
||||
links: [],
|
||||
metadata: {},
|
||||
});
|
||||
for (const [index, item] of culling.nodes.entries())
|
||||
await root.dispatch({ type: "node.add", nodeId: item.id, nodeType: item.executor.key,
|
||||
position: { x: 40 + (index % 6) * 280, y: 120 + Math.floor(index / 6) * 260 } });
|
||||
const authoredNodes = new Map(culling.nodes.map((node) => [node.id, node]));
|
||||
const socketKey = (nodeId, semantic, direction) => {
|
||||
const type = authoredNodes.get(nodeId)?.executor.key;
|
||||
const sockets = fxNodeComposition.nodes[type]?.sockets ?? {};
|
||||
const matches = Object.entries(sockets).filter(
|
||||
([key, socket]) =>
|
||||
socket.direction === direction &&
|
||||
(key === semantic || socket.title === semantic),
|
||||
);
|
||||
return matches.length === 1 ? matches[0][0] : semantic;
|
||||
};
|
||||
const links = culling.nodes.flatMap((item) => Object.entries(item.inputs).flatMap(([socket, sources]) =>
|
||||
sources.map((from, index) => [from.node, from.socket, item.id, socket, index])));
|
||||
for (const [a, as, b, bs, index] of links) {
|
||||
const id = `${a}_${as}_${b}_${bs}_${index}`;
|
||||
await root.dispatch({
|
||||
type: "link.add",
|
||||
link: {
|
||||
id,
|
||||
fromNodeId: a,
|
||||
fromSocketId: `${a}:${socketKey(a, as, "output")}`,
|
||||
toNodeId: b,
|
||||
toSocketId: `${b}:${socketKey(b, bs, "input")}`,
|
||||
muted: false,
|
||||
extensions: {},
|
||||
},
|
||||
});
|
||||
}
|
||||
const authored = await root.getState();
|
||||
for (const item of culling.nodes) {
|
||||
const target = authored.nodes.find((candidate) => candidate.id === item.id);
|
||||
if (item.executor.key === "texture") {
|
||||
const texture = item.parameters.texture;
|
||||
const relative = texture.extent.kind === "surface_relative";
|
||||
const values = {
|
||||
residency: item.parameters.residency,
|
||||
format: texture.format,
|
||||
dimension: texture.dimension,
|
||||
extentMode: texture.extent.kind,
|
||||
absoluteWidth: relative ? 1 : texture.extent.width,
|
||||
absoluteHeight: relative ? 1 : texture.extent.height,
|
||||
relativeWidthNumerator: relative ? texture.extent.width.numerator : 1,
|
||||
relativeWidthDenominator: relative ? texture.extent.width.denominator : 1,
|
||||
relativeHeightNumerator: relative ? texture.extent.height.numerator : 1,
|
||||
relativeHeightDenominator: relative ? texture.extent.height.denominator : 1,
|
||||
depthOrArrayLayers: texture.extent.depthOrArrayLayers,
|
||||
mipLevelCount: texture.mipLevelCount,
|
||||
sampleCount: String(texture.sampleCount),
|
||||
viewFormat: texture.viewFormats[0] ?? "none",
|
||||
};
|
||||
for (const [key, value] of Object.entries(values))
|
||||
target.parameters[key].value = structuredClone(value);
|
||||
continue;
|
||||
}
|
||||
for (const [key, value] of Object.entries(item.parameters)) {
|
||||
const input = key.endsWith("Default") ? key.slice(0, -7) : null;
|
||||
if (input) {
|
||||
const socket = target.sockets.find((candidate) => candidate.key === input);
|
||||
if (socket?.defaultValue) socket.defaultValue.value = structuredClone(value);
|
||||
} else {
|
||||
const authoredKey = item.executor.key === "frustum_cull" && key === "camera" ? "cameraSelection" : key;
|
||||
if (target.parameters[authoredKey]) target.parameters[authoredKey].value = structuredClone(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
await root.setState(authored);
|
||||
}
|
||||
export async function createRenderGraphEditor(canvas) {
|
||||
const allocateId = createNodeIdAllocator();
|
||||
let root,
|
||||
view,
|
||||
menu,
|
||||
destroying,
|
||||
dead = false;
|
||||
const requestAddNode = Object.assign(
|
||||
async (request, point, isCurrent = () => true) => {
|
||||
let typeId;
|
||||
try {
|
||||
typeId = await menu?.open(point);
|
||||
} catch (error) {
|
||||
if (!dead && isCurrent()) console.error(error);
|
||||
return;
|
||||
}
|
||||
if (dead || !isCurrent() || !root || !view) return;
|
||||
const alive = () => !dead && isCurrent();
|
||||
try {
|
||||
await spawnRequestedNode(
|
||||
root,
|
||||
view,
|
||||
request,
|
||||
typeId,
|
||||
allocateId,
|
||||
alive,
|
||||
);
|
||||
} catch (error) {
|
||||
if (!dead) console.error(error);
|
||||
}
|
||||
},
|
||||
{ close: () => menu?.close() },
|
||||
);
|
||||
const host = prepareBrowserHost(canvas, { requestAddNode });
|
||||
const destroy = () =>
|
||||
(destroying ??= (async () => {
|
||||
dead = true;
|
||||
host.destroy();
|
||||
menu?.destroy();
|
||||
try {
|
||||
await view?.detach();
|
||||
} finally {
|
||||
root?.destroy();
|
||||
view = undefined;
|
||||
root = undefined;
|
||||
}
|
||||
})());
|
||||
try {
|
||||
root = await createFxNode({
|
||||
applicationId: "yawn.render-graph",
|
||||
applicationVersion: CATALOG_VERSION,
|
||||
resources: {},
|
||||
});
|
||||
await root.loadComposition(fxNodeComposition);
|
||||
await seed(root);
|
||||
view = await root.attachView({
|
||||
canvas,
|
||||
viewport: host.initialViewport,
|
||||
initialCamera: { center: { x: 780, y: 340 }, zoom: 0.34 },
|
||||
});
|
||||
menu = createAddNodeMenu(canvas.ownerDocument);
|
||||
host.attach(root, view);
|
||||
await view.whenRendered();
|
||||
const editorRoot = root,
|
||||
editorView = view;
|
||||
return {
|
||||
getState: () => editorRoot.getState(),
|
||||
onSnapshots: (fn) =>
|
||||
editorRoot.onSnapshots((event) => fn(event.snapshot, event.version)),
|
||||
whenRendered: () => editorView.whenRendered(),
|
||||
destroy,
|
||||
};
|
||||
} catch (e) {
|
||||
await destroy().catch(() => {});
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/** Allocates an example-local bounded FXNode ID, reserving candidates for this session. */
|
||||
export function createNodeIdAllocator(randomUUID = () => crypto.randomUUID()) {
|
||||
const reserved = new Set();
|
||||
return (existingIds) => {
|
||||
const existing = new Set(existingIds);
|
||||
for (let attempt = 0; attempt < 64; attempt++) {
|
||||
const id = `node_${randomUUID().replaceAll("-", "")}`;
|
||||
if (/^node_[A-Za-z0-9_]+$/.test(id) && id.length <= 128 && !existing.has(id) && !reserved.has(id)) {
|
||||
reserved.add(id);
|
||||
return id;
|
||||
}
|
||||
}
|
||||
throw new Error("Unable to allocate a unique node ID");
|
||||
};
|
||||
}
|
||||
|
||||
/** Adds exactly one node when the request still targets the loaded composition. */
|
||||
export async function spawnRequestedNode(root, view, request, typeId, allocateId, isCurrent = () => true) {
|
||||
const current = () =>
|
||||
isCurrent() && request.compositionRevision === view.getHostSnapshot().compositionRevision;
|
||||
if (!typeId || !current()) return false;
|
||||
let state;
|
||||
try {
|
||||
state = await root.getState();
|
||||
} catch (error) {
|
||||
if (!isCurrent()) return false;
|
||||
throw error;
|
||||
}
|
||||
if (!current()) return false;
|
||||
const nodeId = allocateId(state.nodes.map((node) => node.id));
|
||||
if (!current()) return false;
|
||||
try {
|
||||
await view.addNode(
|
||||
{ typeId, nodeId, viewPosition: request.viewPosition },
|
||||
{ expectedVersion: state.version },
|
||||
);
|
||||
} catch (error) {
|
||||
if (!isCurrent()) return false;
|
||||
throw error;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { defaultPipelines } from "@yawn/default-pipelines";
|
||||
import { descriptors } from "@yawn/render-graph-fxnode/catalog";
|
||||
import { graphFromObject } from "@yawn/render-graph-js";
|
||||
|
||||
// A complete graph authored like a package consumer would author it.
|
||||
|
||||
const input = (node, socket) => [{ node, socket }];
|
||||
const node = (id, key, parameters = {}, inputs = {}) => ({
|
||||
id,
|
||||
state: "enabled",
|
||||
executor: { key, version: descriptors[key].version },
|
||||
parameters,
|
||||
inputs,
|
||||
});
|
||||
const texture = (format) => ({
|
||||
texture: {
|
||||
dimension: "d2",
|
||||
format,
|
||||
extent: {
|
||||
kind: "surface_relative",
|
||||
width: { numerator: 1, denominator: 1 },
|
||||
height: { numerator: 1, denominator: 1 },
|
||||
depthOrArrayLayers: 1,
|
||||
},
|
||||
mipLevelCount: 1,
|
||||
sampleCount: 1,
|
||||
viewFormats: [],
|
||||
},
|
||||
residency: "transient",
|
||||
});
|
||||
|
||||
const nodes = [
|
||||
node("hdr", "texture", texture("rgba16_float")),
|
||||
node("scene_depth", "texture", texture("depth32_float")),
|
||||
node("mesh", "mesh"),
|
||||
node("type_words", "separate_u32x16", { valueDefault: Array(16).fill(0) }, { value: input("mesh", "type") }),
|
||||
node("type_bits", "separate_u32_bits", { valueDefault: 0 }, { value: input("type_words", "word0") }),
|
||||
node("ground_class", "and", {}, { inputs: [...input("type_bits", "bit0"), ...input("type_bits", "bit1")] }),
|
||||
node("not_double", "not", { operandDefault: false }, { operand: input("type_bits", "bit3") }),
|
||||
node("standard_class", "and", {}, { inputs: [...input("type_bits", "bit0"), ...input("type_bits", "bit2"), ...input("not_double", "value")] }),
|
||||
node("double_class", "and", {}, { inputs: [...input("type_bits", "bit0"), ...input("type_bits", "bit3")] }),
|
||||
node("cull", "frustum_cull", { camera: "active" }, { mesh: input("mesh", "mesh"), localAabb: input("mesh", "localAabb") }),
|
||||
node("not_culled", "not", { operandDefault: false }, { operand: input("cull", "isFrustumCulled") }),
|
||||
];
|
||||
for (const id of ["ground_class", "standard_class", "double_class"])
|
||||
nodes.find((item) => item.id === id).inputs.inputs.push(...input("not_culled", "value"));
|
||||
nodes.push(
|
||||
node("ground", "ground_plane", { depthCompare: "less_equal", depthWriteEnabled: true, clearDepth: 1, clearColor: [0.015, 0.02, 0.03, 1], predicateDefault: true }, { mesh: input("mesh", "mesh"), predicate: input("ground_class", "value"), color: input("hdr", "texture"), depth: input("scene_depth", "texture") }),
|
||||
node("pbr", "gltf_standard", { depthCompare: "less_equal", depthWriteEnabled: true, clearDepth: 1, clearColor: [0.015, 0.02, 0.03, 1], predicateDefault: true }, { mesh: input("mesh", "mesh"), predicate: input("standard_class", "value"), color: input("hdr", "texture"), depth: input("scene_depth", "texture") }),
|
||||
node("pbr_double", "gltf_standard_double_sided", { depthCompare: "less_equal", depthWriteEnabled: true, clearDepth: 1, clearColor: [0.015, 0.02, 0.03, 1], predicateDefault: true }, { mesh: input("mesh", "mesh"), predicate: input("double_class", "value"), color: input("hdr", "texture"), depth: input("scene_depth", "texture") }),
|
||||
node("frame_out", "frame_out", { surfaceFormat: "preferred", hdrEnabled: true, toneMapper: "aces", exposureStops: 0, outputTransfer: "srgb", scaleMode: "stretch", filter: "linear", backgroundColor: [0, 0, 0, 1] }, { color: input("pbr_double", "color") }),
|
||||
);
|
||||
|
||||
/** The example's JSO graph; the graph addon canonicalizes it to AST and S-expressions. */
|
||||
export const culling = graphFromObject({
|
||||
id: "example_jso_scene",
|
||||
revision: 1,
|
||||
pipelines: defaultPipelines,
|
||||
nodes,
|
||||
});
|
||||
|
||||
export const renderGraphPresets = Object.freeze({ jso: culling });
|
||||
Reference in New Issue
Block a user