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
+75
View File
@@ -0,0 +1,75 @@
import { CATALOG_VERSION, descriptors, GRAPH_ID } from "./catalog.js";
export class AuthoringGraphError extends Error {
constructor(code, details = {}) { super(code); this.name="AuthoringGraphError"; this.code=code; this.details=Object.freeze(details); }
}
const fail=(code,details)=>{throw new AuthoringGraphError(code,details)};
const object=v=>v !== null && typeof v === "object" && !Array.isArray(v);
const validId=v=>typeof v === "string" && /^[A-Za-z][A-Za-z0-9_.-]*$/.test(v) && new TextEncoder().encode(v).length<=64;
const validSocketId=v=>typeof v === "string" && /^[A-Za-z][A-Za-z0-9_.-]*:[A-Za-z][A-Za-z0-9_.-]*$/.test(v) && new TextEncoder().encode(v).length<=129;
const keysEqual=(a,b)=>a.length===b.length && a.every(x=>b.includes(x));
/** Validate hostile fxnode state and return an app-owned, layout-free projection. */
export function projectAuthoringSnapshot(raw) {
if(!object(raw)||!Array.isArray(raw.nodes)||!Array.isArray(raw.links)) fail("AUTHORING_SHAPE",{field:"snapshot"});
if(raw.graphId!==GRAPH_ID||raw.catalogVersion!==CATALOG_VERSION) fail("AUTHORING_CATALOG",{graphId:raw.graphId,catalogVersion:raw.catalogVersion});
const nodeIds=new Set(), socketIds=new Set(), byId=new Map(), byType=new Map();
for(const n of raw.nodes){
if(!object(n)||!validId(n.id)) fail("AUTHORING_ID",{kind:"node",id:n?.id});
if(nodeIds.has(n.id)) fail("AUTHORING_ID_DUPLICATE",{kind:"node",id:n.id}); nodeIds.add(n.id);
const d=descriptors[n.typeId];
if(!d) fail("AUTHORING_NODE_TYPE",{nodeId:n.id,typeId:n.typeId});
if(n.known!==true) fail("AUTHORING_NODE_UNKNOWN",{nodeId:n.id});
if(n.typeVersion!==d.version) fail("AUTHORING_NODE_VERSION",{nodeId:n.id,expected:d.version,actual:n.typeVersion});
if(typeof n.muted!=="boolean") fail("AUTHORING_NODE_MUTED",{nodeId:n.id});
if(n.muted&&n.typeId!=="scene_forward") fail("AUTHORING_NODE_MUTED",{nodeId:n.id});
if(byType.has(n.typeId)) fail("AUTHORING_TOPOLOGY",{reason:"duplicate-type",typeId:n.typeId});
if(!Array.isArray(n.sockets)||!keysEqual(n.sockets.map(s=>s?.key),Object.keys(d.sockets))) fail("AUTHORING_SOCKET_SET",{nodeId:n.id});
const sockets={};
for(const s of n.sockets){ const expected=d.sockets[s.key];
if(!object(s)||!validSocketId(s.id)) fail("AUTHORING_ID",{kind:"socket",id:s?.id});
if(socketIds.has(s.id)) fail("AUTHORING_ID_DUPLICATE",{kind:"socket",id:s.id}); socketIds.add(s.id);
if(s.direction!==expected[0]||s.dataType!==expected[1]) fail("AUTHORING_SOCKET",{nodeId:n.id,socket:s.key});
sockets[s.key]={id:s.id,direction:s.direction,type:s.dataType,nodeId:n.id};
}
const p=object(n.parameters)?n.parameters:null;
if(!p||!keysEqual(Object.keys(p),d.parameters)) fail("AUTHORING_PARAMETERS",{nodeId:n.id});
let parameters={};
if(n.typeId==="scene_forward"){
const c=p.clearColor, z=p.clearDepth;
if(!object(c)||c.kind!=="color"||!Array.isArray(c.value)||c.value.length!==4||c.value.some(v=>!Number.isFinite(v)||v<0||v>1)) fail("AUTHORING_PARAMETER",{parameter:"clearColor"});
if(!object(z)||z.kind!=="number"||!Number.isFinite(z.value)||z.value<0||z.value>1) fail("AUTHORING_PARAMETER",{parameter:"clearDepth"});
parameters={clearColor:[...c.value],clearDepth:z.value};
}
const projected={type:n.typeId,id:n.id,sockets,parameters,muted:n.muted}; byId.set(n.id,projected); byType.set(n.typeId,projected);
}
if(!keysEqual([...byType.keys()],Object.keys(descriptors))) fail("AUTHORING_TOPOLOGY",{reason:"node-set"});
const linkIds=new Set(), incoming=new Set(), links=[];
for(const l of raw.links){
if(!object(l)||!validId(l.id)) fail("AUTHORING_ID",{kind:"link",id:l?.id});
if(linkIds.has(l.id)) fail("AUTHORING_ID_DUPLICATE",{kind:"link",id:l.id}); linkIds.add(l.id);
if(typeof l.muted!=="boolean") fail("AUTHORING_LINK",{linkId:l.id,reason:"muted"});
const from=byId.get(l.fromNodeId),to=byId.get(l.toNodeId),fs=from&&Object.values(from.sockets).find(s=>s.id===l.fromSocketId),ts=to&&Object.values(to.sockets).find(s=>s.id===l.toSocketId);
if(!fs||!ts||fs.direction!=="output"||ts.direction!=="input"||fs.type!==ts.type) fail("AUTHORING_LINK",{linkId:l.id});
if(incoming.has(ts.id)) fail("AUTHORING_LINK_INCOMING",{socketId:ts.id}); incoming.add(ts.id);
links.push({from:`${from.type}.${Object.keys(from.sockets).find(k=>from.sockets[k]===fs)}`,to:`${to.type}.${Object.keys(to.sockets).find(k=>to.sockets[k]===ts)}`,muted:l.muted});
}
const required=["surface_color.surface>scene_forward.color","depth32.depth>scene_forward.depth","scene_forward.result>present.surface"];
const active=links.filter(l=>!l.muted).map(l=>`${l.from}>${l.to}`).sort();
if(!keysEqual(active,required.sort())||links.length!==3) fail("AUTHORING_TOPOLOGY",{reason:"links"});
return Object.freeze({graphId:GRAPH_ID,clearColor:byType.get("scene_forward").parameters.clearColor,clearDepth:byType.get("scene_forward").parameters.clearDepth,passState:byType.get("scene_forward").muted?"disabled":"enabled"});
}
export function semanticProjectionToV1(p, revision=1){
if(!Number.isInteger(revision)||revision<1||revision>0xffffffff) fail("AUTHORING_REVISION",{revision});
const extent={kind:"surface_relative",width:{numerator:1,denominator:1},height:{numerator:1,denominator:1},depthOrArrayLayers:1};
return {schemaVersion:1,graphId:p.graphId,revision,resources:[
{id:"surface",version:0,residency:{kind:"external",source:"surface_color"},texture:{dimension:"d2",format:"surface",extent,mipLevelCount:1,sampleCount:1}},
{id:"depth",version:0,residency:{kind:"transient"},texture:{dimension:"d2",format:"depth32_float",extent,mipLevelCount:1,sampleCount:1}},
],passes:[{id:"forward",state:p.passState,executor:{key:"scene_forward",version:1},parameters:{},reads:[],writes:[
{binding:"color",resource:{id:"surface",version:0},access:{kind:"color_attachment",location:0,load:{op:"clear",value:p.clearColor},store:"store"}},
{binding:"depth",resource:{id:"depth",version:0},access:{kind:"depth_attachment",load:{op:"clear",value:p.clearDepth},store:"store"}},
]}],outputs:[{name:"present",resource:{id:"surface",version:0}}]};
}
export const adaptFxNodeSnapshot=(snapshot,revision=1)=>semanticProjectionToV1(projectAuthoringSnapshot(snapshot),revision);
export const adaptGraphSnapshot=adaptFxNodeSnapshot;
@@ -0,0 +1,14 @@
export class AuthoringController {
#renderer; #getState; #revision=0; #nextRevision=1; #dirty=true; #applying=null; #listeners=new Set();
constructor({renderer,getState}) { this.#renderer=renderer; this.#getState=getState; }
get revision(){return this.#revision} get dirty(){return this.#dirty} get applying(){return !!this.#applying}
subscribe(fn){this.#listeners.add(fn);return()=>this.#listeners.delete(fn)}
markDirty(){this.#dirty=true;this.#emit()}
#emit(){for(const fn of this.#listeners)fn({revision:this.#revision,dirty:this.#dirty,applying:!!this.#applying})}
apply(adapt){
if(this.#applying)return this.#applying;
const revision=this.#nextRevision++; this.#dirty=false; this.#emit();
this.#applying=(async()=>{try{const snapshot=await this.#getState();const ir=adapt(snapshot,revision);const compiled=await this.#renderer.compileGraph(ir);await this.#renderer.switchCompiledGraph(compiled.compiledId);this.#revision=revision;return compiled} catch(e){this.#dirty=true;throw e} finally{this.#applying=null;this.#emit()}})();
return this.#applying;
}
}
+61
View File
@@ -0,0 +1,61 @@
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, chooseNodeType }={}) {
const ownerDocument=canvas.ownerDocument, ownerWindow=ownerDocument.defaultView ?? window;
const originalTabIndex=canvas.getAttribute("tabindex"), originalTouchAction=canvas.style.touchAction;
let view, dead=false, generation=0, resizing=false, pending, appliedViewport, menuPending=false, unsubscribeHost=()=>{};
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"){menuPending=e.button===2&&!e.ctrlKey&&(e.buttons&1)===0;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(); menuPending=false;
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){menuPending=false;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;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){
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)return;menuPending=false;const typeId=chooseNodeType?.(request);if(typeId)view.addNode({typeId,viewPosition:request.viewPosition}).catch(onError)});
observer.observe(canvas);resize();
},destroy(){
if(dead)return;dead=true;generation++;pending=undefined;observer.disconnect();unsubscribeHost();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;
}};
}
function prevent(e){e.preventDefault()}
+29
View File
@@ -0,0 +1,29 @@
export const GRAPH_ID = "demo_forward";
export const CATALOG_VERSION = 1;
export const socketTypes = {
surface: { title: "Surface", color: "#62b0ff", acceptsFrom: ["surface"] },
depth: { title: "Depth", color: "#b58cff", acceptsFrom: ["depth"] },
};
export const theme = {
background:"#151820",grid:"#292e3a",frame:"#30343a80",frameHeader:"#59616c",body:"#292e39",control:"#191d26",controlFill:"#4775b8",controlEditing:"#101218",textSelection:"#4775b8",outline:"#0b0d12",text:"#edf1f7",muted:"#969eaa",shadow:"#00000088",nodeSelected:"#ff9f43",nodeActive:"#ffffff",unknownHeader:"#555b64",unknownSocket:"#999999",linkMuted:"#d94b4b",knifeMuted:"#e85b5b",emphasis:"#ffffff",focus:"#f5a623",editOutline:"#666a70",resize:"#8b8e95",muteOverlay:"#14141459",boxSelectionFill:"#f5a6231f",checkerLight:"#aaaaaa",checkerDark:"#777777",widgetBorder:"#111216",rampBorder:"#111111",resourceBackground:"#202228"
};
export const styles = { resource:{header:"#3977a8"}, pass:{header:"#426b43"}, output:{header:"#a75d37"} };
const socket = (title, direction, type) => ({ title,direction,type,maxIncomingLinks:direction === "input" ? 1 : 0,visible:true,value:null,showValue:false });
const node = (title, style, sockets, parameters = {}) => ({ version:1,title,behavior:"standard",style,parameters,sockets,ui:[...Object.keys(parameters).map(parameter=>({kind:"parameter",parameter})),...Object.keys(sockets).map(socket=>({kind:"socket",socket}))],muteBypass:[],migrations:[] });
export const nodeDefinitions = {
surface_color: node("Surface Color", "resource", { surface:socket("Surface","output","surface") }),
depth32: node("Depth 32", "resource", { depth:socket("Depth","output","depth") }),
scene_forward: node("Scene Forward", "pass", { color:socket("Color","input","surface"),depth:socket("Depth","input","depth"),result:socket("Result","output","surface") }, {
clearColor:{type:"color",default:{kind:"color",value:[0,0,0,1]},minimum:0,maximum:1},
clearDepth:{type:"number",default:{kind:"number",value:1},minimum:0,maximum:1,step:0.01},
}),
present: node("Present", "output", { surface:socket("Surface","input","surface") }),
};
export const descriptors = Object.freeze({
surface_color:{version:1,sockets:{surface:["output","surface"]},parameters:[]},
depth32:{version:1,sockets:{depth:["output","depth"]},parameters:[]},
scene_forward:{version:1,sockets:{color:["input","surface"],depth:["input","depth"],result:["output","surface"]},parameters:["clearColor","clearDepth"]},
present:{version:1,sockets:{surface:["input","surface"]},parameters:[]},
});
+25
View File
@@ -0,0 +1,25 @@
import { createFxNode } from "@fxnode/index.ts";
import { CATALOG_VERSION, GRAPH_ID, nodeDefinitions, socketTypes, styles, theme } from "./catalog.js";
import { prepareBrowserHost } from "./browser-host.js";
const spec=[
["surface","surface_color",{x:40,y:100}],
["depth","depth32",{x:40,y:330}],
["forward","scene_forward",{x:360,y:190}],
["present","present",{x:700,y:220}],
];
async function seed(root){
await root.setState({graphId:GRAPH_ID,catalogVersion:CATALOG_VERSION,nodes:[],links:[],metadata:{}});
for(const [nodeId,nodeType,position] of spec) await root.dispatch({type:"node.add",nodeId,nodeType,position});
for(const link of [
{id:"surface_link",fromNodeId:"surface",fromSocketId:"surface:surface",toNodeId:"forward",toSocketId:"forward:color",muted:false,extensions:{}},
{id:"depth_link",fromNodeId:"depth",fromSocketId:"depth:depth",toNodeId:"forward",toSocketId:"forward:depth",muted:false,extensions:{}},
{id:"present_link",fromNodeId:"forward",fromSocketId:"forward:result",toNodeId:"present",toSocketId:"present:surface",muted:false,extensions:{}},
]) await root.dispatch({type:"link.add",link});
}
export async function createRenderGraphEditor(canvas){
const chooseNodeType=()=>{const value=canvas.ownerDocument.defaultView?.prompt(`Node type: ${Object.keys(nodeDefinitions).join(", ")}`,"scene_forward");return Object.hasOwn(nodeDefinitions,value)?value:null};
const host=prepareBrowserHost(canvas,{chooseNodeType});let root,view,destroying;
const destroy=()=>destroying??=(async()=>{host.destroy();try{await view?.detach()}finally{root?.destroy();view=undefined;root=undefined}})();
try{root=await createFxNode({applicationId:"yawn.render-graph",applicationVersion:1,resources:{}});await root.setTheme(theme);await root.setHeaderStyles(styles);for(const entry of Object.entries(socketTypes))await root.composeSocket(...entry);for(const entry of Object.entries(nodeDefinitions))await root.composeNode(...entry);await seed(root);view=await root.attachView({canvas,viewport:host.initialViewport,initialCamera:{center:{x:470,y:210},zoom:.5}});host.attach(root,view);await view.whenRendered();return {getState:()=>root.getState(),onSnapshots:fn=>root.onSnapshots(fn),whenRendered:()=>view.whenRendered(),destroy};}catch(e){await destroy().catch(()=>{});throw e}
}
+5
View File
@@ -0,0 +1,5 @@
import { semanticProjectionToV1 } from "./adapter.js";
const make=(graphId,clearColor)=>Object.freeze(semanticProjectionToV1({graphId,clearColor,clearDepth:1,passState:"enabled"},1));
export const midnight=make("preset_midnight",[0.015,0.06,0.18,1]);
export const ember=make("preset_ember",[0.18,0.035,0.012,1]);
export const renderGraphPresets=Object.freeze({midnight,ember});