Rebuild core around render graph AST and shared memory
Amp-Thread-ID: https://ampcode.com/threads/T-01a01380-b478-77d0-84a0-102880a5c5ae Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
# Yawn examples
|
||||
|
||||
- `render-graph-studio/` is the complete browser integration with FXNode, JSO,
|
||||
external pipelines, shared glTF import, mesh handles, and picking.
|
||||
- `cookbook/` contains small copyable recipes, each focused on one public API.
|
||||
|
||||
Start the full browser example with `npm run dev`. The cookbook modules are plain
|
||||
ES modules and accept a `YawnCore`, mesh, instance, or worker endpoint where a live
|
||||
renderer is required.
|
||||
@@ -0,0 +1,27 @@
|
||||
import {
|
||||
createGraphAst,
|
||||
reference,
|
||||
serializeGraphAst,
|
||||
} from "@yawn/render-graph-ast";
|
||||
|
||||
const expression = (id, inputs = {}) => ({
|
||||
id,
|
||||
state: "enabled",
|
||||
executor: { key: "and", version: 2 },
|
||||
parameters: {},
|
||||
inputs,
|
||||
});
|
||||
|
||||
/** Build one DAG whose shared output fans out to two consumers. */
|
||||
export function canonicalAstExample() {
|
||||
const ast = createGraphAst({
|
||||
id: "shared_dag",
|
||||
revision: 1,
|
||||
nodes: [
|
||||
expression("source"),
|
||||
expression("left", { inputs: [reference("source", "value")] }),
|
||||
expression("right", { inputs: [reference("source", "value")] }),
|
||||
],
|
||||
});
|
||||
return { ast, source: serializeGraphAst(ast) };
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { graphFromObject } from "@yawn/render-graph-js";
|
||||
|
||||
/** Author a graph with an ordinary JavaScript object and receive canonical AST. */
|
||||
export function jsoGraphExample() {
|
||||
return graphFromObject({
|
||||
id: "jso_graph",
|
||||
revision: 1,
|
||||
nodes: [
|
||||
{
|
||||
id: "mesh",
|
||||
state: "enabled",
|
||||
executor: { key: "mesh", version: 2 },
|
||||
parameters: {},
|
||||
inputs: {},
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { RenderGraph, ref } from "@yawn/render-graph-js";
|
||||
|
||||
/** Build the same sort of DAG with the small mutable authoring facade. */
|
||||
export function fluentGraphExample() {
|
||||
return new RenderGraph("fluent_graph", 1)
|
||||
.node("source", "and", { version: 2 })
|
||||
.node("consumer", "not", {
|
||||
version: 1,
|
||||
inputs: { operand: [ref("source", "value")] },
|
||||
})
|
||||
.ast();
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { defaultPipelines } from "@yawn/default-pipelines";
|
||||
import { adaptFxNodeSnapshot } from "@yawn/render-graph-fxnode";
|
||||
import { CATALOG_VERSION, GRAPH_ID } from "@yawn/render-graph-fxnode/catalog";
|
||||
|
||||
/** Export a minimal FXNode authoring document through the shared AST boundary. */
|
||||
export function fxNodeExportExample() {
|
||||
return adaptFxNodeSnapshot(
|
||||
{
|
||||
graphId: GRAPH_ID,
|
||||
catalogVersion: CATALOG_VERSION,
|
||||
nodes: [],
|
||||
links: [],
|
||||
metadata: {},
|
||||
version: 1,
|
||||
},
|
||||
1,
|
||||
{ pipelines: defaultPipelines },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defaultPipelines } from "@yawn/default-pipelines";
|
||||
import { RenderGraph } from "@yawn/render-graph-js";
|
||||
|
||||
/** Copy the optional package's external programs into a graph AST. */
|
||||
export function defaultPipelineExample() {
|
||||
const graph = new RenderGraph("default_programs", 1);
|
||||
for (const pipeline of defaultPipelines.render)
|
||||
graph.renderPipeline(pipeline);
|
||||
for (const pipeline of defaultPipelines.compute)
|
||||
graph.computePipeline(pipeline);
|
||||
return graph.ast();
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { RenderGraph } from "@yawn/render-graph-js";
|
||||
|
||||
export const simpleSceneShader = /* wgsl */ `
|
||||
@group(1) @binding(0) var<uniform> view_projection: mat4x4<f32>;
|
||||
struct Input {
|
||||
@location(0) position: vec3<f32>,
|
||||
@location(3) model_0: vec4<f32>,
|
||||
@location(4) model_1: vec4<f32>,
|
||||
@location(5) model_2: vec4<f32>,
|
||||
@location(6) model_3: vec4<f32>,
|
||||
}
|
||||
@vertex fn vertex_main(input: Input) -> @builtin(position) vec4<f32> {
|
||||
let model = mat4x4<f32>(input.model_0, input.model_1, input.model_2, input.model_3);
|
||||
return view_projection * model * vec4(input.position, 1.0);
|
||||
}
|
||||
@fragment fn fragment_main() -> @location(0) vec4<f32> {
|
||||
return vec4(0.2, 0.7, 1.0, 1.0);
|
||||
}`;
|
||||
|
||||
/** Supply scene WGSL and entry points as graph data, never as core source. */
|
||||
export function customRenderPipelineExample() {
|
||||
return new RenderGraph("custom_render_program", 1)
|
||||
.renderPipeline({
|
||||
name: "ground_plane",
|
||||
shader: simpleSceneShader,
|
||||
vertexEntry: "vertex_main",
|
||||
fragmentEntry: "fragment_main",
|
||||
doubleSided: false,
|
||||
})
|
||||
.ast();
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { RenderGraph } from "@yawn/render-graph-js";
|
||||
|
||||
export const initializeShader = /* wgsl */ `
|
||||
@compute @workgroup_size(8, 1, 1)
|
||||
fn initialize() {}
|
||||
`;
|
||||
|
||||
/** Declare a binding-free compute pass that runs before graph render passes. */
|
||||
export function computePipelineExample() {
|
||||
return new RenderGraph("compute_program", 1)
|
||||
.computePipeline({
|
||||
name: "initialize",
|
||||
shader: initializeShader,
|
||||
entry: "initialize",
|
||||
dispatch: [4, 1, 1],
|
||||
})
|
||||
.ast();
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { loadGraph } from "@yawn/render-graph-js";
|
||||
|
||||
/** Compile a complete AST/JSO and make its prepared loadout active. */
|
||||
export async function compileAndSwitch(core, graph) {
|
||||
const compiled = await loadGraph(core, graph);
|
||||
try {
|
||||
await core.switchCompiledGraph(compiled.compiledId);
|
||||
return compiled;
|
||||
} catch (error) {
|
||||
await core.dropCompiledGraph(compiled.compiledId).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Return to clear-only mode before releasing an active compiled loadout. */
|
||||
export async function dropActiveGraph(core, compiled) {
|
||||
await core.switchToImmediate();
|
||||
await core.dropCompiledGraph(compiled.compiledId);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { GltfImporter } from "@yawn/gltf-import";
|
||||
import { MeshHandles } from "@yawn/mesh-handles";
|
||||
|
||||
/** Fetch a glTF URL in the import worker and wrap the resulting protocol handles. */
|
||||
export async function importGltf(core, url, options) {
|
||||
const importer = new GltfImporter(core);
|
||||
try {
|
||||
const imported = await importer.load(url, options);
|
||||
return new MeshHandles(core).fromImportedScene(imported);
|
||||
} finally {
|
||||
importer.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export const identityTransform = Object.freeze([
|
||||
1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1,
|
||||
]);
|
||||
|
||||
/** Create a conventional instance object from an imported mesh handle. */
|
||||
export function createInstance(mesh, transform = identityTransform) {
|
||||
return mesh.createInstance(transform);
|
||||
}
|
||||
|
||||
/** Frequent mutations stay on the shared-memory path exposed by the handle addon. */
|
||||
export function updateInstance(instance, transform, typeWords) {
|
||||
instance.setTransform(transform);
|
||||
instance.setType(typeWords);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/** Allocate one SIMD-aligned velocity row for every live instance slot. */
|
||||
export function createVelocityColumn(core) {
|
||||
return core.allocateArray({
|
||||
name: "instance.velocity",
|
||||
domain: "instance",
|
||||
scalar: "f32",
|
||||
lanes: 4,
|
||||
});
|
||||
}
|
||||
|
||||
/** Mutate an existing row directly; no renderer command message is emitted. */
|
||||
export function setVelocity(column, instance, xyz) {
|
||||
column.write(instance.handle[0], [xyz[0], xyz[1], xyz[2], 0]);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/** Write a new model matrix through core's generation-guarded shared SOA column. */
|
||||
export function animateInstance(core, instance, transform) {
|
||||
core.setInstanceTransform(instance.handle, transform);
|
||||
}
|
||||
|
||||
/** Write the opaque 512-bit instance classification used by graph predicates. */
|
||||
export function classifyInstance(core, instance, words) {
|
||||
core.setInstanceType(instance.handle, words);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { MeshHandles } from "@yawn/mesh-handles";
|
||||
|
||||
/** Query the optional snapshot/BVH worker and receive wrapped instance handles. */
|
||||
export function pickNearest(core, origin, direction) {
|
||||
return new MeshHandles(core).pickRay(origin, direction, {
|
||||
maxDistance: 10_000,
|
||||
maxHits: 1,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { YawnCore } from "@yawn/core";
|
||||
|
||||
/** Connect from any worker using a MessagePort with the Worker-like transport API. */
|
||||
export async function connectFromWorker(port, options = {}) {
|
||||
const core = new YawnCore({
|
||||
worker: port,
|
||||
memory: options.memory,
|
||||
ringPtr: options.ringPtr,
|
||||
pickingWorkerFactory: options.pickingWorkerFactory,
|
||||
free: options.free,
|
||||
});
|
||||
await core.ready;
|
||||
return core;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { culling } from "../render-graph-studio/render-graph/presets.js";
|
||||
import { importGltf } from "./09-gltf-import-worker.js";
|
||||
import { compileAndSwitch, dropActiveGraph } from "./08-compile-and-switch.js";
|
||||
|
||||
/** Combine the complete JSO graph, shared glTF import, and mesh-handle facade. */
|
||||
export async function loadCompleteScene(core, gltfUrl) {
|
||||
const compiled = await compileAndSwitch(core, culling);
|
||||
try {
|
||||
const meshes = await importGltf(core, gltfUrl);
|
||||
return {
|
||||
compiled,
|
||||
meshes,
|
||||
dispose: () => dropActiveGraph(core, compiled),
|
||||
};
|
||||
} catch (error) {
|
||||
await dropActiveGraph(core, compiled).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
# Yawn addon cookbook
|
||||
|
||||
These recipes deliberately avoid another application framework or renderer wrapper.
|
||||
Import the function you need and pass the same `YawnCore` instance to every addon.
|
||||
|
||||
| Recipe | Demonstrates |
|
||||
| --- | --- |
|
||||
| `01-canonical-ast.js` | A shared DAG output and canonical S-expression serialization |
|
||||
| `02-jso-graph.js` | Plain JavaScript object authoring |
|
||||
| `03-fluent-builder.js` | Fluent render-graph authoring |
|
||||
| `04-fxnode-export.js` | Exporting an FXNode snapshot to the canonical AST |
|
||||
| `05-default-pipelines.js` | Attaching optional external WGSL declarations |
|
||||
| `06-custom-render-pipeline.js` | Supplying a custom scene render shader |
|
||||
| `07-compute-pipeline.js` | Supplying a binding-free compute pass |
|
||||
| `08-compile-and-switch.js` | Compiling, activating, and safely cleaning up a graph |
|
||||
| `09-gltf-import-worker.js` | Fetching a glTF URL into shared memory from a worker |
|
||||
| `10-mesh-instances.js` | Creating and mutating conventional instance handles |
|
||||
| `11-custom-soa-column.js` | Allocating an instance-sized shared SOA column |
|
||||
| `12-direct-sab-animation.js` | Updating transforms through generation-guarded SAB writes |
|
||||
| `13-bvh-picking.js` | Ray picking through the mesh-handles addon |
|
||||
| `14-worker-to-worker.js` | Using core from another worker through a `MessagePort` |
|
||||
| `15-complete-scene.js` | Combining the graph, glTF, and mesh addons |
|
||||
|
||||
Recipes 1–7 isolate graph authoring concepts, so their ASTs are intentionally
|
||||
fragments rather than complete renderable loadouts. Recipe 15 uses the complete
|
||||
scene graph from `render-graph-studio` when an end-to-end example is needed.
|
||||
@@ -0,0 +1,15 @@
|
||||
export * from "./01-canonical-ast.js";
|
||||
export * from "./02-jso-graph.js";
|
||||
export * from "./03-fluent-builder.js";
|
||||
export * from "./04-fxnode-export.js";
|
||||
export * from "./05-default-pipelines.js";
|
||||
export * from "./06-custom-render-pipeline.js";
|
||||
export * from "./07-compute-pipeline.js";
|
||||
export * from "./08-compile-and-switch.js";
|
||||
export * from "./09-gltf-import-worker.js";
|
||||
export * from "./10-mesh-instances.js";
|
||||
export * from "./11-custom-soa-column.js";
|
||||
export * from "./12-direct-sab-animation.js";
|
||||
export * from "./13-bvh-picking.js";
|
||||
export * from "./14-worker-to-worker.js";
|
||||
export * from "./15-complete-scene.js";
|
||||
@@ -0,0 +1,93 @@
|
||||
// Procedural example assets keep the package demo self-contained.
|
||||
const JSON_CHUNK = 0x4e4f534a;
|
||||
const BIN_CHUNK = 0x004e4942;
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const align4 = value => (value + 3) & ~3;
|
||||
const finiteMinMax = (values, width) => {
|
||||
const min = Array(width).fill(Infinity), max = Array(width).fill(-Infinity);
|
||||
for (let i=0;i<values.length;i++) { const lane=i%width; min[lane]=Math.min(min[lane],values[i]); max[lane]=Math.max(max[lane],values[i]); }
|
||||
return {min,max};
|
||||
};
|
||||
|
||||
/** Encode indexed geometry as a deterministic, self-contained GLB 2.0 scene. */
|
||||
export function encodeGeometryGlb({positions,normals,texcoords,indices}) {
|
||||
if([...positions,...normals,...texcoords].some(value=>!Number.isFinite(value))||indices.some(value=>!Number.isInteger(value)||value<0))throw new TypeError("Invalid demo geometry");
|
||||
const streams=[new Float32Array(positions),new Float32Array(normals),new Float32Array(texcoords),new Uint32Array(indices)];
|
||||
if(!streams[0].length||streams[0].length%3||streams[1].length!==streams[0].length||streams[2].length/2!==streams[0].length/3||streams[3].length%3) throw new TypeError("Invalid demo geometry");
|
||||
const offsets=[], chunks=[], views=[]; let byteLength=0;
|
||||
for(const stream of streams){byteLength=align4(byteLength);offsets.push(byteLength);const bytes=new Uint8Array(stream.buffer);chunks.push({offset:byteLength,bytes});views.push({buffer:0,byteOffset:byteLength,byteLength:bytes.length});byteLength+=bytes.length;}
|
||||
byteLength=align4(byteLength);
|
||||
const vertexCount=streams[0].length/3, bounds=finiteMinMax(streams[0],3);
|
||||
if(indices.some(value=>value>=vertexCount))throw new TypeError("Invalid demo geometry");
|
||||
const nodes=[]; for(let z=-1;z<=1;z++)for(let x=-1;x<=1;x++)nodes.push({mesh:0,translation:[x*3,0,z*3]});
|
||||
const json={asset:{version:"2.0",generator:"yawn-phase8"},scene:0,scenes:[{nodes:nodes.map((_,i)=>i)}],nodes,meshes:[{primitives:[{attributes:{POSITION:0,NORMAL:1,TEXCOORD_0:2},indices:3}]}],buffers:[{byteLength}],bufferViews:views,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:streams[3].length,type:"SCALAR"},
|
||||
]};
|
||||
let jsonBytes=encoder.encode(JSON.stringify(json)); const jsonLength=align4(jsonBytes.length), total=12+8+jsonLength+8+byteLength;
|
||||
const 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 createCubeGeometry(){
|
||||
const positions=[],normals=[],texcoords=[],indices=[];const faces=[[[1,0,0],[1,-1,-1],[1,-1,1],[1,1,1],[1,1,-1]],[[-1,0,0],[-1,-1,1],[-1,-1,-1],[-1,1,-1],[-1,1,1]],[[0,1,0],[-1,1,1],[1,1,1],[1,1,-1],[-1,1,-1]],[[0,-1,0],[-1,-1,-1],[1,-1,-1],[1,-1,1],[-1,-1,1]],[[0,0,1],[-1,-1,1],[1,-1,1],[1,1,1],[-1,1,1]],[[0,0,-1],[1,-1,-1],[-1,-1,-1],[-1,1,-1],[1,1,-1]]];
|
||||
for(const [normal,...corners] of faces){
|
||||
const base=positions.length/3;corners.forEach((p,i)=>{positions.push(...p);normals.push(...normal);texcoords.push(...[[0,0],[1,0],[1,1],[0,1]][i]);});
|
||||
const a=corners[0],b=corners[1],c=corners[2],ab=b.map((value,i)=>value-a[i]),ac=c.map((value,i)=>value-a[i]);
|
||||
const cross=[ab[1]*ac[2]-ab[2]*ac[1],ab[2]*ac[0]-ab[0]*ac[2],ab[0]*ac[1]-ab[1]*ac[0]];
|
||||
const outward=cross.reduce((sum,value,i)=>sum+value*normal[i],0)>0;
|
||||
indices.push(...(outward?[base,base+1,base+2,base,base+2,base+3]:[base,base+2,base+1,base,base+3,base+2]));
|
||||
}
|
||||
return {positions,normals,texcoords,indices};
|
||||
}
|
||||
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 const loadouts=Object.freeze({cubes:{label:"Procedural cubes"},spheres:{label:"Procedural spheres"},materials:{label:"PBR material gallery"}});
|
||||
export async function loadDemoLoadout(id){
|
||||
if(id==="cubes")return encodeGeometryGlb(createCubeGeometry());
|
||||
if(id==="spheres")return encodeGeometryGlb(createUvSphereGeometry());
|
||||
if(id==="materials")return createMaterialGalleryGlb();
|
||||
throw new RangeError(`Unknown loadout: ${id}`);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Yawn Package Integration Example</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>
|
||||
</select></label
|
||||
><label class="field" for="graph-select"
|
||||
>Graph preset<select id="graph-select">
|
||||
<option value="authored">Authored</option>
|
||||
<option value="jso">JSO addon</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>
|
||||
@@ -0,0 +1,482 @@
|
||||
import { YawnCore, RendererError } from "@yawn/core";
|
||||
import { MeshHandles, createPickingWorker } from "@yawn/mesh-handles";
|
||||
import { loadDemoLoadout } from "./demo-loadouts.js";
|
||||
import { adaptFxNodeSnapshot } from "@yawn/render-graph-fxnode";
|
||||
import { createGraphAst } from "@yawn/render-graph-ast";
|
||||
import { loadGraph } from "@yawn/render-graph-js";
|
||||
import { GltfImporter } from "@yawn/gltf-import";
|
||||
import { defaultPipelines } from "@yawn/default-pipelines";
|
||||
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,
|
||||
meshHandles,
|
||||
gltfImporter,
|
||||
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 createWorkerTransport(profile) {
|
||||
const canvas = document.querySelector("#canvas0");
|
||||
const dpr = devicePixelRatio;
|
||||
canvas.width = Math.round(Math.max(1, canvas.clientWidth) * dpr);
|
||||
canvas.height = Math.round(Math.max(1, canvas.clientHeight) * dpr);
|
||||
|
||||
const worker = new Worker(
|
||||
new URL(
|
||||
"../../renderer/src/platform/web/worker/mainWorker.js",
|
||||
import.meta.url,
|
||||
),
|
||||
{ type: "module", name: "yawn-renderer" },
|
||||
);
|
||||
const abort = new AbortController();
|
||||
const options = { signal: abort.signal };
|
||||
const post = (kind, values) =>
|
||||
worker.postMessage({
|
||||
type: "window-event",
|
||||
kind,
|
||||
values: new Float64Array(values),
|
||||
});
|
||||
const mouseValues = (event) => [
|
||||
devicePixelRatio,
|
||||
event.buttons,
|
||||
event.movementX,
|
||||
event.movementY,
|
||||
event.offsetX,
|
||||
event.offsetY,
|
||||
Math.max(1, canvas.clientHeight),
|
||||
];
|
||||
|
||||
addEventListener(
|
||||
"resize",
|
||||
() =>
|
||||
post(0, [
|
||||
Math.max(1, canvas.clientWidth),
|
||||
Math.max(1, canvas.clientHeight),
|
||||
devicePixelRatio,
|
||||
]),
|
||||
options,
|
||||
);
|
||||
canvas.addEventListener(
|
||||
"pointerdown",
|
||||
(event) => {
|
||||
if (event.pointerType === "mouse" && (event.button === 1 || event.button === 2)) {
|
||||
event.preventDefault();
|
||||
canvas.setPointerCapture(event.pointerId);
|
||||
}
|
||||
},
|
||||
options,
|
||||
);
|
||||
canvas.addEventListener(
|
||||
"pointermove",
|
||||
(event) => {
|
||||
if (
|
||||
event.pointerType === "mouse" &&
|
||||
canvas.hasPointerCapture(event.pointerId) &&
|
||||
(event.buttons & 6) !== 0
|
||||
) {
|
||||
event.preventDefault();
|
||||
post(1, mouseValues(event));
|
||||
}
|
||||
},
|
||||
options,
|
||||
);
|
||||
for (const type of ["pointerup", "pointercancel"]) {
|
||||
canvas.addEventListener(
|
||||
type,
|
||||
(event) => {
|
||||
if (canvas.hasPointerCapture(event.pointerId)) {
|
||||
canvas.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
},
|
||||
options,
|
||||
);
|
||||
}
|
||||
canvas.addEventListener(
|
||||
"click",
|
||||
(event) => {
|
||||
if (event.button === 0) post(2, mouseValues(event));
|
||||
},
|
||||
options,
|
||||
);
|
||||
canvas.addEventListener(
|
||||
"wheel",
|
||||
(event) => {
|
||||
event.preventDefault();
|
||||
const delta =
|
||||
event.deltaMode === 0
|
||||
? event.deltaY
|
||||
: event.deltaMode === 1
|
||||
? event.deltaY * 16
|
||||
: event.deltaMode === 2
|
||||
? event.deltaY * Math.max(1, canvas.clientHeight)
|
||||
: null;
|
||||
if (Number.isFinite(delta)) post(3, [delta]);
|
||||
},
|
||||
{ ...options, passive: false },
|
||||
);
|
||||
canvas.addEventListener("contextmenu", (event) => event.preventDefault(), options);
|
||||
|
||||
const offscreen = canvas.transferControlToOffscreen();
|
||||
worker.postMessage({ type: "init", canvas: offscreen, profile }, [offscreen]);
|
||||
return {
|
||||
worker,
|
||||
pickingWorkerFactory: createPickingWorker,
|
||||
free() {
|
||||
abort.abort();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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(renderer, "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,
|
||||
activeCompiledSchemaVersion: telemetry.activeCompiledSchemaVersion,
|
||||
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);
|
||||
renderer.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"));
|
||||
};
|
||||
renderer.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?.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 });
|
||||
const url = URL.createObjectURL(new Blob([glb], { type: "model/gltf-binary" }));
|
||||
try {
|
||||
meshHandles.fromImportedScene(await gltfImporter.load(url));
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
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 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 {
|
||||
gltfImporter?.dispose();
|
||||
renderer?.dispose();
|
||||
}
|
||||
}
|
||||
const pagehide = () => {
|
||||
void cleanup();
|
||||
};
|
||||
|
||||
async function start() {
|
||||
addEventListener("pagehide", pagehide, { once: true });
|
||||
delete document.documentElement.dataset.phase8Ready;
|
||||
renderer = new YawnCore(createWorkerTransport(profileEnabled));
|
||||
meshHandles = new MeshHandles(renderer);
|
||||
gltfImporter = new GltfImporter(renderer);
|
||||
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: (snapshot, revision) =>
|
||||
adaptFxNodeSnapshot(snapshot, revision, { pipelines: defaultPipelines }),
|
||||
});
|
||||
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)) {
|
||||
// The explicit AST construction shows the common boundary shared by JSO and FXNode.
|
||||
const compiled = await loadGraph(renderer, createGraphAst(preset));
|
||||
state.compiled[name] = {
|
||||
...compiled,
|
||||
graphId: preset.id,
|
||||
revision: preset.revision,
|
||||
};
|
||||
}
|
||||
on(renderer, "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;
|
||||
const glb = await loadDemoLoadout("cubes");
|
||||
const url = URL.createObjectURL(new Blob([glb], { type: "model/gltf-binary" }));
|
||||
try {
|
||||
meshHandles.fromImportedScene(await gltfImporter.load(url));
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
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);
|
||||
@@ -0,0 +1,134 @@
|
||||
import { NODE_TITLE_OVERRIDES, semanticCatalog } from "@yawn/render-graph-fxnode/catalog";
|
||||
|
||||
// Example-only DOM menu for the FXNode frontend.
|
||||
|
||||
const GROUPS = Object.freeze([
|
||||
["source", "Source"],
|
||||
["expression", "Expression"],
|
||||
["compute", "Compute"],
|
||||
["cpu_preparation", "CPU preparation"],
|
||||
["render", "Render / post"],
|
||||
["frame", "Frame"],
|
||||
]);
|
||||
|
||||
const title = (typeId) => typeId.replaceAll("_", " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
||||
|
||||
/** Application-owned, immutable add-node catalog model. */
|
||||
export const addNodeItems = Object.freeze(
|
||||
GROUPS.flatMap(([execution, group]) =>
|
||||
Object.entries(semanticCatalog)
|
||||
.filter(([, definition]) => definition.execution === execution)
|
||||
.map(([typeId]) => Object.freeze({ typeId, title: NODE_TITLE_OVERRIDES[typeId] ?? title(typeId), group })),
|
||||
),
|
||||
);
|
||||
|
||||
export function searchAddNodeItems(query, items = addNodeItems) {
|
||||
const terms = query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean);
|
||||
return items.filter((item) => terms.every((term) =>
|
||||
`${item.title} ${item.typeId} ${item.group}`.toLocaleLowerCase().includes(term),
|
||||
));
|
||||
}
|
||||
|
||||
export function moveAddNodeSelection(index, delta, length) {
|
||||
return length ? ((Math.max(0, index) + delta) % length + length) % length : -1;
|
||||
}
|
||||
|
||||
/** Creates one transient DOM menu owned by the application rather than fxnode. */
|
||||
export function createAddNodeMenu(ownerDocument = document) {
|
||||
const ownerWindow = ownerDocument.defaultView;
|
||||
const root = ownerDocument.createElement("div");
|
||||
root.className = "fxnode-add-menu";
|
||||
root.hidden = true;
|
||||
root.setAttribute("role", "dialog");
|
||||
root.setAttribute("aria-label", "Add render graph node");
|
||||
const input = ownerDocument.createElement("input");
|
||||
input.type = "search";
|
||||
input.placeholder = "Search nodes…";
|
||||
input.setAttribute("aria-label", "Search nodes");
|
||||
input.setAttribute("aria-controls", "fxnode-add-options");
|
||||
input.setAttribute("aria-autocomplete", "list");
|
||||
const list = ownerDocument.createElement("div");
|
||||
list.id = "fxnode-add-options";
|
||||
list.className = "fxnode-add-menu__list";
|
||||
list.setAttribute("role", "listbox");
|
||||
root.append(input, list);
|
||||
ownerDocument.body.append(root);
|
||||
let resolve, filtered = addNodeItems, selected = 0, serial = 0, previousFocus;
|
||||
|
||||
const close = (value = null) => {
|
||||
if (root.hidden) return;
|
||||
root.hidden = true;
|
||||
const done = resolve;
|
||||
resolve = undefined;
|
||||
previousFocus?.focus?.();
|
||||
previousFocus = undefined;
|
||||
done?.(value);
|
||||
};
|
||||
const render = () => {
|
||||
filtered = searchAddNodeItems(input.value);
|
||||
selected = filtered.length ? Math.min(Math.max(selected, 0), filtered.length - 1) : -1;
|
||||
list.replaceChildren();
|
||||
let group;
|
||||
for (const [index, item] of filtered.entries()) {
|
||||
if (item.group !== group) {
|
||||
group = item.group;
|
||||
const heading = ownerDocument.createElement("div");
|
||||
heading.className = "fxnode-add-menu__group";
|
||||
heading.textContent = group;
|
||||
heading.setAttribute("role", "presentation");
|
||||
list.append(heading);
|
||||
}
|
||||
const option = ownerDocument.createElement("button");
|
||||
option.type = "button";
|
||||
option.id = `fxnode-add-option-${serial}-${index}`;
|
||||
option.className = "fxnode-add-menu__option";
|
||||
option.dataset.typeId = item.typeId;
|
||||
option.textContent = item.title;
|
||||
option.setAttribute("role", "option");
|
||||
option.setAttribute("aria-selected", String(index === selected));
|
||||
option.tabIndex = -1;
|
||||
option.addEventListener("pointermove", () => { selected = index; render(); });
|
||||
option.addEventListener("click", () => close(item.typeId));
|
||||
list.append(option);
|
||||
}
|
||||
const active = selected >= 0 ? list.querySelector(`[data-type-id="${filtered[selected].typeId}"]`) : null;
|
||||
input.setAttribute("aria-activedescendant", active?.id ?? "");
|
||||
active?.scrollIntoView({ block: "nearest" });
|
||||
};
|
||||
const reposition = () => {
|
||||
if (root.hidden) return;
|
||||
const margin = 8, box = root.getBoundingClientRect();
|
||||
root.style.left = `${Math.max(margin, Math.min(Number(root.dataset.x), ownerWindow.innerWidth - box.width - margin))}px`;
|
||||
root.style.top = `${Math.max(margin, Math.min(Number(root.dataset.y), ownerWindow.innerHeight - box.height - margin))}px`;
|
||||
};
|
||||
input.addEventListener("input", () => { selected = 0; render(); });
|
||||
input.addEventListener("keydown", (event) => {
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||||
event.preventDefault(); selected = moveAddNodeSelection(selected, event.key === "ArrowDown" ? 1 : -1, filtered.length); render();
|
||||
} else if (event.key === "Enter" && selected >= 0) {
|
||||
event.preventDefault(); close(filtered[selected].typeId);
|
||||
} else if (event.key === "Escape") { event.preventDefault(); close(); }
|
||||
});
|
||||
const outside = (event) => { if (!root.hidden && !root.contains(event.target)) close(); };
|
||||
ownerDocument.addEventListener("pointerdown", outside, true);
|
||||
ownerWindow.addEventListener("resize", close);
|
||||
ownerWindow.addEventListener("blur", close);
|
||||
return {
|
||||
open({ x, y }) {
|
||||
close();
|
||||
serial++;
|
||||
previousFocus = ownerDocument.activeElement;
|
||||
root.dataset.x = String(x); root.dataset.y = String(y);
|
||||
input.value = ""; selected = 0; root.hidden = false; render(); reposition(); input.focus();
|
||||
return new Promise((done) => { resolve = done; });
|
||||
},
|
||||
close,
|
||||
destroy() {
|
||||
close();
|
||||
ownerDocument.removeEventListener("pointerdown", outside, true);
|
||||
ownerWindow.removeEventListener("resize", close);
|
||||
ownerWindow.removeEventListener("blur", close);
|
||||
root.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { mapAuthoringDiagnostic } from "@yawn/render-graph-fxnode";
|
||||
import { loadGraph } from "@yawn/render-graph-js";
|
||||
|
||||
// Example lifecycle glue; package frontends remain independent of this controller.
|
||||
|
||||
export class AuthoringController {
|
||||
#renderer; #adapt; #revision = 0; #nextRevision = 1; #generation = 0;
|
||||
#current; #lastGood; #applyPromise; #listeners = new Set();
|
||||
#owned = new Map(); #drops = new Map(); #activeCompiles = new Set();
|
||||
#disposed = false; #applyingRecord; #scheduler; #debounceMs; #timer; #destroyPromise;
|
||||
|
||||
constructor({ renderer, adapt, scheduler = globalThis, debounceMs = 150 }) {
|
||||
this.#renderer = renderer; this.#adapt = adapt;
|
||||
this.#scheduler = scheduler; this.#debounceMs = debounceMs;
|
||||
}
|
||||
get revision() { return this.#revision; }
|
||||
get dirty() { return !!this.#current; }
|
||||
get applying() { return !!this.#applyPromise; }
|
||||
get staged() { return this.#current?.candidate ?? null; }
|
||||
get canApply() { return !this.#disposed && !!this.#current?.candidate && !this.#applyPromise; }
|
||||
subscribe(fn) {
|
||||
if (this.#disposed) return () => {};
|
||||
this.#listeners.add(fn); fn(this.#state());
|
||||
return () => this.#listeners.delete(fn);
|
||||
}
|
||||
#state() { return { revision: this.#revision, dirty: this.dirty, applying: this.applying, staged: this.staged, canApply: this.canApply, error: this.#current?.diagnostic ?? null }; }
|
||||
#emit() { if (!this.#disposed) for (const fn of this.#listeners) fn(this.#state()); }
|
||||
#key(id) { return JSON.stringify(id); }
|
||||
#drop(candidate) {
|
||||
if (!candidate) return Promise.resolve();
|
||||
const key = this.#key(candidate.compiledId);
|
||||
if (!this.#owned.has(key)) return this.#drops.get(key) ?? Promise.resolve();
|
||||
if (this.#drops.has(key)) return this.#drops.get(key);
|
||||
let result;
|
||||
try { result = this.#renderer.dropCompiledGraph(candidate.compiledId); }
|
||||
catch (error) { result = Promise.reject(error); }
|
||||
const dropping = Promise.resolve(result)
|
||||
.then(() => { this.#owned.delete(key); })
|
||||
.finally(() => { this.#drops.delete(key); });
|
||||
this.#drops.set(key, dropping);
|
||||
return dropping;
|
||||
}
|
||||
#retire(candidate) { if (candidate) void this.#drop(candidate).catch(() => {}); }
|
||||
#start(record) {
|
||||
if (!record.compile) {
|
||||
record.compile = this.#compile(record);
|
||||
this.#activeCompiles.add(record.compile);
|
||||
record.compile.finally(() => this.#activeCompiles.delete(record.compile));
|
||||
}
|
||||
return record.compile;
|
||||
}
|
||||
#flush(record = this.#current) {
|
||||
if (this.#timer) { this.#scheduler.clearTimeout(this.#timer); this.#timer = undefined; }
|
||||
return record ? this.#start(record) : null;
|
||||
}
|
||||
markDirty(snapshot) {
|
||||
if (this.#disposed) return;
|
||||
const previous = this.#current;
|
||||
const record = { generation: ++this.#generation, snapshot, candidate: null, error: null, diagnostic: null, compile: null };
|
||||
this.#current = record;
|
||||
if (previous?.candidate && previous !== this.#applyingRecord && previous.candidate !== this.#lastGood) this.#retire(previous.candidate);
|
||||
if (this.#timer) this.#scheduler.clearTimeout(this.#timer);
|
||||
this.#timer = this.#scheduler.setTimeout(() => { this.#timer = undefined; if (!this.#disposed) this.#start(record); }, this.#debounceMs);
|
||||
this.#emit();
|
||||
}
|
||||
async #compile(record) {
|
||||
let candidate, ir;
|
||||
try {
|
||||
ir = this.#adapt(record.snapshot, this.#nextRevision++);
|
||||
candidate = await loadGraph(this.#renderer, ir);
|
||||
this.#owned.set(this.#key(candidate.compiledId), candidate);
|
||||
if (this.#disposed || this.#current !== record) { this.#retire(candidate); return null; }
|
||||
record.candidate = candidate; record.error = record.diagnostic = null; this.#emit(); return candidate;
|
||||
} catch (error) {
|
||||
if (candidate) this.#retire(candidate);
|
||||
record.error = error;
|
||||
record.diagnostic = ir ? mapAuthoringDiagnostic(ir, error) : error;
|
||||
if (this.#current === record) this.#emit();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
apply() {
|
||||
if (this.#disposed) return Promise.resolve(null);
|
||||
if (this.#applyPromise) return this.#applyPromise;
|
||||
const record = this.#current;
|
||||
if (!record) return Promise.resolve(this.#lastGood);
|
||||
this.#applyingRecord = record; this.#flush(record);
|
||||
this.#applyPromise = this.#applyRecord(record); this.#emit(); return this.#applyPromise;
|
||||
}
|
||||
async #applyRecord(record) {
|
||||
try {
|
||||
const candidate = record.candidate ?? (await record.compile);
|
||||
if (!candidate) throw record.error ?? new Error("Graph compilation failed");
|
||||
if (this.#disposed || this.#current !== record) { this.#retire(candidate); return null; }
|
||||
await this.#renderer.switchCompiledGraph(candidate.compiledId);
|
||||
const old = this.#lastGood; this.#lastGood = candidate;
|
||||
this.#revision = candidate.revision ?? this.#revision + 1;
|
||||
if (this.#current === record) this.#current = undefined;
|
||||
if (old && old !== candidate) this.#retire(old);
|
||||
return candidate;
|
||||
} finally {
|
||||
if (this.#current !== record && record.candidate && record.candidate !== this.#lastGood) this.#retire(record.candidate);
|
||||
this.#applyPromise = null; this.#applyingRecord = undefined; this.#emit();
|
||||
}
|
||||
}
|
||||
destroy() {
|
||||
if (this.#destroyPromise) return this.#destroyPromise;
|
||||
this.#disposed = true;
|
||||
if (this.#timer) { this.#scheduler.clearTimeout(this.#timer); this.#timer = undefined; }
|
||||
this.#listeners.clear();
|
||||
this.#destroyPromise = this.#finish();
|
||||
return this.#destroyPromise;
|
||||
}
|
||||
async #finish() {
|
||||
const applying = this.#applyPromise;
|
||||
await Promise.allSettled([...(applying ? [applying] : []), ...this.#activeCompiles, ...this.#drops.values()]);
|
||||
await Promise.allSettled([...this.#owned.values()].map((candidate) => this.#drop(candidate)));
|
||||
this.#current = this.#lastGood = undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Browser input host used only by the interactive package example.
|
||||
const viewport = (canvas, ownerWindow) => ({
|
||||
width: Math.max(1, canvas.clientWidth),
|
||||
height: Math.max(1, canvas.clientHeight),
|
||||
dpr: Math.min(4, Math.max(1, ownerWindow.devicePixelRatio || 1)),
|
||||
});
|
||||
const sameViewport = (a, b) => a.width === b.width && a.height === b.height && a.dpr === b.dpr;
|
||||
const sizeCanvas = (canvas, value) => {
|
||||
canvas.width = Math.round(value.width * value.dpr);
|
||||
canvas.height = Math.round(value.height * value.dpr);
|
||||
};
|
||||
const mods = e => ({ alt:e.altKey, control:e.ctrlKey, meta:e.metaKey, shift:e.shiftKey });
|
||||
|
||||
export function prepareBrowserHost(canvas, { onError=console.error, requestAddNode }={}) {
|
||||
const ownerDocument=canvas.ownerDocument, ownerWindow=ownerDocument.defaultView ?? window;
|
||||
const originalTabIndex=canvas.getAttribute("tabindex"), originalTouchAction=canvas.style.touchAction;
|
||||
let view, root, dead=false, generation=0, requestEpoch=0, resizing=false, pending, appliedViewport, menuPending=false, menuPoint, unsubscribeHost=()=>{};
|
||||
const rootSubscriptions=[];
|
||||
const invalidateAddNode=()=>{requestEpoch++;menuPending=false;requestAddNode?.close?.()};
|
||||
const captured=new Set();
|
||||
const initialViewport=viewport(canvas,ownerWindow); appliedViewport=initialViewport; sizeCanvas(canvas,initialViewport);
|
||||
canvas.tabIndex=0; canvas.style.touchAction="none";
|
||||
const point=e=>{const r=canvas.getBoundingClientRect();return{x:e.clientX-r.left,y:e.clientY-r.top}};
|
||||
const input=e=>{
|
||||
if(!view)return;
|
||||
if(e instanceof ownerWindow.PointerEvent){
|
||||
const phase=e.type==="pointerdown"?"down":e.type==="pointermove"?"move":e.type==="pointerup"?"up":"cancel";
|
||||
if(phase==="down"){invalidateAddNode();menuPending=e.button===2&&!e.ctrlKey&&!e.altKey&&!e.metaKey&&!e.shiftKey&&(e.buttons&1)===0;menuPoint={x:e.clientX,y:e.clientY};canvas.focus();try{canvas.setPointerCapture(e.pointerId);captured.add(e.pointerId)}catch{}}
|
||||
if((phase==="up"||phase==="cancel")&&captured.delete(e.pointerId))try{if(canvas.hasPointerCapture(e.pointerId))canvas.releasePointerCapture(e.pointerId)}catch{}
|
||||
view.feedInput({kind:"pointer",phase,pointerId:e.pointerId,pointerType:e.pointerType,position:point(e),button:e.button,buttons:e.buttons,modifiers:mods(e)});
|
||||
}else if(e instanceof ownerWindow.WheelEvent){
|
||||
e.preventDefault(); invalidateAddNode();
|
||||
const scale=e.deltaMode===ownerWindow.WheelEvent.DOM_DELTA_LINE?16:e.deltaMode===ownerWindow.WheelEvent.DOM_DELTA_PAGE?Math.max(1,canvas.clientHeight):1;
|
||||
view.feedInput({kind:"wheel",position:point(e),delta:{x:e.deltaX*scale,y:e.deltaY*scale},modifiers:mods(e)});
|
||||
}else if(e instanceof ownerWindow.KeyboardEvent){invalidateAddNode();view.feedInput({kind:"key",phase:e.type==="keydown"?"down":"up",key:e.key,code:e.code,repeat:e.repeat,modifiers:mods(e)});
|
||||
}else view.feedInput({kind:"focus",phase:e.type==="focus"?"focus":"blur"});
|
||||
};
|
||||
const names=["pointerdown","pointermove","pointerup","pointercancel","wheel","keydown","keyup","focus","blur"];
|
||||
const pump=()=>{
|
||||
if(!view||resizing||!pending||dead)return;
|
||||
const next=pending, currentGeneration=generation;pending=undefined;
|
||||
if(sameViewport(next,appliedViewport)){sizeCanvas(canvas,next);pump();return}
|
||||
resizing=true;
|
||||
Promise.resolve(view.setViewport(next)).then(()=>{if(dead||currentGeneration!==generation)return;appliedViewport=next;sizeCanvas(canvas,next)}).catch(error=>{if(!dead&¤tGeneration===generation)onError(error)}).finally(()=>{if(dead||currentGeneration!==generation)return;resizing=false;pump()});
|
||||
};
|
||||
const resize=()=>{if(dead)return;invalidateAddNode();pending=viewport(canvas,ownerWindow);pump()};
|
||||
const outside=e=>{if(view&&e.button===0&&e.target!==canvas&&!canvas.contains(e.target)&&view.getHostSnapshot().colorPickerOpen)view.feedInput({kind:"outside-pointer",button:0})};
|
||||
const lost=e=>captured.delete(e.pointerId);
|
||||
const observer=new ownerWindow.ResizeObserver(resize);
|
||||
return {initialViewport,attach(_root,next){
|
||||
root=_root;view=next;
|
||||
for(const n of names)canvas.addEventListener(n,input,{passive:n!=="wheel"});
|
||||
canvas.addEventListener("contextmenu",prevent);canvas.addEventListener("lostpointercapture",lost);
|
||||
ownerDocument.addEventListener("pointerdown",outside,true);ownerWindow.addEventListener("resize",resize);
|
||||
unsubscribeHost=view.onHostRequests(request=>{if(request.kind!=="add-node-menu"||!menuPending||request.compositionRevision!==view.getHostSnapshot().compositionRevision){invalidateAddNode();return}menuPending=false;const epoch=requestEpoch;requestAddNode?.(request,menuPoint,()=>!dead&&epoch===requestEpoch);});
|
||||
rootSubscriptions.push(root.onMutations(invalidateAddNode),root.onCompositionChanges(invalidateAddNode));
|
||||
observer.observe(canvas);resize();
|
||||
},destroy(){
|
||||
if(dead)return;dead=true;generation++;pending=undefined;invalidateAddNode();observer.disconnect();unsubscribeHost();for(const unsubscribe of rootSubscriptions)unsubscribe();rootSubscriptions.length=0;ownerWindow.removeEventListener("resize",resize);ownerDocument.removeEventListener("pointerdown",outside,true);
|
||||
for(const n of names)canvas.removeEventListener(n,input);canvas.removeEventListener("contextmenu",prevent);canvas.removeEventListener("lostpointercapture",lost);
|
||||
for(const id of captured)try{if(canvas.hasPointerCapture(id))canvas.releasePointerCapture(id)}catch{}captured.clear();
|
||||
if(originalTabIndex===null)canvas.removeAttribute("tabindex");else canvas.setAttribute("tabindex",originalTabIndex);canvas.style.touchAction=originalTouchAction;view=null;root=null;
|
||||
}};
|
||||
}
|
||||
function prevent(e){e.preventDefault()}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { createFxNode } from "@fxnode/index.ts";
|
||||
import { CATALOG_VERSION, GRAPH_ID, fxNodeComposition } from "@yawn/render-graph-fxnode/catalog";
|
||||
import { prepareBrowserHost } from "./browser-host.js";
|
||||
import { createAddNodeMenu } from "./add-node-menu.js";
|
||||
import { createNodeIdAllocator, spawnRequestedNode } from "./node-spawn.js";
|
||||
import { culling } from "./presets.js";
|
||||
|
||||
// Seeds a user-facing FXNode document before exporting it through the addon.
|
||||
|
||||
async function seed(root) {
|
||||
await root.setState({
|
||||
graphId: GRAPH_ID,
|
||||
catalogVersion: CATALOG_VERSION,
|
||||
nodes: [],
|
||||
links: [],
|
||||
metadata: {},
|
||||
});
|
||||
for (const [index, item] of culling.nodes.entries())
|
||||
await root.dispatch({ type: "node.add", nodeId: item.id, nodeType: item.executor.key,
|
||||
position: { x: 40 + (index % 6) * 280, y: 120 + Math.floor(index / 6) * 260 } });
|
||||
const authoredNodes = new Map(culling.nodes.map((node) => [node.id, node]));
|
||||
const socketKey = (nodeId, semantic, direction) => {
|
||||
const type = authoredNodes.get(nodeId)?.executor.key;
|
||||
const sockets = fxNodeComposition.nodes[type]?.sockets ?? {};
|
||||
const matches = Object.entries(sockets).filter(
|
||||
([key, socket]) =>
|
||||
socket.direction === direction &&
|
||||
(key === semantic || socket.title === semantic),
|
||||
);
|
||||
return matches.length === 1 ? matches[0][0] : semantic;
|
||||
};
|
||||
const links = culling.nodes.flatMap((item) => Object.entries(item.inputs).flatMap(([socket, sources]) =>
|
||||
sources.map((from, index) => [from.node, from.socket, item.id, socket, index])));
|
||||
for (const [a, as, b, bs, index] of links) {
|
||||
const id = `${a}_${as}_${b}_${bs}_${index}`;
|
||||
await root.dispatch({
|
||||
type: "link.add",
|
||||
link: {
|
||||
id,
|
||||
fromNodeId: a,
|
||||
fromSocketId: `${a}:${socketKey(a, as, "output")}`,
|
||||
toNodeId: b,
|
||||
toSocketId: `${b}:${socketKey(b, bs, "input")}`,
|
||||
muted: false,
|
||||
extensions: {},
|
||||
},
|
||||
});
|
||||
}
|
||||
const authored = await root.getState();
|
||||
for (const item of culling.nodes) {
|
||||
const target = authored.nodes.find((candidate) => candidate.id === item.id);
|
||||
if (item.executor.key === "texture") {
|
||||
const texture = item.parameters.texture;
|
||||
const relative = texture.extent.kind === "surface_relative";
|
||||
const values = {
|
||||
residency: item.parameters.residency,
|
||||
format: texture.format,
|
||||
dimension: texture.dimension,
|
||||
extentMode: texture.extent.kind,
|
||||
absoluteWidth: relative ? 1 : texture.extent.width,
|
||||
absoluteHeight: relative ? 1 : texture.extent.height,
|
||||
relativeWidthNumerator: relative ? texture.extent.width.numerator : 1,
|
||||
relativeWidthDenominator: relative ? texture.extent.width.denominator : 1,
|
||||
relativeHeightNumerator: relative ? texture.extent.height.numerator : 1,
|
||||
relativeHeightDenominator: relative ? texture.extent.height.denominator : 1,
|
||||
depthOrArrayLayers: texture.extent.depthOrArrayLayers,
|
||||
mipLevelCount: texture.mipLevelCount,
|
||||
sampleCount: String(texture.sampleCount),
|
||||
viewFormat: texture.viewFormats[0] ?? "none",
|
||||
};
|
||||
for (const [key, value] of Object.entries(values))
|
||||
target.parameters[key].value = structuredClone(value);
|
||||
continue;
|
||||
}
|
||||
for (const [key, value] of Object.entries(item.parameters)) {
|
||||
const input = key.endsWith("Default") ? key.slice(0, -7) : null;
|
||||
if (input) {
|
||||
const socket = target.sockets.find((candidate) => candidate.key === input);
|
||||
if (socket?.defaultValue) socket.defaultValue.value = structuredClone(value);
|
||||
} else {
|
||||
const authoredKey = item.executor.key === "frustum_cull" && key === "camera" ? "cameraSelection" : key;
|
||||
if (target.parameters[authoredKey]) target.parameters[authoredKey].value = structuredClone(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
await root.setState(authored);
|
||||
}
|
||||
export async function createRenderGraphEditor(canvas) {
|
||||
const allocateId = createNodeIdAllocator();
|
||||
let root,
|
||||
view,
|
||||
menu,
|
||||
destroying,
|
||||
dead = false;
|
||||
const requestAddNode = Object.assign(
|
||||
async (request, point, isCurrent = () => true) => {
|
||||
let typeId;
|
||||
try {
|
||||
typeId = await menu?.open(point);
|
||||
} catch (error) {
|
||||
if (!dead && isCurrent()) console.error(error);
|
||||
return;
|
||||
}
|
||||
if (dead || !isCurrent() || !root || !view) return;
|
||||
const alive = () => !dead && isCurrent();
|
||||
try {
|
||||
await spawnRequestedNode(
|
||||
root,
|
||||
view,
|
||||
request,
|
||||
typeId,
|
||||
allocateId,
|
||||
alive,
|
||||
);
|
||||
} catch (error) {
|
||||
if (!dead) console.error(error);
|
||||
}
|
||||
},
|
||||
{ close: () => menu?.close() },
|
||||
);
|
||||
const host = prepareBrowserHost(canvas, { requestAddNode });
|
||||
const destroy = () =>
|
||||
(destroying ??= (async () => {
|
||||
dead = true;
|
||||
host.destroy();
|
||||
menu?.destroy();
|
||||
try {
|
||||
await view?.detach();
|
||||
} finally {
|
||||
root?.destroy();
|
||||
view = undefined;
|
||||
root = undefined;
|
||||
}
|
||||
})());
|
||||
try {
|
||||
root = await createFxNode({
|
||||
applicationId: "yawn.render-graph",
|
||||
applicationVersion: CATALOG_VERSION,
|
||||
resources: {},
|
||||
});
|
||||
await root.loadComposition(fxNodeComposition);
|
||||
await seed(root);
|
||||
view = await root.attachView({
|
||||
canvas,
|
||||
viewport: host.initialViewport,
|
||||
initialCamera: { center: { x: 780, y: 340 }, zoom: 0.34 },
|
||||
});
|
||||
menu = createAddNodeMenu(canvas.ownerDocument);
|
||||
host.attach(root, view);
|
||||
await view.whenRendered();
|
||||
const editorRoot = root,
|
||||
editorView = view;
|
||||
return {
|
||||
getState: () => editorRoot.getState(),
|
||||
onSnapshots: (fn) =>
|
||||
editorRoot.onSnapshots((event) => fn(event.snapshot, event.version)),
|
||||
whenRendered: () => editorView.whenRendered(),
|
||||
destroy,
|
||||
};
|
||||
} catch (e) {
|
||||
await destroy().catch(() => {});
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/** Allocates an example-local bounded FXNode ID, reserving candidates for this session. */
|
||||
export function createNodeIdAllocator(randomUUID = () => crypto.randomUUID()) {
|
||||
const reserved = new Set();
|
||||
return (existingIds) => {
|
||||
const existing = new Set(existingIds);
|
||||
for (let attempt = 0; attempt < 64; attempt++) {
|
||||
const id = `node_${randomUUID().replaceAll("-", "")}`;
|
||||
if (/^node_[A-Za-z0-9_]+$/.test(id) && id.length <= 128 && !existing.has(id) && !reserved.has(id)) {
|
||||
reserved.add(id);
|
||||
return id;
|
||||
}
|
||||
}
|
||||
throw new Error("Unable to allocate a unique node ID");
|
||||
};
|
||||
}
|
||||
|
||||
/** Adds exactly one node when the request still targets the loaded composition. */
|
||||
export async function spawnRequestedNode(root, view, request, typeId, allocateId, isCurrent = () => true) {
|
||||
const current = () =>
|
||||
isCurrent() && request.compositionRevision === view.getHostSnapshot().compositionRevision;
|
||||
if (!typeId || !current()) return false;
|
||||
let state;
|
||||
try {
|
||||
state = await root.getState();
|
||||
} catch (error) {
|
||||
if (!isCurrent()) return false;
|
||||
throw error;
|
||||
}
|
||||
if (!current()) return false;
|
||||
const nodeId = allocateId(state.nodes.map((node) => node.id));
|
||||
if (!current()) return false;
|
||||
try {
|
||||
await view.addNode(
|
||||
{ typeId, nodeId, viewPosition: request.viewPosition },
|
||||
{ expectedVersion: state.version },
|
||||
);
|
||||
} catch (error) {
|
||||
if (!isCurrent()) return false;
|
||||
throw error;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { defaultPipelines } from "@yawn/default-pipelines";
|
||||
import { descriptors } from "@yawn/render-graph-fxnode/catalog";
|
||||
import { graphFromObject } from "@yawn/render-graph-js";
|
||||
|
||||
// A complete graph authored like a package consumer would author it.
|
||||
|
||||
const input = (node, socket) => [{ node, socket }];
|
||||
const node = (id, key, parameters = {}, inputs = {}) => ({
|
||||
id,
|
||||
state: "enabled",
|
||||
executor: { key, version: descriptors[key].version },
|
||||
parameters,
|
||||
inputs,
|
||||
});
|
||||
const texture = (format) => ({
|
||||
texture: {
|
||||
dimension: "d2",
|
||||
format,
|
||||
extent: {
|
||||
kind: "surface_relative",
|
||||
width: { numerator: 1, denominator: 1 },
|
||||
height: { numerator: 1, denominator: 1 },
|
||||
depthOrArrayLayers: 1,
|
||||
},
|
||||
mipLevelCount: 1,
|
||||
sampleCount: 1,
|
||||
viewFormats: [],
|
||||
},
|
||||
residency: "transient",
|
||||
});
|
||||
|
||||
const nodes = [
|
||||
node("hdr", "texture", texture("rgba16_float")),
|
||||
node("scene_depth", "texture", texture("depth32_float")),
|
||||
node("mesh", "mesh"),
|
||||
node("type_words", "separate_u32x16", { valueDefault: Array(16).fill(0) }, { value: input("mesh", "type") }),
|
||||
node("type_bits", "separate_u32_bits", { valueDefault: 0 }, { value: input("type_words", "word0") }),
|
||||
node("ground_class", "and", {}, { inputs: [...input("type_bits", "bit0"), ...input("type_bits", "bit1")] }),
|
||||
node("not_double", "not", { operandDefault: false }, { operand: input("type_bits", "bit3") }),
|
||||
node("standard_class", "and", {}, { inputs: [...input("type_bits", "bit0"), ...input("type_bits", "bit2"), ...input("not_double", "value")] }),
|
||||
node("double_class", "and", {}, { inputs: [...input("type_bits", "bit0"), ...input("type_bits", "bit3")] }),
|
||||
node("cull", "frustum_cull", { camera: "active" }, { mesh: input("mesh", "mesh"), localAabb: input("mesh", "localAabb") }),
|
||||
node("not_culled", "not", { operandDefault: false }, { operand: input("cull", "isFrustumCulled") }),
|
||||
];
|
||||
for (const id of ["ground_class", "standard_class", "double_class"])
|
||||
nodes.find((item) => item.id === id).inputs.inputs.push(...input("not_culled", "value"));
|
||||
nodes.push(
|
||||
node("ground", "ground_plane", { depthCompare: "less_equal", depthWriteEnabled: true, clearDepth: 1, clearColor: [0.015, 0.02, 0.03, 1], predicateDefault: true }, { mesh: input("mesh", "mesh"), predicate: input("ground_class", "value"), color: input("hdr", "texture"), depth: input("scene_depth", "texture") }),
|
||||
node("pbr", "gltf_standard", { depthCompare: "less_equal", depthWriteEnabled: true, clearDepth: 1, clearColor: [0.015, 0.02, 0.03, 1], predicateDefault: true }, { mesh: input("mesh", "mesh"), predicate: input("standard_class", "value"), color: input("hdr", "texture"), depth: input("scene_depth", "texture") }),
|
||||
node("pbr_double", "gltf_standard_double_sided", { depthCompare: "less_equal", depthWriteEnabled: true, clearDepth: 1, clearColor: [0.015, 0.02, 0.03, 1], predicateDefault: true }, { mesh: input("mesh", "mesh"), predicate: input("double_class", "value"), color: input("hdr", "texture"), depth: input("scene_depth", "texture") }),
|
||||
node("frame_out", "frame_out", { surfaceFormat: "preferred", hdrEnabled: true, toneMapper: "aces", exposureStops: 0, outputTransfer: "srgb", scaleMode: "stretch", filter: "linear", backgroundColor: [0, 0, 0, 1] }, { color: input("pbr_double", "color") }),
|
||||
);
|
||||
|
||||
/** The example's JSO graph; the graph addon canonicalizes it to AST and S-expressions. */
|
||||
export const culling = graphFromObject({
|
||||
id: "example_jso_scene",
|
||||
revision: 1,
|
||||
pipelines: defaultPipelines,
|
||||
nodes,
|
||||
});
|
||||
|
||||
export const renderGraphPresets = Object.freeze({ jso: culling });
|
||||
Reference in New Issue
Block a user