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:
+37
-2
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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&¤tGeneration===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
@@ -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,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user