Move glTF, picking, camera controls, and conventional handles into addons. Keep camera and material mutations in SIMD-aligned shared SOA rows and synchronize material updates directly into GPU buffers. Amp-Thread-ID: https://ampcode.com/threads/T-01a01380-b478-77d0-84a0-102880a5c5ae Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
386 lines
12 KiB
JavaScript
386 lines
12 KiB
JavaScript
import { YawnCore, RendererError } from "@yawn/core";
|
|
import { MeshHandles } from "@yawn/mesh-handles";
|
|
import { installCameraRenderDataControls } from "../cookbook/16-camera-render-data.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 createWorkerTransport() {
|
|
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),
|
|
});
|
|
addEventListener(
|
|
"resize",
|
|
() =>
|
|
post(0, [
|
|
Math.max(1, canvas.clientWidth),
|
|
Math.max(1, canvas.clientHeight),
|
|
devicePixelRatio,
|
|
]),
|
|
options,
|
|
);
|
|
const offscreen = canvas.transferControlToOffscreen();
|
|
worker.postMessage({ type: "init", canvas: offscreen }, [offscreen]);
|
|
return {
|
|
worker,
|
|
free() {
|
|
abort.abort();
|
|
},
|
|
};
|
|
}
|
|
|
|
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();
|
|
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);
|