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:
Amp
2026-08-18 11:58:31 +00:00
co-authored by heaust
parent a23ef37b4d
commit 0e44917f9e
101 changed files with 4409 additions and 2610 deletions
@@ -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&&currentGeneration===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 });