feat: add gpu driven render graph pipeline

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 20:26:31 +00:00
co-authored by heaust
parent d4e8634f67
commit 05311e564c
51 changed files with 13262 additions and 743 deletions
+37 -2
View File
@@ -48,12 +48,47 @@ export function createUvSphereGeometry(segments=24,rings=12){
const positions=[],normals=[],texcoords=[],indices=[];for(let y=0;y<=rings;y++){const v=y/rings,phi=v*Math.PI;for(let x=0;x<=segments;x++){const u=x/segments,theta=u*Math.PI*2,nx=Math.sin(phi)*Math.cos(theta),ny=Math.cos(phi),nz=Math.sin(phi)*Math.sin(theta);positions.push(nx,ny,nz);normals.push(nx,ny,nz);texcoords.push(u,v);}}
for(let y=0;y<rings;y++)for(let x=0;x<segments;x++){const a=y*(segments+1)+x,b=a+segments+1;indices.push(a,a+1,b,a+1,b+1,b);}return {positions,normals,texcoords,indices};
}
const galleryPngBase64=Object.freeze({
base:"iVBORw0KGgoAAAANSUhEUgAAAAMAAAACCAYAAACddGYaAAAAIUlEQVR42mP4ryH3X+NOgIZGwP//DP/vyGn8BwI5Obn/AKsPDa3HqsdFAAAAAElFTkSuQmCC",
mr:"iVBORw0KGgoAAAANSUhEUgAAAAMAAAACCAYAAACddGYaAAAAJUlEQVR42gEaAOX/AP8gAP//YED//7Sg/wD/8P///0Dc///cIP/dYBIQ76JUtAAAAABJRU5ErkJggg==",
normal:"iVBORw0KGgoAAAANSUhEUgAAAAMAAAACCAYAAACddGYaAAAAH0lEQVR42mNoaPj//0TDu/8WQMzQcOLd/wYLIAYKAgDieBGxoS0BjwAAAABJRU5ErkJggg==",
});
const decodeBase64=value=>{const binary=atob(value),bytes=new Uint8Array(binary.length);for(let i=0;i<binary.length;i++)bytes[i]=binary.charCodeAt(i);return bytes;};
/** Build the deterministic Phase 6 PBR shader validation gallery. */
export function createMaterialGalleryGlb(){
// A modest shared sphere keeps the embedded GLB compact while making roughness
// and normal-map responses much easier to compare than the former cubes.
const geometry=createUvSphereGeometry(16,8);
const streams=[new Float32Array(geometry.positions),new Float32Array(geometry.normals),new Float32Array(geometry.texcoords),new Uint32Array(geometry.indices)];
const images=Object.values(galleryPngBase64).map(decodeBase64),chunks=[],bufferViews=[];let byteLength=0;
for(const stream of [...streams,...images]){byteLength=align4(byteLength);const bytes=stream instanceof Uint8Array?stream:new Uint8Array(stream.buffer);chunks.push({offset:byteLength,bytes});bufferViews.push({buffer:0,byteOffset:byteLength,byteLength:bytes.length});byteLength+=bytes.length;}
byteLength=align4(byteLength);const bounds=finiteMinMax(streams[0],3),vertexCount=geometry.positions.length/3;
const materials=[
...[0.08,0.3,0.6,1].map(roughnessFactor=>({name:`Dielectric roughness ${roughnessFactor}`,pbrMetallicRoughness:{baseColorFactor:[0.72,0.18,0.08,1],metallicFactor:0,roughnessFactor}})),
...[0.08,0.3,0.6,1].map(roughnessFactor=>({name:`Metal roughness ${roughnessFactor}`,pbrMetallicRoughness:{baseColorFactor:[0.72,0.76,0.82,1],metallicFactor:1,roughnessFactor}})),
...[1,1.5,2].map(ior=>({name:`Dielectric IOR ${ior}`,pbrMetallicRoughness:{baseColorFactor:[0.12,0.48,0.82,1],metallicFactor:0,roughnessFactor:0.18},extensions:{KHR_materials_ior:{ior}}})),
{name:"Odd-width OpenGL normal map",pbrMetallicRoughness:{baseColorFactor:[0.7,0.7,0.7,1],metallicFactor:0,roughnessFactor:0.4},normalTexture:{index:2,scale:1}},
{name:"Odd-width AO",pbrMetallicRoughness:{baseColorFactor:[0.8,0.55,0.12,1],metallicFactor:0,roughnessFactor:0.65},occlusionTexture:{index:1,strength:1}},
{name:"Odd-width emissive",pbrMetallicRoughness:{baseColorFactor:[0.03,0.03,0.03,1],metallicFactor:0,roughnessFactor:0.8},emissiveFactor:[1,0.3,0.05],emissiveTexture:{index:0}},
{name:"Odd-width alpha MASK",pbrMetallicRoughness:{baseColorFactor:[1,1,1,1],baseColorTexture:{index:0},metallicFactor:0,roughnessFactor:0.55},alphaMode:"MASK",alphaCutoff:0.5,doubleSided:true},
{name:"Reflected non-uniform double-sided",pbrMetallicRoughness:{baseColorFactor:[0.25,0.85,0.38,1],metallicFactor:0.15,roughnessFactor:0.45},doubleSided:true},
];
const nodes=materials.map((material,index)=>({name:material.name,mesh:index,translation:[(index%4-1.5)*2.5,(1.5-Math.floor(index/4))*2.5,0],...(index===15?{scale:[-1.25,0.7,1.1]}:{})}));
const meshes=materials.map((material,index)=>({name:material.name,primitives:[{attributes:{POSITION:0,NORMAL:1,TEXCOORD_0:2},indices:3,material:index}]}));
const json={asset:{version:"2.0",generator:"yawn-phase6-pbr-gallery"},extensionsUsed:["KHR_materials_ior"],scene:0,scenes:[{name:"Phase 6 deterministic PBR gallery",nodes:nodes.map((_,i)=>i)}],nodes,meshes,materials,
samplers:[{magFilter:9728,minFilter:9728,wrapS:10497,wrapT:10497}],images:images.map((_,i)=>({name:["Odd-width sRGB base color and emissive","Odd-width linear MR and AO","Odd-width OpenGL normal map"][i],bufferView:i+4,mimeType:"image/png"})),textures:images.map((_,i)=>({sampler:0,source:i})),
buffers:[{byteLength}],bufferViews,accessors:[{bufferView:0,componentType:5126,count:vertexCount,type:"VEC3",min:bounds.min,max:bounds.max},{bufferView:1,componentType:5126,count:vertexCount,type:"VEC3"},{bufferView:2,componentType:5126,count:vertexCount,type:"VEC2"},{bufferView:3,componentType:5125,count:geometry.indices.length,type:"SCALAR"}]};
let jsonBytes=encoder.encode(JSON.stringify(json));const jsonLength=align4(jsonBytes.length),total=12+8+jsonLength+8+byteLength,out=new ArrayBuffer(total),view=new DataView(out),bytes=new Uint8Array(out);
view.setUint32(0,0x46546c67,true);view.setUint32(4,2,true);view.setUint32(8,total,true);view.setUint32(12,jsonLength,true);view.setUint32(16,JSON_CHUNK,true);bytes.fill(0x20,20,20+jsonLength);bytes.set(jsonBytes,20);const binHeader=20+jsonLength;view.setUint32(binHeader,byteLength,true);view.setUint32(binHeader+4,BIN_CHUNK,true);for(const chunk of chunks)bytes.set(chunk.bytes,binHeader+8+chunk.offset);return out;
}
export function isGitLfsPointer(bytes){const text=new TextDecoder().decode(new Uint8Array(bytes,0,Math.min(bytes.byteLength,256)));return text.startsWith("version https://git-lfs.github.com/spec/v1\n");}
export class LoadoutError extends Error{constructor(code,message){super(message);this.name="LoadoutError";this.code=code;}}
export const loadouts=Object.freeze({cubes:{label:"Procedural cubes"},spheres:{label:"Procedural spheres"},manor:{label:"The Manor"},sponza:{label:"Sponza"}});
export const loadouts=Object.freeze({cubes:{label:"Procedural cubes"},spheres:{label:"Procedural spheres"},materials:{label:"Phase 6 deterministic PBR gallery"},manor:{label:"The Manor"},sponza:{label:"Sponza"}});
const assetUrls=Object.freeze({manor:new URL("./themanor.glb",import.meta.url),sponza:new URL("./sponza.glb",import.meta.url)});
export async function loadDemoLoadout(id,{signal,fetchImpl=fetch}={}){
if(id==="cubes")return encodeGeometryGlb(createCubeGeometry());if(id==="spheres")return encodeGeometryGlb(createUvSphereGeometry());
if(id==="cubes")return encodeGeometryGlb(createCubeGeometry());if(id==="spheres")return encodeGeometryGlb(createUvSphereGeometry());if(id==="materials")return createMaterialGalleryGlb();
const url=assetUrls[id];if(!url)throw new LoadoutError("LOADOUT_UNKNOWN",`Unknown loadout: ${id}`);
let response;try{response=await fetchImpl(url,{signal});}catch(error){if(error?.name==="AbortError")throw error;throw new LoadoutError("LOADOUT_FETCH_FAILED",`Could not fetch ${id}: ${error?.message||"network error"}`);}
if(!response.ok)throw new LoadoutError("LOADOUT_HTTP",`Could not fetch ${id}: HTTP ${response.status}`);const buffer=await response.arrayBuffer();if(isGitLfsPointer(buffer))throw new LoadoutError("LOADOUT_LFS_POINTER",`${id} is a Git LFS pointer; hydrate repository assets first`);return buffer;
+199 -4
View File
@@ -1,5 +1,200 @@
<!doctype html>
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Yawn Render Graph Demo</title>
<style>
*{box-sizing:border-box}html,body{height:100%;margin:0;overflow:hidden;background:#0b0e14;color:#eef2f8;font:14px Inter,system-ui,sans-serif}main{display:grid;grid-template-columns:minmax(0,3fr) minmax(380px,2fr);height:100%}.viewport,.editor{min-width:0;min-height:0;position:relative}canvas{display:block;width:100%;height:100%}#canvas0{background:#090d16}.toolbar{position:absolute;z-index:2;inset:18px 18px auto;display:flex;align-items:end;gap:14px;padding:13px 16px;border:1px solid #ffffff18;border-radius:10px;background:#111722e8;box-shadow:0 12px 30px #0008}.brand{margin-right:auto}.brand strong{display:block;font-size:17px;letter-spacing:.08em}.brand small{color:#8d9bb1}.field{display:grid;gap:5px;color:#9da9ba;font-size:11px;text-transform:uppercase;letter-spacing:.08em}select,button{font:inherit;color:#eef;background:#202938;border:1px solid #3a4659;border-radius:6px;padding:7px 10px}button{background:#ba5c2e;border-color:#d77949;font-weight:650;cursor:pointer}button:disabled,select:disabled{opacity:.48;cursor:wait}#demo-status{position:absolute;z-index:2;left:18px;bottom:18px;padding:9px 12px;border-radius:7px;background:#0b1019dc;color:#bac6d8;box-shadow:0 5px 20px #0008}.editor{display:grid;grid-template-rows:58px minmax(0,1fr);border-left:1px solid #2d3542;background:#151820}.editor-bar{display:flex;align-items:center;gap:12px;padding:0 14px;border-bottom:1px solid #303745}.editor-bar strong{font-size:15px}.editor-bar span{color:#9ba7b9}@media(max-width:820px){main{grid-template-columns:1fr;grid-template-rows:52% 48%}.editor{border-left:0;border-top:1px solid #2d3542}.toolbar{flex-wrap:wrap}.brand{width:100%}}
</style></head><body><main><section class="viewport" aria-label="Rendered scene"><div class="toolbar"><div class="brand"><strong>YAWN</strong><small>Render Graph Studio</small></div><label class="field" for="loadout-select">Scene loadout<select id="loadout-select"><option value="cubes">Cubes</option><option value="spheres">UV spheres</option><option value="manor">The Manor</option><option value="sponza">Sponza</option></select></label><label class="field" for="graph-select">Graph preset<select id="graph-select"><option value="authored">Authored</option><option value="midnight">Midnight</option><option value="ember">Ember</option></select></label></div><canvas id="canvas0"></canvas><output id="demo-status" aria-live="polite">Starting Phase 8…</output></section><section class="editor" aria-label="Render graph editor"><div class="editor-bar"><strong>Authored Graph</strong><button id="apply-graph" disabled>Apply</button><span id="graph-status">Loading editor…</span></div><canvas id="graph-editor"></canvas></section></main><script type="module" src="./index.js"></script></body></html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Yawn Render Graph Demo</title>
<style>
* {
box-sizing: border-box;
}
html,
body {
height: 100%;
margin: 0;
overflow: hidden;
background: #0b0e14;
color: #eef2f8;
font:
14px Inter,
system-ui,
sans-serif;
}
main {
display: grid;
grid-template-columns: minmax(0, 3fr) minmax(380px, 2fr);
height: 100%;
}
.viewport,
.editor {
min-width: 0;
min-height: 0;
position: relative;
}
canvas {
display: block;
width: 100%;
height: 100%;
}
#canvas0 {
background: #090d16;
}
.toolbar {
position: absolute;
z-index: 2;
inset: 18px 18px auto;
display: flex;
align-items: end;
gap: 14px;
padding: 13px 16px;
border: 1px solid #ffffff18;
border-radius: 10px;
background: #111722e8;
box-shadow: 0 12px 30px #0008;
}
.brand {
margin-right: auto;
}
.brand strong {
display: block;
font-size: 17px;
letter-spacing: 0.08em;
}
.brand small {
color: #8d9bb1;
}
.field {
display: grid;
gap: 5px;
color: #9da9ba;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
}
#profile-menu { min-width: 210px; color: #9da9ba; }
#profile-menu summary { cursor: pointer; color: #eef2f8; }
#profile-menu table { width: 100%; font-size: 11px; }
#profile-menu th { text-align: left; font-weight: 500; }
#profile-menu td { text-align: right; font-variant-numeric: tabular-nums; }
select,
button {
font: inherit;
color: #eef;
background: #202938;
border: 1px solid #3a4659;
border-radius: 6px;
padding: 7px 10px;
}
button {
background: #ba5c2e;
border-color: #d77949;
font-weight: 650;
cursor: pointer;
}
button:disabled,
select:disabled {
opacity: 0.48;
cursor: wait;
}
#demo-status {
position: absolute;
z-index: 2;
left: 18px;
bottom: 18px;
padding: 9px 12px;
border-radius: 7px;
background: #0b1019dc;
color: #bac6d8;
box-shadow: 0 5px 20px #0008;
}
.editor {
display: grid;
grid-template-rows: 58px minmax(0, 1fr);
border-left: 1px solid #2d3542;
background: #151820;
}
.editor-bar {
display: flex;
align-items: center;
gap: 12px;
padding: 0 14px;
border-bottom: 1px solid #303745;
}
.editor-bar strong {
font-size: 15px;
}
.editor-bar span {
color: #9ba7b9;
}
.fxnode-add-menu {
position: fixed; z-index: 20; width: min(280px, calc(100vw - 16px)); max-height: min(440px, calc(100vh - 16px));
padding: 8px; overflow: hidden; border: 1px solid #495568; border-radius: 8px; background: #171d27; box-shadow: 0 14px 40px #000b;
}
.fxnode-add-menu[hidden] { display: none; }
.fxnode-add-menu input { width: 100%; padding: 8px; color: #eef2f8; background: #0f141c; border: 1px solid #3a4659; border-radius: 5px; }
.fxnode-add-menu__list { max-height: 360px; margin-top: 6px; overflow: auto; }
.fxnode-add-menu__group { padding: 8px 7px 3px; color: #8491a5; font-size: 10px; font-weight: 700; letter-spacing: .1em; text-transform: uppercase; }
.fxnode-add-menu .fxnode-add-menu__option { display: block; width: 100%; padding: 7px 9px; border: 0; text-align: left; text-transform: capitalize; background: transparent; }
.fxnode-add-menu__option[aria-selected="true"] { background: #394a64; outline: 1px solid #6883aa; }
@media (max-width: 820px) {
main {
grid-template-columns: 1fr;
grid-template-rows: 52% 48%;
}
.editor {
border-left: 0;
border-top: 1px solid #2d3542;
}
.toolbar {
flex-wrap: wrap;
}
.brand {
width: 100%;
}
}
</style>
</head>
<body>
<main>
<section class="viewport" aria-label="Rendered scene">
<div class="toolbar">
<div class="brand">
<strong>YAWN</strong><small>Render Graph Studio</small>
</div>
<label class="field" for="loadout-select"
>Scene loadout<select id="loadout-select">
<option value="cubes">Cubes</option>
<option value="spheres">UV spheres</option>
<option value="materials">Phase 6 PBR gallery</option>
<option value="manor">The Manor</option>
<option value="sponza">Sponza</option>
</select></label
><label class="field" for="graph-select"
>Graph preset<select id="graph-select">
<option value="authored">Authored</option>
<option value="midnight">Midnight</option>
<option value="ember">Ember</option>
<option value="hdr">HDR Fullscreen</option>
<option value="culling">GPU frustum culling</option>
<option value="tone">Tone map</option>
<option value="edges">Edges</option>
<option value="bloom">Bloom</option>
<option value="combined">Combined</option>
</select></label
>
</div>
<canvas id="canvas0"></canvas
><output id="demo-status" aria-live="polite">Starting Phase 8…</output>
</section>
<section class="editor" aria-label="Render graph editor">
<div class="editor-bar">
<strong>Authored Graph</strong
><button id="apply-graph" disabled>Apply</button
><span id="graph-status">Loading editor…</span>
</div>
<canvas id="graph-editor"></canvas>
</section>
</main>
<script type="module" src="./index.js"></script>
</body>
</html>
+335 -43
View File
@@ -6,55 +6,347 @@ import { AuthoringController } from "./render-graph/authoring-controller.js";
import { createRenderGraphEditor } from "./render-graph/fxnode-editor.js";
import { renderGraphPresets } from "./render-graph/presets.js";
let renderer,editor,controller,assetAbort,busy=false,cleaned=false;
let unsubscribeController=()=>{},unsubscribeSnapshots=()=>{};
const listeners=[];
const on=(target,type,fn)=>{target.addEventListener(type,fn);listeners.push(()=>target.removeEventListener(type,fn));};
const status=message=>{const node=document.querySelector("#demo-status");if(node)node.textContent=message;};
const sameId=(a,b)=>Array.isArray(a)&&Array.isArray(b)&&a[0]===b[0]&&a[1]===b[1];
const state={loadout:"cubes",graph:"authored",compiled:{},telemetry:null};
let renderer,
editor,
controller,
assetAbort,
busy = false,
cleaned = false;
let unsubscribeController = () => {},
unsubscribeSnapshots = () => {};
const listeners = [];
const on = (target, type, fn) => {
target.addEventListener(type, fn);
listeners.push(() => target.removeEventListener(type, fn));
};
const status = (message) => {
const node = document.querySelector("#demo-status");
if (node) node.textContent = message;
};
const sameId = (a, b) =>
Array.isArray(a) && Array.isArray(b) && a[0] === b[0] && a[1] === b[1];
const state = {
loadout: "cubes",
graph: "authored",
compiled: {},
telemetry: null,
};
export const profileRequested = (search) =>
new URLSearchParams(search).get("profile") === "1";
const profileEnabled = profileRequested(location.search);
function installProfileMenu() {
if (!profileEnabled) return;
const menu = document.createElement("details");
menu.id = "profile-menu";
menu.innerHTML =
'<summary>GPU profile</summary><div id="profile-status">Waiting for GPU timestamps…</div><table><tbody id="profile-passes"></tbody></table>';
document.querySelector(".toolbar")?.append(menu);
on(window, "renderer-profile", (event) => {
const p = event.detail;
document.querySelector("#profile-status").textContent = p.available
? `${p.graph} · epoch ${p.epoch} · ${p.dropped} dropped`
: "GPU timestamps unavailable";
document.querySelector("#profile-passes").replaceChildren(
...Object.entries(p.passes || {}).map(([id, ms]) => {
const row = document.createElement("tr"),
name = document.createElement("th"),
value = document.createElement("td");
name.textContent = id;
value.textContent = `${Number(ms).toFixed(3)} ms`;
row.append(name, value);
return row;
}),
);
});
}
function publish(telemetry){
state.telemetry=telemetry;
document.documentElement.dataset.phase8State=JSON.stringify({activeLoadout:state.loadout,activeGraph:state.graph,renderDataRevision:telemetry.revision,renderMode:telemetry.renderMode,activeCompiledId:telemetry.activeCompiledId,activeCompiledGraph:telemetry.activeCompiledGraph,activeCompiledRevision:telemetry.activeCompiledRevision,graphPasses:telemetry.graphPasses,draws:telemetry.draws,instances:telemetry.instances,indices:telemetry.indices,framingRadius:telemetry.framingRadius,gpuError:telemetry.gpuError});
function publish(telemetry) {
state.telemetry = telemetry;
document.documentElement.dataset.phase8State = JSON.stringify({
activeLoadout: state.loadout,
activeGraph: state.graph,
renderDataRevision: telemetry.revision,
renderMode: telemetry.renderMode,
activeCompiledId: telemetry.activeCompiledId,
activeCompiledGraph: telemetry.activeCompiledGraph,
activeCompiledRevision: telemetry.activeCompiledRevision,
activeCompiledSchemaVersion: telemetry.activeCompiledSchemaVersion,
graphPasses: telemetry.graphPasses,
graphExecutions: telemetry.graphExecutions,
graphTextureSlots: telemetry.graphTextureSlots,
draws: telemetry.draws,
instances: telemetry.instances,
indices: telemetry.indices,
framingRadius: telemetry.framingRadius,
gpuError: telemetry.gpuError,
});
}
function waitTelemetry(predicate,timeout=30000){
const current=renderer?.telemetry;if(current&&predicate(current))return Promise.resolve(current);
return new Promise((resolve,reject)=>{let timer;const done=()=>{clearTimeout(timer);removeEventListener("renderer-frame",frame);};const frame=e=>{if(predicate(e.detail)){done();resolve(e.detail);}};timer=setTimeout(()=>{done();reject(new Error("Telemetry confirmation timed out"));},timeout);onAbort=()=>{done();reject(new RendererError("DISPOSED"));};addEventListener("renderer-frame",frame);timer.unref?.();});
function waitTelemetry(predicate, timeout = 30000) {
const current = renderer?.telemetry;
if (current && predicate(current)) return Promise.resolve(current);
return new Promise((resolve, reject) => {
let timer;
const done = () => {
clearTimeout(timer);
removeEventListener("renderer-frame", frame);
};
const frame = (e) => {
if (predicate(e.detail)) {
done();
resolve(e.detail);
}
};
timer = setTimeout(() => {
done();
reject(new Error("Telemetry confirmation timed out"));
}, timeout);
onAbort = () => {
done();
reject(new RendererError("DISPOSED"));
};
addEventListener("renderer-frame", frame);
timer.unref?.();
});
}
let onAbort=()=>{};
async function transaction(label,operation,rollback){
if(busy||cleaned)return false;busy=true;document.querySelectorAll("select, #apply-graph").forEach(x=>x.disabled=true);status(label);
try{const telemetry=await operation();if(cleaned)return false;if(telemetry){publish(telemetry);status(`${state.loadout} · ${state.graph} · ${telemetry.draws} draws · ${telemetry.instances} instances`);}else status(`${state.loadout} · ${state.graph} · committed; telemetry pending`);return true;}
catch(error){if(!cleaned){try{await rollback?.();}catch(rollbackError){console.error("Phase 8 rollback failed",rollbackError);}console.error("Phase 8 transaction failed",error);status(`Failed · ${error?.code??error?.message??error}`);}return false;}
finally{busy=false;if(!cleaned){document.querySelectorAll("select").forEach(x=>x.disabled=false);const button=document.querySelector("#apply-graph");if(button)button.disabled=controller?.applying||!controller?.dirty;}}
let onAbort = () => {};
async function transaction(label, operation, rollback) {
if (busy || cleaned) return false;
busy = true;
document
.querySelectorAll("select, #apply-graph")
.forEach((x) => (x.disabled = true));
status(label);
try {
const telemetry = await operation();
if (cleaned) return false;
if (telemetry) {
publish(telemetry);
status(
`${state.loadout} · ${state.graph} · ${telemetry.draws} draws · ${telemetry.instances} instances`,
);
} else
status(
`${state.loadout} · ${state.graph} · committed; telemetry pending`,
);
return true;
} catch (error) {
if (!cleaned) {
try {
await rollback?.();
} catch (rollbackError) {
console.error("Phase 8 rollback failed", rollbackError);
}
console.error("Phase 8 transaction failed", error);
status(`Failed · ${error?.code ?? error?.message ?? error}`);
}
return false;
} finally {
busy = false;
if (!cleaned) {
document.querySelectorAll("select").forEach((x) => (x.disabled = false));
const button = document.querySelector("#apply-graph");
if (button) button.disabled = !controller?.canApply;
}
}
}
async function selectLoadout(next,select){
const previous=state.loadout,targetRevision=(renderer.telemetry?.revision??0)+1;assetAbort=new AbortController();
const ok=await transaction(`Loading ${next}`,async()=>{const glb=await loadDemoLoadout(next,{signal:assetAbort.signal});await renderer.replaceSceneGlb(glb,{framing:next==="sponza"?"interior":"exterior"});state.loadout=next;return waitTelemetry(x=>x.revision===targetRevision&&x.draws>0&&x.activeCompiledGraph===state.compiled[state.graph].graphId&&x.gpuError===false).catch(()=>null);});
assetAbort=undefined;if(!ok)select.value=previous;
async function selectLoadout(next, select) {
const previous = state.loadout,
targetRevision = (renderer.telemetry?.revision ?? 0) + 1;
assetAbort = new AbortController();
const ok = await transaction(`Loading ${next}`, async () => {
const glb = await loadDemoLoadout(next, { signal: assetAbort.signal });
await renderer.replaceSceneGlb(glb, {
framing: next === "sponza" ? "interior" : "exterior",
});
state.loadout = next;
return waitTelemetry(
(x) =>
x.revision === targetRevision &&
x.draws > 0 &&
x.activeCompiledGraph === state.compiled[state.graph].graphId &&
x.gpuError === false,
).catch(() => null);
});
assetAbort = undefined;
if (!ok) select.value = previous;
}
async function selectGraph(next,select){
const previous=state.graph,compiled=state.compiled[next];
const ok=await transaction(`Activating ${next}`,async()=>{await renderer.switchCompiledGraph(compiled.compiledId);state.graph=next;return waitTelemetry(x=>sameId(x.activeCompiledId,compiled.compiledId)&&x.activeCompiledGraph===compiled.graphId&&x.activeCompiledRevision===compiled.revision&&x.gpuError===false).catch(()=>null);},async()=>{state.graph=previous;select.value=previous;});
if(!ok)select.value=previous;
async function selectGraph(next, select) {
const previous = state.graph,
compiled = state.compiled[next];
const ok = await transaction(
`Activating ${next}`,
async () => {
await renderer.switchCompiledGraph(compiled.compiledId);
state.graph = next;
return waitTelemetry(
(x) =>
sameId(x.activeCompiledId, compiled.compiledId) &&
x.activeCompiledGraph === compiled.graphId &&
x.activeCompiledRevision === compiled.revision &&
x.gpuError === false,
).catch(() => null);
},
async () => {
state.graph = previous;
select.value = previous;
},
);
if (!ok) select.value = previous;
}
async function cleanup(){if(cleaned)return;cleaned=true;removeEventListener("pagehide",pagehide);assetAbort?.abort();onAbort();listeners.splice(0).forEach(fn=>fn());unsubscribeController();unsubscribeSnapshots();try{await editor?.destroy();}finally{renderer?.dispose();}}
const pagehide=()=>{void cleanup()};
async function cleanup() {
if (cleaned) return;
cleaned = true;
removeEventListener("pagehide", pagehide);
assetAbort?.abort();
onAbort();
listeners.splice(0).forEach((fn) => fn());
unsubscribeController();
unsubscribeSnapshots();
try {
await controller?.destroy();
await editor?.destroy();
} finally {
renderer?.dispose();
}
}
const pagehide = () => {
void cleanup();
};
async function start(){
addEventListener("pagehide",pagehide,{once:true});delete document.documentElement.dataset.phase8Ready;await wbg_init();if(cleaned)return;
renderer=new RendererClient(main());await renderer.ready;const nextEditor=await createRenderGraphEditor(document.querySelector("#graph-editor"));if(cleaned){await nextEditor.destroy();return}editor=nextEditor;
controller=new AuthoringController({renderer,getState:editor.getState});const apply=document.querySelector("#apply-graph"),graphStatus=document.querySelector("#graph-status"),loadoutSelect=document.querySelector("#loadout-select"),graphSelect=document.querySelector("#graph-select");
unsubscribeController=controller.subscribe(s=>{apply.disabled=busy||s.applying||!s.dirty;graphStatus.textContent=s.applying?"Applying…":s.dirty?"Unapplied changes":`Authored revision ${s.revision}`;});unsubscribeSnapshots=editor.onSnapshots(()=>controller.markDirty());
const authored=await controller.apply(adaptFxNodeSnapshot);state.compiled.authored={...authored,graphId:"demo_forward"};
for(const [name,preset] of Object.entries(renderGraphPresets)){const compiled=await renderer.compileGraph(preset);state.compiled[name]={...compiled,graphId:preset.graphId,revision:preset.revision};}
on(window,"renderer-frame",event=>{const expected=state.compiled[state.graph],telemetry=event.detail;if(expected&&telemetry.activeCompiledGraph===expected.graphId&&sameId(telemetry.activeCompiledId,expected.compiledId)&&telemetry.gpuError===false){publish(telemetry);if(!busy)status(`${state.loadout} · ${state.graph} · ${telemetry.draws} draws · ${telemetry.instances} instances`);}});
on(loadoutSelect,"change",()=>void selectLoadout(loadoutSelect.value,loadoutSelect));on(graphSelect,"change",()=>void selectGraph(graphSelect.value,graphSelect));
on(apply,"click",()=>{const previous=state.graph,previousAuthored=state.compiled.authored;void transaction("Applying authored graph…",async()=>{const compiled=await controller.apply(adaptFxNodeSnapshot);state.compiled.authored={...compiled,graphId:"demo_forward"};state.graph="authored";graphSelect.value="authored";return waitTelemetry(x=>sameId(x.activeCompiledId,compiled.compiledId)&&x.activeCompiledGraph==="demo_forward"&&x.activeCompiledRevision===compiled.revision&&x.gpuError===false).catch(()=>null);},async()=>{state.compiled.authored=previousAuthored;state.graph=previous;graphSelect.value=previous;controller.markDirty();});});
async function start() {
addEventListener("pagehide", pagehide, { once: true });
delete document.documentElement.dataset.phase8Ready;
await wbg_init();
if (cleaned) return;
renderer = new RendererClient(main(profileEnabled));
installProfileMenu();
await renderer.ready;
const nextEditor = await createRenderGraphEditor(
document.querySelector("#graph-editor"),
);
if (cleaned) {
await nextEditor.destroy();
return;
}
editor = nextEditor;
controller = new AuthoringController({
renderer,
adapt: adaptFxNodeSnapshot,
});
const apply = document.querySelector("#apply-graph"),
graphStatus = document.querySelector("#graph-status"),
loadoutSelect = document.querySelector("#loadout-select"),
graphSelect = document.querySelector("#graph-select");
unsubscribeController = controller.subscribe((s) => {
apply.disabled = busy || !s.canApply;
graphStatus.textContent = s.error
? `Invalid · ${s.error.code ?? s.error.message}`
: s.applying
? "Applying…"
: s.dirty
? s.staged
? "Ready to apply"
: "Validating…"
: `Authored revision ${s.revision}`;
});
unsubscribeSnapshots = editor.onSnapshots((snapshot) =>
controller.markDirty(snapshot),
);
controller.markDirty(await editor.getState());
const authored = await controller.apply();
state.compiled.authored = { ...authored, graphId: "authored_gpu_culling" };
for (const [name, preset] of Object.entries(renderGraphPresets)) {
const compiled = await renderer.compileGraph(preset);
state.compiled[name] = {
...compiled,
graphId: preset.graphId,
revision: preset.revision,
};
}
on(window, "renderer-frame", (event) => {
const expected = state.compiled[state.graph],
telemetry = event.detail;
if (
expected &&
telemetry.activeCompiledGraph === expected.graphId &&
sameId(telemetry.activeCompiledId, expected.compiledId) &&
telemetry.gpuError === false
) {
publish(telemetry);
if (!busy)
status(
`${state.loadout} · ${state.graph} · ${telemetry.draws} draws · ${telemetry.instances} instances`,
);
}
});
on(
loadoutSelect,
"change",
() => void selectLoadout(loadoutSelect.value, loadoutSelect),
);
on(
graphSelect,
"change",
() => void selectGraph(graphSelect.value, graphSelect),
);
on(apply, "click", () => {
const previous = state.graph,
previousAuthored = state.compiled.authored;
void transaction(
"Applying authored graph…",
async () => {
const compiled = await controller.apply();
state.compiled.authored = {
...compiled,
graphId: "authored_gpu_culling",
};
state.graph = "authored";
graphSelect.value = "authored";
return waitTelemetry(
(x) =>
sameId(x.activeCompiledId, compiled.compiledId) &&
x.activeCompiledGraph === "authored_gpu_culling" &&
x.activeCompiledRevision === compiled.revision &&
x.gpuError === false,
).catch(() => null);
},
async () => {
state.compiled.authored = previousAuthored;
state.graph = previous;
graphSelect.value = previous;
},
);
});
await editor.whenRendered();
const initialized=await transaction("Preparing procedural cubes…",async()=>{const targetRevision=(renderer.telemetry?.revision??0)+1;await renderer.replaceSceneGlb(await loadDemoLoadout("cubes"));await renderer.switchCompiledGraph(authored.compiledId);return waitTelemetry(x=>x.revision===targetRevision&&x.draws>0&&x.activeCompiledGraph==="demo_forward"&&x.activeCompiledRevision===authored.revision&&x.gpuError===false);});
if(!initialized)throw new Error("Initial demo transaction failed");document.documentElement.dataset.phase8Ready="true";
const initialized = await transaction(
"Preparing procedural cubes…",
async () => {
const targetRevision = (renderer.telemetry?.revision ?? 0) + 1;
await renderer.replaceSceneGlb(await loadDemoLoadout("cubes"));
await renderer.switchCompiledGraph(authored.compiledId);
return waitTelemetry(
(x) =>
x.revision === targetRevision &&
x.draws > 0 &&
x.activeCompiledGraph === "authored_gpu_culling" &&
x.activeCompiledRevision === authored.revision &&
x.gpuError === false,
);
},
);
if (!initialized) throw new Error("Initial demo transaction failed");
document.documentElement.dataset.phase8Ready = "true";
}
const startupError=error=>{if(cleaned)return;console.error("Phase 8 startup failed",error);status(`Startup failed · ${error?.code??error}`);void cleanup();};
if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",()=>start().catch(startupError),{once:true});else start().catch(startupError);
const startupError = (error) => {
if (cleaned) return;
console.error("Phase 8 startup failed", error);
status(`Startup failed · ${error?.code ?? error}`);
void cleanup();
};
if (document.readyState === "loading")
document.addEventListener(
"DOMContentLoaded",
() => start().catch(startupError),
{ once: true },
);
else start().catch(startupError);
+414 -69
View File
@@ -1,75 +1,420 @@
import { CATALOG_VERSION, descriptors, GRAPH_ID } from "./catalog.js";
import {
CATALOG_VERSION,
descriptors,
GRAPH_ID,
nodeDefinitions,
socketTypes,
} 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);
constructor(code, details = {}) {
super(code);
this.name = "AuthoringGraphError";
this.code = code;
this.details = Object.freeze({ ...details });
}
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}}]};
const fail = (code, details) => {
throw new AuthoringGraphError(code, details);
};
const object = (value) =>
value !== null && typeof value === "object" && !Array.isArray(value);
const identifier = (value) =>
typeof value === "string" &&
/^[A-Za-z][A-Za-z0-9_.-]*$/.test(value) &&
new TextEncoder().encode(value).length <= 64;
const exactKeys = (value, keys) =>
object(value) &&
Object.keys(value).length === keys.length &&
keys.every((key) => Object.hasOwn(value, key));
const finiteJson = (value) =>
value === null ||
typeof value === "string" ||
typeof value === "boolean" ||
(typeof value === "number" && Number.isFinite(value)) ||
(Array.isArray(value) && value.every(finiteJson)) ||
(object(value) && Object.values(value).every(finiteJson));
const canonical = (value) =>
Array.isArray(value)
? value.map(canonical)
: object(value)
? Object.fromEntries(
Object.keys(value)
.sort()
.map((key) => [key, canonical(value[key])]),
)
: value;
const deepFreeze = (value) => {
if (value && typeof value === "object" && !Object.isFrozen(value)) {
Object.freeze(value);
for (const child of Object.values(value)) deepFreeze(child);
}
return value;
};
const sourceMaps = new WeakMap();
export const getSourceMap = (ir) => sourceMaps.get(ir);
export const mapAuthoringDiagnostic = (ir, diagnostic) => {
const details = diagnostic?.details;
const path = [details?.path, diagnostic?.path, details?.field, diagnostic?.field]
.find((value) => typeof value === "string");
const map = getSourceMap(ir);
let match;
if (path && map)
for (const key of Object.keys(map))
if (
(path === key || path.startsWith(`${key}.`) || path.startsWith(`${key}[`)) &&
(!match || key.length > match.length)
)
match = key;
return deepFreeze({
name: diagnostic?.name,
code: diagnostic?.code,
message: details?.message ?? diagnostic?.message ?? diagnostic?.code,
details: details === undefined ? undefined : structuredClone(details),
path,
source: match ? structuredClone(map[match]) : undefined,
});
};
const mapValuePaths = (paths, path, source, value) => {
paths[path] = source;
if (Array.isArray(value))
value.forEach((child, index) => mapValuePaths(paths, `${path}[${index}]`, source, child));
else if (object(value))
for (const key of Object.keys(value))
mapValuePaths(paths, `${path}.${key}`, source, value[key]);
};
function parameterValue(raw, schema, nodeId, key) {
const expected = schema.type === "json" ? "json" : schema.type;
if (
!exactKeys(raw, ["kind", "value"]) ||
raw.kind !== expected ||
!finiteJson(raw.value)
)
fail("AUTHORING_PARAMETER", { nodeId, parameter: key });
if (
(expected === "number" && typeof raw.value !== "number") ||
(expected === "string" && typeof raw.value !== "string") ||
(expected === "boolean" && typeof raw.value !== "boolean") ||
(expected === "json" && !finiteJson(raw.value))
)
fail("AUTHORING_PARAMETER", { nodeId, parameter: key });
return canonical(structuredClone(raw.value));
}
export function adaptFxNodeSnapshot(raw, revision = 1) {
try {
const rootKeys = ["graphId", "catalogVersion", "nodes", "links", "metadata", "version"];
if (
!exactKeys(raw, rootKeys) ||
!Array.isArray(raw.nodes) ||
!Array.isArray(raw.links) ||
!object(raw.metadata) ||
!finiteJson(raw.metadata)
)
fail("AUTHORING_SHAPE");
if (raw.graphId !== GRAPH_ID || raw.catalogVersion !== CATALOG_VERSION)
fail("AUTHORING_CATALOG");
if (
!Number.isSafeInteger(raw.version) || raw.version < 0
)
fail("AUTHORING_SHAPE");
if (!Number.isInteger(revision) || revision < 1 || revision > 0xffffffff)
fail("AUTHORING_REVISION");
const nodes = new Map(),
sockets = new Map(),
paths = {};
for (let ordinal = 0; ordinal < raw.nodes.length; ordinal++) {
const n = raw.nodes[ordinal];
if (!object(n) || !identifier(n.id)) fail("AUTHORING_ID", { id: n?.id });
if (nodes.has(n.id)) fail("AUTHORING_ID_DUPLICATE", { id: n.id });
const descriptor = descriptors[n.typeId],
definition = nodeDefinitions[n.typeId];
if (!descriptor)
fail("AUTHORING_NODE_TYPE", { nodeId: n.id, typeId: n.typeId });
const nodeKeys = ["id", "typeId", "typeVersion", "position", "size", "label", "parameters", "sockets", "muted", "collapsed", "extensions", "known"];
if (Object.hasOwn(n, "parentId")) nodeKeys.push("parentId");
if (
!exactKeys(n, nodeKeys) ||
n.known !== true ||
n.typeVersion !== 1 ||
typeof n.muted !== "boolean" ||
typeof n.collapsed !== "boolean" ||
typeof n.label !== "string" ||
!exactKeys(n.position, ["x", "y"]) || !Number.isFinite(n.position.x) || !Number.isFinite(n.position.y) ||
!exactKeys(n.size, ["x", "y"]) || !Number.isFinite(n.size.x) || !Number.isFinite(n.size.y) || n.size.x <= 0 || n.size.y <= 0 ||
(Object.hasOwn(n, "parentId") && !identifier(n.parentId)) ||
!object(n.extensions) || !finiteJson(n.extensions) ||
!Array.isArray(n.sockets) ||
!object(n.parameters)
)
fail("AUTHORING_NODE_INVALID", { nodeId: n.id });
const parameterKeys = Object.keys(definition.parameters);
if (
Object.keys(n.parameters).length !== parameterKeys.length ||
!parameterKeys.every((key) => Object.hasOwn(n.parameters, key))
)
fail("AUTHORING_PARAMETER_SET", { nodeId: n.id });
const parameters = Object.fromEntries(
parameterKeys.map((key) => [
key,
parameterValue(
n.parameters[key],
definition.parameters[key],
n.id,
key,
),
]),
);
const expected = [
...Object.keys(descriptor.inputs),
...Object.keys(descriptor.outputs),
];
if (n.sockets.length !== expected.length)
fail("AUTHORING_SOCKET_SET", { nodeId: n.id });
for (const s of n.sockets) {
if (!object(s) || !expected.includes(s.key) || sockets.has(s.id))
fail("AUTHORING_SOCKET", { nodeId: n.id, socket: s?.key });
const input = descriptor.inputs[s.key],
socketDefinition = definition.sockets[s.key],
direction = input ? "input" : "output",
dataType = socketDefinition.type,
socketKeys = ["id", "key", "label", "direction", "dataType", "accepts", "maxIncomingLinks", ...(socketDefinition.value ? ["defaultValue"] : []), "visible"];
if (
!exactKeys(s, socketKeys) ||
s.id !== `${n.id}:${s.key}` ||
s.label !== socketDefinition.title ||
s.direction !== direction ||
s.dataType !== dataType ||
!Array.isArray(s.accepts) || s.accepts.length !== (direction === "input" ? socketTypes[dataType].acceptsFrom.length : 0) ||
!s.accepts.every((v, i) => v === (direction === "input" ? socketTypes[dataType].acceptsFrom[i] : undefined)) ||
(socketDefinition.value
? !exactKeys(s.defaultValue, ["kind", "value"]) || !finiteJson(s.defaultValue.value)
: s.defaultValue !== undefined) ||
s.visible !== socketDefinition.visible ||
s.maxIncomingLinks !== socketDefinition.maxIncomingLinks
)
fail("AUTHORING_SOCKET", { nodeId: n.id, socket: s.key });
sockets.set(s.id, {
node: n.id,
key: s.key,
direction,
semanticType: input
? input.accepted.types[0]
: descriptor.outputs[s.key].type,
authoringType: s.dataType,
maxIncomingLinks: s.maxIncomingLinks,
});
}
if (new Set(n.sockets.map((s) => s.key)).size !== expected.length)
fail("AUTHORING_SOCKET_SET", { nodeId: n.id });
nodes.set(n.id, {
ordinal,
value: {
id: n.id,
state: n.muted ? "muted" : "enabled",
executor: { key: n.typeId, version: 1 },
parameters,
inputs: {},
},
});
}
const incoming = new Map(),
linkIds = new Set(),
linkSources = new Map();
for (let ordinal = 0; ordinal < raw.links.length; ordinal++) {
const link = raw.links[ordinal];
if (
!object(link) ||
!identifier(link.id) ||
linkIds.has(link.id) ||
!exactKeys(link, ["id", "fromNodeId", "fromSocketId", "toNodeId", "toSocketId", "muted", "extensions"]) ||
typeof link.muted !== "boolean" || !object(link.extensions) || !finiteJson(link.extensions)
)
fail("AUTHORING_LINK", { linkId: link?.id });
linkIds.add(link.id);
const from = sockets.get(link.fromSocketId),
to = sockets.get(link.toSocketId);
if (
!from ||
!to ||
link.fromNodeId !== from.node ||
link.toNodeId !== to.node ||
from.direction !== "output" ||
to.direction !== "input" ||
(!link.muted && (incoming.get(link.toSocketId) ?? 0) >= to.maxIncomingLinks)
)
fail(
!link.muted && (incoming.get(link.toSocketId) ?? 0) >= (to?.maxIncomingLinks ?? Infinity)
? "AUTHORING_LINK_INCOMING"
: "AUTHORING_LINK",
!link.muted && (incoming.get(link.toSocketId) ?? 0) >= (to?.maxIncomingLinks ?? Infinity)
? { socketId: link.toSocketId }
: { linkId: link.id },
);
const accepted =
descriptors[nodes.get(to.node).value.executor.key].inputs[to.key]
.accepted.types;
const authoringAccepted = socketTypes[nodeDefinitions[nodes.get(to.node).value.executor.key].sockets[to.key].type].acceptsFrom;
if (!accepted.includes(from.semanticType) || !authoringAccepted.includes(from.authoringType))
fail("AUTHORING_LINK_TYPE", { linkId: link.id });
const linkSource = {
kind: "link",
linkId: link.id,
fromNodeId: link.fromNodeId,
fromSocketId: link.fromSocketId,
toNodeId: link.toNodeId,
toSocketId: link.toSocketId,
muted: link.muted,
nodeId: to.node,
input: to.key,
fromSocket: from.key,
toSocket: to.key,
};
linkSources.set(link.id, linkSource);
if (!link.muted) {
incoming.set(link.toSocketId, (incoming.get(link.toSocketId) ?? 0) + 1);
nodes.get(to.node).value.inputs[to.key] = {
node: from.node,
socket: from.key,
};
}
}
const ordered = [...nodes.values()].sort((a, b) =>
a.value.id < b.value.id
? -1
: a.value.id > b.value.id
? 1
: a.ordinal - b.ordinal,
);
for (let wireOrdinal = 0; wireOrdinal < ordered.length; wireOrdinal++) {
const item = ordered[wireOrdinal];
item.value.inputs = Object.fromEntries(
Object.keys(descriptors[item.value.executor.key].inputs)
.filter((key) => Object.hasOwn(item.value.inputs, key))
.map((key) => [key, item.value.inputs[key]]),
);
const base = `nodes[${wireOrdinal}]`;
const nodeSource = { kind: "node", nodeId: item.value.id };
paths[base] = nodeSource;
for (const field of ["id", "state", "executor", "executor.key", "executor.version"])
paths[`${base}.${field}`] = nodeSource;
paths[`${base}.parameters`] = nodeSource;
for (const key of Object.keys(item.value.parameters))
mapValuePaths(paths, `${base}.parameters.${key}`, { kind: "parameter", nodeId: item.value.id, parameter: key }, item.value.parameters[key]);
for (const key of Object.keys(descriptors[item.value.executor.key].inputs)) {
const link = raw.links.find((x) => !x.muted && x.toNodeId === item.value.id && sockets.get(x.toSocketId)?.key === key);
const source = linkSources.get(link?.id) ?? {
kind: "input",
nodeId: item.value.id,
input: key,
socketId: `${item.value.id}:${key}`,
unconnected: true,
};
paths[`${base}.inputs.${key}`] = source;
if (link) {
paths[`${base}.inputs.${key}.node`] = source;
paths[`${base}.inputs.${key}.socket`] = {
kind: "socket",
nodeId: link.fromNodeId,
socketId: link.fromSocketId,
socket: sockets.get(link.fromSocketId).key,
linkId: link.id,
};
}
}
paths[`${base}.inputs`] = nodeSource;
}
const ir = {
schemaVersion: 2,
graphId: GRAPH_ID,
revision,
nodes: ordered.map((item) => item.value),
};
const graphSource = { kind: "graph", graphId: GRAPH_ID };
for (const field of ["schemaVersion", "graphId", "revision", "nodes"])
paths[field] = graphSource;
deepFreeze(paths);
deepFreeze(ir);
sourceMaps.set(ir, paths);
return ir;
} catch (error) {
if (error instanceof AuthoringGraphError) throw error;
fail("AUTHORING_SHAPE");
}
}
export const adaptGraphSnapshot = adaptFxNodeSnapshot;
/** Compatibility helper retained for V1 presets. */
export function semanticProjectionToV1(p, revision = 1) {
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;
+130
View File
@@ -0,0 +1,130 @@
import { semanticCatalog } from "./catalog.js";
const GROUPS = Object.freeze([
["source", "Source"],
["compute", "Compute"],
["render", "Render / post"],
["present", "Present"],
]);
const title = (typeId) => typeId.replaceAll("_", " ");
/** 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: 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();
},
};
}
+114 -11
View File
@@ -1,14 +1,117 @@
import { mapAuthoringDiagnostic } from "./adapter.js";
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;
#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 this.#renderer.compileGraph(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;
}
}
+13 -10
View File
@@ -10,10 +10,12 @@ const sizeCanvas = (canvas, value) => {
};
const mods = e => ({ alt:e.altKey, control:e.ctrlKey, meta:e.metaKey, shift:e.shiftKey });
export function prepareBrowserHost(canvas, { onError=console.error, chooseNodeType }={}) {
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, dead=false, generation=0, resizing=false, pending, appliedViewport, menuPending=false, unsubscribeHost=()=>{};
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";
@@ -22,14 +24,14 @@ export function prepareBrowserHost(canvas, { onError=console.error, chooseNodeTy
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==="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(); menuPending=false;
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){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 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"];
@@ -40,22 +42,23 @@ export function prepareBrowserHost(canvas, { onError=console.error, chooseNodeTy
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 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){
view=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)return;menuPending=false;const typeId=chooseNodeType?.(request);if(typeId)view.addNode({typeId,viewPosition:request.viewPosition}).catch(onError)});
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;observer.disconnect();unsubscribeHost();ownerWindow.removeEventListener("resize",resize);ownerDocument.removeEventListener("pointerdown",outside,true);
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;
if(originalTabIndex===null)canvas.removeAttribute("tabindex");else canvas.setAttribute("tabindex",originalTabIndex);canvas.style.touchAction=originalTouchAction;view=null;root=null;
}};
}
function prevent(e){e.preventDefault()}
+346 -28
View File
@@ -1,29 +1,347 @@
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:[]},
export const GRAPH_ID = "authored_gpu_culling";
export const CATALOG_VERSION = 2;
const exact = (type) => ({ kind: "exact", types: [type] });
const oneOf = (...types) => ({ kind: "one_of", types });
const i = (type, required = true, authoringType) => ({
accepted: typeof type === "string" ? exact(type) : type,
required,
...(authoringType ? { authoringType } : {}),
});
const o = (type) => ({ type });
const texture = {
residency: "transient",
texture: {
dimension: "d2",
format: "rgba16_float",
extent: {
kind: "surface_relative",
width: { numerator: 1, denominator: 1 },
height: { numerator: 1, denominator: 1 },
depthOrArrayLayers: 1,
},
mipLevelCount: 1,
sampleCount: 1,
viewFormats: [],
},
};
export const semanticCatalog = Object.freeze({
surface_target: {
execution: "source",
inputs: {},
outputs: { surface: o("surface_target") },
parameters: {},
},
texture_spec: {
execution: "source",
inputs: {},
outputs: { spec: o("texture_spec") },
parameters: structuredClone(texture),
},
scene_table: {
execution: "source",
inputs: {},
outputs: { scene: o("scene_table") },
parameters: {},
},
local_aabb_buffer: {
execution: "source",
inputs: { scene: i("scene_table") },
outputs: { localAabbs: o("local_aabb_buffer") },
parameters: {},
},
camera_frustum: {
execution: "source",
inputs: {},
outputs: { frustum: o("camera_frustum") },
parameters: {},
},
visibility_flags: {
execution: "source",
inputs: { scene: i("scene_table") },
outputs: {
flags: {
...o("boolean_flag_buffer"),
authoringType: "visibility_flag_buffer",
},
},
parameters: {},
},
frustum_cull: {
execution: "compute",
inputs: {
scene: i("scene_table"),
localAabbs: i("local_aabb_buffer"),
frustum: i("camera_frustum"),
},
outputs: {
flags: {
...o("boolean_flag_buffer"),
authoringType: "frustum_flag_buffer",
},
},
parameters: {},
},
mesh_query: {
execution: "compute",
inputs: {
scene: i("scene_table"),
isVisible: i("boolean_flag_buffer", false, "visibility_flag_buffer"),
isFrustumCulled: i("boolean_flag_buffer", false, "frustum_flag_buffer"),
},
outputs: { draws: o("draw_stream") },
parameters: {
filters: [
{ flag: "isVisible", predicate: "required_true" },
{ flag: "isFrustumCulled", predicate: "required_false" },
],
},
},
depth_stencil_config: {
execution: "source",
inputs: {},
outputs: { config: o("depth_stencil_config") },
parameters: {
depthCompare: "less_equal",
depthWriteEnabled: true,
clearDepth: 1,
},
},
legacy_forward: {
execution: "render",
inputs: {
scene: i("scene_table"),
draws: i("draw_stream"),
colorTarget: i(oneOf("surface_target", "texture_spec", "texture")),
depthTarget: i(oneOf("texture_spec", "texture")),
depthStencil: i("depth_stencil_config"),
},
outputs: { color: o("texture"), depth: o("texture") },
parameters: { clearColor: [0.015, 0.02, 0.03, 1] },
},
fullscreen_copy: {
execution: "render",
inputs: {
source: i("texture"),
colorTarget: i(oneOf("surface_target", "texture_spec", "texture")),
},
outputs: { color: o("texture") },
parameters: {},
},
tone_map: {
execution: "render",
inputs: {
source: i("texture"),
colorTarget: i(oneOf("surface_target", "texture_spec", "texture")),
},
outputs: { color: o("texture") },
parameters: { exposure: 1 },
},
bloom_extract: {
execution: "render",
inputs: {
source: i("texture"),
colorTarget: i(oneOf("surface_target", "texture_spec", "texture")),
},
outputs: { color: o("texture") },
parameters: { threshold: 1, knee: 0.5 },
},
bloom_blur: {
execution: "render",
inputs: {
source: i("texture"),
colorTarget: i(oneOf("surface_target", "texture_spec", "texture")),
},
outputs: { color: o("texture") },
parameters: { direction: [1, 0], radius: 1 },
},
bloom_composite: {
execution: "render",
inputs: {
source: i("texture"),
bloom: i("texture"),
colorTarget: i(oneOf("surface_target", "texture_spec", "texture")),
},
outputs: { color: o("texture") },
parameters: { intensity: 1 },
},
luminance_edge: {
execution: "render",
inputs: {
source: i("texture"),
colorTarget: i(oneOf("surface_target", "texture_spec", "texture")),
},
outputs: { color: o("texture") },
parameters: { strength: 2 },
},
present: {
execution: "present",
inputs: { surface: i("texture") },
outputs: {},
parameters: {},
},
});
const socketColors = [
"#d17c7c",
"#d19e7c",
"#d1c77c",
"#9ed17c",
"#7cd1a5",
"#7ccbd1",
"#7c98d1",
"#a27cd1",
"#d17cb8",
];
export const socketTypes = Object.fromEntries(
[
"surface_target",
"texture_spec",
"texture",
"scene_table",
"local_aabb_buffer",
"camera_frustum",
"boolean_flag_buffer",
"draw_stream",
"depth_stencil_config",
"visibility_flag_buffer",
"frustum_flag_buffer",
].map((type, index) => [
type,
{
title: type.replaceAll("_", " "),
color: socketColors[index % socketColors.length],
acceptsFrom: [type],
},
]),
);
socketTypes.surface_target.acceptsFrom = [
"surface_target",
"texture_spec",
"texture",
];
socketTypes.texture_spec.acceptsFrom = ["texture_spec", "texture"];
socketTypes.boolean_flag_buffer.acceptsFrom = [
"boolean_flag_buffer",
"visibility_flag_buffer",
"frustum_flag_buffer",
];
export const theme = {
background: "#151820",
grid: "#292e3a",
frame: "#30343a80",
frameHeader: "#59616c",
body: "#292e39",
control: "#24272b",
controlFill: "#4775b8",
controlEditing: "#181a1d",
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 = {
source: { header: "#3977a8" },
compute: { header: "#725a9b" },
render: { header: "#426b43" },
present: { header: "#a75d37" },
};
const socket = (title, direction, type) => ({
title,
direction,
type,
maxIncomingLinks: direction === "input" ? 1 : 0,
visible: true,
value: null,
showValue: false,
});
const parameterSchema = (value) =>
typeof value === "number"
? { type: "number", default: { kind: "number", value } }
: typeof value === "string"
? { type: "string", default: { kind: "string", value } }
: typeof value === "boolean"
? { type: "boolean", default: { kind: "boolean", value } }
: { type: "json", default: { kind: "json", value } };
export const nodeDefinitions = Object.fromEntries(
Object.entries(semanticCatalog).map(([key, c]) => {
const sockets = {
...Object.fromEntries(
Object.entries(c.inputs).map(([n, v]) => [
n,
socket(n, "input", v.authoringType ?? v.accepted.types[0]),
]),
),
...Object.fromEntries(
Object.entries(c.outputs).map(([n, v]) => [
n,
socket(n, "output", v.authoringType ?? v.type),
]),
),
},
parameters = Object.fromEntries(
Object.entries(c.parameters).map(([name, value]) => [
name,
parameterSchema(value),
]),
);
return [
key,
{
version: 1,
title: key.replaceAll("_", " "),
behavior: "standard",
style: c.execution,
parameters,
sockets,
ui: [
...Object.keys(parameters).map((parameter) => ({
kind: "parameter",
parameter,
})),
...Object.keys(sockets).map((socket) => ({ kind: "socket", socket })),
],
muteBypass: [],
migrations: [],
},
];
}),
);
export const fxNodeComposition = Object.freeze({
schemaVersion: 2,
id: "yawn.render-graph",
version: CATALOG_VERSION,
compatibility: { wildcardInputTypes: [] },
socketTypes,
nodeStyles: styles,
resources: {},
theme,
nodes: nodeDefinitions,
});
export const descriptors = Object.fromEntries(
Object.entries(semanticCatalog).map(([key, c]) => [
key,
{
version: 1,
inputs: c.inputs,
outputs: c.outputs,
parameters: c.parameters,
},
]),
);
+149 -19
View File
@@ -1,25 +1,155 @@
import { createFxNode } from "@fxnode/index.ts";
import { CATALOG_VERSION, GRAPH_ID, nodeDefinitions, socketTypes, styles, theme } from "./catalog.js";
import { CATALOG_VERSION, GRAPH_ID, fxNodeComposition } from "./catalog.js";
import { prepareBrowserHost } from "./browser-host.js";
import { createAddNodeMenu } from "./add-node-menu.js";
import { createNodeIdAllocator, spawnRequestedNode } from "./node-spawn.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}],
const spec = [
["surface", "surface_target", { x: 40, y: 40 }],
["hdr", "texture_spec", { x: 40, y: 170 }],
["depth", "texture_spec", { x: 40, y: 300 }],
["scene", "scene_table", { x: 40, y: 470 }],
["aabbs", "local_aabb_buffer", { x: 290, y: 430 }],
["frustum", "camera_frustum", { x: 290, y: 590 }],
["visible", "visibility_flags", { x: 290, y: 300 }],
["cull", "frustum_cull", { x: 540, y: 480 }],
["query", "mesh_query", { x: 790, y: 330 }],
["depth_config", "depth_stencil_config", { x: 790, y: 620 }],
["forward", "legacy_forward", { x: 1040, y: 290 }],
["copy", "fullscreen_copy", { x: 1300, y: 250 }],
["present", "present", { x: 1540, y: 250 }],
];
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});
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 });
const links = [
["scene", "scene", "aabbs", "scene"],
["scene", "scene", "visible", "scene"],
["scene", "scene", "cull", "scene"],
["aabbs", "localAabbs", "cull", "localAabbs"],
["frustum", "frustum", "cull", "frustum"],
["scene", "scene", "query", "scene"],
["visible", "flags", "query", "isVisible"],
["cull", "flags", "query", "isFrustumCulled"],
["scene", "scene", "forward", "scene"],
["query", "draws", "forward", "draws"],
["hdr", "spec", "forward", "colorTarget"],
["depth", "spec", "forward", "depthTarget"],
["depth_config", "config", "forward", "depthStencil"],
["forward", "color", "copy", "source"],
["surface", "surface", "copy", "colorTarget"],
["copy", "color", "present", "surface"],
];
for (const [a, as, b, bs] of links) {
const id = `${a}_${as}_${b}_${bs}`;
await root.dispatch({
type: "link.add",
link: {
id,
fromNodeId: a,
fromSocketId: `${a}:${as}`,
toNodeId: b,
toSocketId: `${b}:${bs}`,
muted: false,
extensions: {},
},
});
}
const authored = await root.getState(),
depth = authored.nodes.find((node) => node.id === "depth");
depth.parameters.texture = {
kind: "json",
value: {
dimension: "d2",
format: "depth32_float",
extent: {
kind: "surface_relative",
width: { numerator: 1, denominator: 1 },
height: { numerator: 1, denominator: 1 },
depthOrArrayLayers: 1,
},
mipLevelCount: 1,
sampleCount: 1,
viewFormats: [],
},
};
await root.setState(authored);
}
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}
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();
return createEditorFacade(root, view, { requestAddNode, destroy });
} catch (e) {
await destroy().catch(() => {});
throw e;
}
}
/** Creates the small application-facing wrapper around an fxnode root and view. */
export function createEditorFacade(
root,
view,
{ requestAddNode = () => null, destroy = async () => {} } = {},
) {
return {
getState: () => root.getState(),
getSaveData: () => root.getSaveData(),
load: async (data) => {
await root.load(data);
await view.whenRendered();
},
loadComposition: async (data) => {
await root.loadComposition(data);
await view.whenRendered();
},
onSnapshots: (fn) =>
root.onSnapshots((event) => fn(event.snapshot, event.version)),
requestAddNode,
whenRendered: () => view.whenRendered(),
destroy,
};
}
+42
View File
@@ -0,0 +1,42 @@
/** Allocates a bounded fxnode-safe 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;
}
+255 -4
View File
@@ -1,5 +1,256 @@
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});
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]);
const input = (node, socket) => ({ node, socket });
const node = (id, key, parameters = {}, inputs = {}) => ({
id,
state: "enabled",
executor: { key, version: 1 },
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",
});
export const hdr = Object.freeze({
schemaVersion: 2,
graphId: "preset_hdr_fullscreen",
revision: 1,
nodes: [
node("surface", "surface_target"),
node("hdr", "texture_spec", texture("rgba16_float")),
node("depth", "texture_spec", texture("depth32_float")),
node("scene", "scene_table"),
node("visible", "visibility_flags", {}, { scene: input("scene", "scene") }),
node(
"query",
"mesh_query",
{
filters: [
{ flag: "isVisible", predicate: "required_true" },
{ flag: "isFrustumCulled", predicate: "any" },
],
},
{ scene: input("scene", "scene"), isVisible: input("visible", "flags") },
),
node("depth_config", "depth_stencil_config", {
depthCompare: "less_equal",
depthWriteEnabled: true,
clearDepth: 1,
}),
node(
"forward",
"legacy_forward",
{ clearColor: [0.015, 0.02, 0.03, 1] },
{
scene: input("scene", "scene"),
draws: input("query", "draws"),
colorTarget: input("hdr", "spec"),
depthTarget: input("depth", "spec"),
depthStencil: input("depth_config", "config"),
},
),
node(
"copy",
"fullscreen_copy",
{},
{
source: input("forward", "color"),
colorTarget: input("surface", "surface"),
},
),
node("present", "present", {}, { surface: input("copy", "color") }),
],
});
export const culling = Object.freeze((() => {
const graph = structuredClone(hdr);
graph.graphId = "preset_gpu_culling";
graph.nodes.splice(
5,
0,
node("aabbs", "local_aabb_buffer", {}, { scene: input("scene", "scene") }),
node("frustum", "camera_frustum"),
node("cull", "frustum_cull", {}, {
scene: input("scene", "scene"),
localAabbs: input("aabbs", "localAabbs"),
frustum: input("frustum", "frustum"),
}),
);
const query = graph.nodes.find((x) => x.id === "query");
query.parameters.filters[1].predicate = "required_false";
query.inputs.isFrustumCulled = input("cull", "flags");
return graph;
})());
const postPreset = (graphId, kind) => {
const nodes = hdr.nodes.slice(0, 8).map((x) => structuredClone(x));
if (kind === "tone")
nodes.push(
node(
"tone",
"tone_map",
{ exposure: 1 },
{
source: input("forward", "color"),
colorTarget: input("surface", "surface"),
},
),
);
if (kind === "edges") {
nodes.splice(1, 0, node("edge_hdr", "texture_spec", texture("rgba16_float")));
nodes.push(
node(
"edges",
"luminance_edge",
{ strength: 2 },
{
source: input("forward", "color"),
colorTarget: input("edge_hdr", "spec"),
},
),
);
nodes.push(
node(
"tone",
"tone_map",
{ exposure: 1 },
{
source: input("edges", "color"),
colorTarget: input("surface", "surface"),
},
),
);
}
if (kind === "bloom" || kind === "combined") {
const half = {
texture: {
...texture("rgba16_float").texture,
extent: {
kind: "surface_relative",
width: { numerator: 1, denominator: 2 },
height: { numerator: 1, denominator: 2 },
depthOrArrayLayers: 1,
},
},
residency: "transient",
};
nodes.splice(
1,
0,
node("half_a", "texture_spec", structuredClone(half)),
node("half_b", "texture_spec", structuredClone(half)),
node("half_c", "texture_spec", structuredClone(half)),
node("composite_hdr", "texture_spec", texture("rgba16_float")),
);
nodes.push(
node(
"extract",
"bloom_extract",
{ threshold: 1, knee: 0.5 },
{
source: input("forward", "color"),
colorTarget: input("half_a", "spec"),
},
),
);
nodes.push(
node(
"blur_h",
"bloom_blur",
{ direction: [1, 0], radius: 1 },
{
source: input("extract", "color"),
colorTarget: input("half_b", "spec"),
},
),
);
nodes.push(
node(
"blur_v",
"bloom_blur",
{ direction: [0, 1], radius: 1 },
{
source: input("blur_h", "color"),
colorTarget: input("half_c", "spec"),
},
),
);
nodes.push(
node(
"composite",
"bloom_composite",
{ intensity: 0.8 },
{
source: input("forward", "color"),
bloom: input("blur_v", "color"),
colorTarget: input("composite_hdr", "spec"),
},
),
);
let toneSource = "composite";
if (kind === "combined") {
nodes.splice(1, 0, node("edge_hdr", "texture_spec", texture("rgba16_float")));
nodes.push(
node(
"edges",
"luminance_edge",
{ strength: 2 },
{
source: input("composite", "color"),
colorTarget: input("edge_hdr", "spec"),
},
),
);
toneSource = "edges";
}
nodes.push(
node(
"tone",
"tone_map",
{ exposure: 1 },
{
source: input(toneSource, "color"),
colorTarget: input("surface", "surface"),
},
),
);
}
const last = nodes.at(-1);
nodes.push(
node("present", "present", {}, { surface: input(last.id, "color") }),
);
return Object.freeze({ schemaVersion: 2, graphId, revision: 1, nodes });
};
export const tone = postPreset("preset_tone", "tone"),
edges = postPreset("preset_edges", "edges"),
bloom = postPreset("preset_bloom", "bloom"),
combined = postPreset("preset_combined", "combined");
export const renderGraphPresets = Object.freeze({
midnight,
ember,
hdr,
culling,
tone,
edges,
bloom,
combined,
});
+5 -1
View File
@@ -12,7 +12,7 @@ export class RendererError extends Error {
export class RendererClient {
#bridge; #worker; #header; #slots; #buffer; #next = 1; #payload = 1;
#pending = new Map(); #payloadPending = new Map(); #payloadActive = new Set(); #ready; #disposed = false;
#telemetry; #stopped = false;
#telemetry; #profile; #stopped = false;
#graphQueue = []; #graphBusy = false;
#bvh; #snapshotReader; #picking = true; #snapshotEpoch = 0; #pickNext = 1; #picks = new Map();
@@ -40,6 +40,7 @@ export class RendererClient {
get ready() { return this.#ready; }
get telemetry() { return this.#telemetry; }
get profile() { return this.#profile; }
#refreshViews() {
const buffer = this.#bridge.memory.buffer;
@@ -61,6 +62,9 @@ export class RendererClient {
} else if (message?.type === "telemetry") {
this.#telemetry = message;
dispatchEvent(new CustomEvent("renderer-frame", { detail: message }));
} else if (message?.type === "profile-snapshot") {
this.#profile = message;
dispatchEvent(new CustomEvent("renderer-profile", { detail: message }));
} else if (message?.type === "fatal") {
console.error("renderer worker fatal", message.code, message.message);
this.#fail(message.code || "WORKER_FATAL");