Files
yawn/examples/render-graph-studio/index.js
T

346 lines
11 KiB
JavaScript

import { YawnCore, RendererError } from "@yawn/core";
import { MeshHandles } from "@yawn/mesh-handles";
import { installCameraRenderDataControls } from "../shared/camera-controls.js";
import { createWorkerTransport } from "../shared/create-worker-transport.js";
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,
};
function publish(telemetry) {
state.telemetry = telemetry;
document.documentElement.dataset.yawnState = 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,
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("Render graph rollback failed", rollbackError);
}
console.error("Render graph 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();
meshHandles?.dispose();
renderer?.dispose();
}
}
const pagehide = () => {
void cleanup();
};
async function start() {
addEventListener("pagehide", pagehide, { once: true });
delete document.documentElement.dataset.yawnReady;
const transport = createWorkerTransport(document.querySelector("#canvas0"));
renderer = new YawnCore(transport);
meshHandles = new MeshHandles(renderer);
gltfImporter = new GltfImporter(renderer);
await renderer.ready;
listeners.push(
installCameraRenderDataControls(renderer, document.querySelector("#canvas0")),
);
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.yawnReady = "true";
}
const startupError = (error) => {
if (cleaned) return;
console.error("Render graph 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);