Add FxNode render graph playground
Add persistent render graph loadouts, grouped node authoring, code-appending apply behavior, explicit playground lighting, and resilient worker startup. Amp-Thread-ID: https://ampcode.com/threads/T-01a033d4-0e44-708e-af0e-6166283416ff Co-authored-by: Heaust Azure <heaust.azure@gmail.com>
This commit is contained in:
+31
-10
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
AmbientLight,
|
||||
ArcRotateCamera,
|
||||
PointLight,
|
||||
Scene,
|
||||
importGltf,
|
||||
} from "@yawn/handles";
|
||||
@@ -223,24 +224,47 @@ async function openFile(file) {
|
||||
scene = new Scene(canvas, {
|
||||
arenaBytes: 384 * 1024 * 1024,
|
||||
hdr: true,
|
||||
clearColor: [0.025, 0.035, 0.045, 1],
|
||||
});
|
||||
await scene.ready;
|
||||
scene.array("sceneAccent").row(0).set([1, 1, 1, 1]);
|
||||
|
||||
const meshes = await importGltf(scene, url);
|
||||
if (!meshes.length)
|
||||
throw new Error("The file does not contain any triangle meshes.");
|
||||
|
||||
const bounds = findBounds(scene, meshes);
|
||||
const light = new AmbientLight(scene, {
|
||||
color: [1, 0.94, 0.86],
|
||||
intensity: 0.28,
|
||||
});
|
||||
const radius = Math.max(bounds.radius, 0.001);
|
||||
const lights = [
|
||||
new AmbientLight(scene, {
|
||||
color: [0.82, 0.9, 1],
|
||||
intensity: 0.22,
|
||||
}),
|
||||
new PointLight(scene, {
|
||||
position: [
|
||||
bounds.center[0] + radius * 2.4,
|
||||
bounds.center[1] + radius * 3.1,
|
||||
bounds.center[2] + radius * 2.2,
|
||||
],
|
||||
color: [1, 0.78, 0.56],
|
||||
intensity: 300,
|
||||
range: radius * 8,
|
||||
}),
|
||||
new PointLight(scene, {
|
||||
position: [
|
||||
bounds.center[0] - radius * 2.1,
|
||||
bounds.center[1] + radius * 0.8,
|
||||
bounds.center[2] - radius * 1.8,
|
||||
],
|
||||
color: [0.45, 0.72, 1],
|
||||
intensity: 120,
|
||||
range: radius * 7,
|
||||
}),
|
||||
];
|
||||
const camera = new ArcRotateCamera(
|
||||
scene,
|
||||
cameraOptions(scene, meshes, canvas, bounds),
|
||||
);
|
||||
await Promise.all([light.ready, camera.ready]);
|
||||
await Promise.all([...lights.map((light) => light.ready), camera.ready]);
|
||||
|
||||
elements.renderValue.textContent = "— ms";
|
||||
elements.renderTime.hidden = false;
|
||||
@@ -251,10 +275,7 @@ async function openFile(file) {
|
||||
if (!profiling)
|
||||
elements.renderTime.title =
|
||||
"GPU timestamp queries are not supported by this adapter.";
|
||||
else {
|
||||
elements.renderTime.removeAttribute("title");
|
||||
scene.array("sceneAccent").row(0)[3] = 1;
|
||||
}
|
||||
else elements.renderTime.removeAttribute("title");
|
||||
|
||||
activeViewer = {
|
||||
camera,
|
||||
|
||||
@@ -42,6 +42,8 @@ button { color: inherit; }
|
||||
.tool-group button { display: inline-flex; height: 29px; align-items: center; gap: 6px; padding: 0 9px; color: #586d80; border: 1px solid transparent; border-radius: 5px; background: transparent; cursor: pointer; font-size: 10px; }
|
||||
.tool-group button:hover, .tool-group button[aria-pressed="true"] { color: #187fc2; border-color: #d3e3ed; background: #fff; }
|
||||
.braces { color: #2498df; font: 700 10px/1 ui-monospace, monospace; }
|
||||
.graph-tool-hint { display: inline-flex; height: 27px; align-items: center; gap: 5px; padding: 0 9px; color: #6b8193; font-size: 9px; }
|
||||
.graph-tool-hint b { color: #258dcf; font-weight: 700; }
|
||||
.tool-context { display: flex; min-width: 0; flex: 1; align-items: center; justify-content: center; gap: 7px; color: #748698; font-size: 9px; }
|
||||
.language-dot { width: 6px; height: 6px; border-radius: 2px; background: #2d9de3; }
|
||||
.context-divider { width: 1px; height: 12px; margin-inline: 4px; background: #cfdae3; }
|
||||
@@ -55,6 +57,10 @@ button { color: inherit; }
|
||||
.pane-tab { display: flex; min-width: 145px; align-items: center; gap: 7px; padding: 0 14px; color: #9db1c2; border: 0; border-right: 1px solid #22384b; background: #0d1d2d; font-size: 10px; }
|
||||
.pane-tab.active { color: #d4e2ed; border-top: 2px solid #2ba5ea; background: #0b1927; }
|
||||
.ts-icon { display: grid; width: 18px; height: 16px; place-items: center; color: #fff; border-radius: 2px; background: #278dcd; font: 700 7px/1 ui-monospace, monospace; }
|
||||
.rg-tab-icon { display: grid; width: 18px; height: 16px; place-items: center; color: #b8e6fb; border: 1px solid #277da9; border-radius: 3px; background: #16425c; font-size: 10px; }
|
||||
#graph-tab-dirty { display: none; width: 6px; height: 6px; border-radius: 50%; background: #4fbceb; }
|
||||
#graph-tab-dirty.visible { display: inline-block; }
|
||||
.coding-panel { min-height: 0; }
|
||||
.editor { position: relative; min-height: 0; overflow: hidden; background: #0b1927; }
|
||||
.editor-loading { display: grid; height: 100%; place-items: center; color: #668097; font: 10px/1 ui-monospace, monospace; }
|
||||
.monaco-editor .margin { background-color: #0b1927 !important; }
|
||||
@@ -64,6 +70,57 @@ button { color: inherit; }
|
||||
#editor-diagnostics i { width: 5px; height: 5px; border-radius: 50%; background: #4aa975; }
|
||||
#editor-diagnostics.has-errors { color: #e8a4a4; }
|
||||
#editor-diagnostics.has-errors i { background: #e26f6f; }
|
||||
.render-graph-editor { display: grid; min-height: 0; grid-template-rows: 46px minmax(0, 1fr); overflow: hidden; background: #081522; }
|
||||
.render-graph-toolbar { position: relative; z-index: 2; display: flex; min-width: 0; align-items: center; gap: 8px; padding: 6px 8px; border-bottom: 1px solid #1d3548; background: #0d2030; box-shadow: 0 5px 18px rgb(0 0 0 / 10%); }
|
||||
.render-graph-toolbar label { position: relative; display: flex; min-width: 0; height: 31px; align-items: center; }
|
||||
.render-graph-toolbar label > span { position: absolute; z-index: 1; top: 4px; left: 9px; color: #54758c; font: 700 6px/1 ui-monospace, monospace; letter-spacing: .08em; text-transform: uppercase; pointer-events: none; }
|
||||
.render-graph-toolbar select, .render-graph-toolbar input { height: 31px; padding: 11px 28px 3px 9px; color: #c2d7e5; border: 1px solid #29465c; border-radius: 5px; outline: 0; background: #10283a; font-size: 9px; }
|
||||
.render-graph-toolbar select { width: 138px; cursor: pointer; }
|
||||
.render-graph-toolbar input { width: 120px; padding-right: 8px; }
|
||||
.render-graph-toolbar select:focus, .render-graph-toolbar input:focus { border-color: #318fc8; box-shadow: 0 0 0 2px rgb(49 155 215 / 12%); }
|
||||
.render-graph-toolbar button { display: inline-flex; height: 31px; flex: 0 0 auto; align-items: center; gap: 5px; padding: 0 10px; color: #dff4ff; border: 1px solid #238ac8; border-radius: 5px; background: #187cb7; cursor: pointer; font-size: 8px; font-weight: 700; }
|
||||
.render-graph-toolbar button:hover { background: #208dca; }
|
||||
.render-graph-toolbar button.secondary { color: #a9c7d9; border-color: #31536a; background: #132d3f; }
|
||||
.render-graph-toolbar button.secondary:hover { color: #e0f4ff; border-color: #39779c; background: #18394f; }
|
||||
.render-graph-toolbar button:disabled { cursor: wait; opacity: .55; }
|
||||
#loadout-status { display: inline-flex; min-width: 0; align-items: center; gap: 6px; margin-left: auto; overflow: hidden; color: #7590a3; font: 7px/1 ui-monospace, monospace; text-overflow: ellipsis; white-space: nowrap; }
|
||||
#loadout-status i { width: 5px; height: 5px; flex: 0 0 auto; border-radius: 50%; background: #44ad78; box-shadow: 0 0 0 3px rgb(68 173 120 / 10%); }
|
||||
#loadout-status.busy i { background: #3aa9e5; animation: status-pulse 1s infinite; }
|
||||
#loadout-status.failed { color: #df9696; }
|
||||
#loadout-status.failed i { background: #df6c6c; }
|
||||
.render-graph-canvas-wrap { position: relative; min-width: 0; min-height: 0; overflow: hidden; }
|
||||
#render-graph-canvas { display: block; width: 100%; height: 100%; outline: 0; touch-action: none; }
|
||||
#render-graph-canvas:focus-visible { box-shadow: inset 0 0 0 1px #3ba8e4; }
|
||||
.render-graph-empty-hint { position: absolute; right: 12px; bottom: 12px; display: grid; grid-template-columns: 24px auto; gap: 2px 8px; align-items: center; padding: 8px 10px; color: #718b9f; border: 1px solid #203e53; border-radius: 7px; background: rgb(9 27 42 / 86%); box-shadow: 0 8px 25px rgb(0 0 0 / 16%); pointer-events: none; backdrop-filter: blur(6px); }
|
||||
.render-graph-empty-hint > span { display: grid; width: 24px; height: 24px; grid-row: 1 / 3; place-items: center; color: #50b9ed; border: 1px solid #295b77; border-radius: 5px; background: #0f3046; }
|
||||
.render-graph-empty-hint strong { color: #9bb4c6; font-size: 8px; }
|
||||
.render-graph-empty-hint small { color: #577085; font-size: 7px; }
|
||||
|
||||
.rg-node-menu { position: fixed; z-index: 250; width: 226px; padding: 7px; visibility: hidden; color: #bcd0de; border: 1px solid #2d4c61; border-radius: 8px; background: #0d2030; box-shadow: 0 18px 50px rgb(0 0 0 / 40%); font-size: 9px; }
|
||||
.rg-node-menu::before { position: absolute; inset: 0; border-radius: inherit; box-shadow: inset 0 1px rgb(255 255 255 / 4%); content: ""; pointer-events: none; }
|
||||
.rg-menu-search { display: flex; height: 31px; align-items: center; gap: 7px; margin-bottom: 6px; padding: 0 8px; color: #5d8098; border: 1px solid #28465b; border-radius: 5px; background: #091927; }
|
||||
.rg-menu-search input { min-width: 0; flex: 1; color: #cce1ed; border: 0; outline: 0; background: transparent; font-size: 9px; }
|
||||
.rg-menu-search input::placeholder { color: #557186; }
|
||||
.rg-menu-groups { display: grid; gap: 2px; }
|
||||
.rg-menu-group { position: relative; }
|
||||
.rg-menu-group-trigger { display: grid; width: 100%; min-height: 34px; grid-template-columns: 24px 1fr auto; align-items: center; gap: 7px; padding: 0 7px; color: #a6bfce; text-align: left; border: 1px solid transparent; border-radius: 5px; background: transparent; cursor: default; }
|
||||
.rg-menu-group-trigger:hover, .rg-menu-group.open > .rg-menu-group-trigger { color: #d9effb; border-color: #294b62; background: #153246; }
|
||||
.rg-menu-group-icon { display: grid; width: 21px; height: 21px; place-items: center; color: #60c3f2; border: 1px solid #2a5a75; border-radius: 4px; background: #0e2a3e; font-size: 10px; }
|
||||
.rg-menu-group-trigger small { color: #557287; font-size: 7px; }
|
||||
.rg-menu-chevron { color: #56788f; }
|
||||
.rg-menu-submenu { position: absolute; top: -7px; left: calc(100% + 7px); display: none; width: 252px; max-height: min(390px, calc(100vh - 20px)); padding: 7px; overflow-y: auto; border: 1px solid #2d4c61; border-radius: 8px; background: #0d2030; box-shadow: 0 18px 50px rgb(0 0 0 / 38%); }
|
||||
.rg-node-menu.open-left .rg-menu-submenu { right: calc(100% + 7px); left: auto; }
|
||||
.rg-menu-group.open > .rg-menu-submenu { display: grid; gap: 2px; }
|
||||
.rg-menu-group:nth-child(n + 4) .rg-menu-submenu { top: auto; bottom: -7px; }
|
||||
.rg-menu-submenu::before { position: absolute; top: 0; right: 100%; width: 8px; height: 42px; content: ""; }
|
||||
.rg-menu-submenu > strong { padding: 5px 7px 7px; color: #5f8198; font: 700 7px/1 ui-monospace, monospace; letter-spacing: .1em; text-transform: uppercase; }
|
||||
.rg-menu-option { display: grid; min-height: 42px; grid-template-columns: 1fr auto; gap: 3px 8px; padding: 6px 8px; color: #a9c0cf; text-align: left; border: 1px solid transparent; border-radius: 5px; background: transparent; cursor: pointer; }
|
||||
.rg-menu-option:hover, .rg-menu-option.active { color: #def3ff; border-color: #2b526a; background: #15364c; }
|
||||
.rg-menu-option span { font-weight: 650; }
|
||||
.rg-menu-option small { grid-column: 1 / 3; color: #607b8f; font-size: 7px; line-height: 1.3; }
|
||||
.rg-menu-option b { color: #48b6eb; font-size: 11px; font-weight: 400; }
|
||||
.rg-menu-results { display: grid; gap: 2px; max-height: min(390px, calc(100vh - 75px)); overflow-y: auto; }
|
||||
.rg-menu-empty { padding: 17px 9px; color: #657e91; text-align: center; font-size: 8px; }
|
||||
.resize-handle { position: relative; z-index: 3; cursor: col-resize; background: #172a3b; }
|
||||
.resize-handle::after { position: absolute; top: 50%; left: 1px; width: 3px; height: 40px; transform: translateY(-50%); border-radius: 3px; background: #355067; content: ""; opacity: .65; }
|
||||
.resize-handle:hover, .resize-handle.dragging { background: #218ed2; }
|
||||
@@ -153,9 +210,13 @@ button { color: inherit; }
|
||||
.run-button kbd { display: none; }
|
||||
.tool-context { display: none; }
|
||||
.tool-group-right { margin-left: auto; }
|
||||
.workbench { display: grid; overflow: auto; grid-template-columns: 1fr; grid-template-rows: minmax(400px, 55vh) minmax(450px, 1fr); }
|
||||
.workbench { display: grid; overflow: auto; grid-template-columns: 1fr; grid-template-rows: max(560px, 55vh) minmax(500px, 1fr); }
|
||||
.resize-handle { display: none; }
|
||||
.stage-pane { min-height: 500px; }
|
||||
.render-graph-toolbar { overflow-x: auto; }
|
||||
.loadout-name-field { display: none !important; }
|
||||
.render-graph-empty-hint { display: none; }
|
||||
.rg-menu-submenu { position: fixed; top: auto; right: 8px; bottom: 8px; left: 8px; width: auto; max-height: 42vh; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
+515
-17
@@ -1,3 +1,12 @@
|
||||
import {
|
||||
compileFxNodeRenderGraph,
|
||||
createRenderGraphEditor,
|
||||
renderGraphNodeGroups,
|
||||
renderGraphPresets,
|
||||
} from "@yawn/fxnode-rg";
|
||||
|
||||
const renderGraphComment = "/* Render graph loadout:";
|
||||
|
||||
const defaultSource = `import {
|
||||
AmbientLight,
|
||||
ArcRotateCamera,
|
||||
@@ -11,7 +20,11 @@ const defaultSource = `import {
|
||||
SpotLight,
|
||||
} from "@yawn/handles";
|
||||
|
||||
const scene = new Scene(canvas, { hdr: true, fps: 60 });
|
||||
const scene = new Scene(canvas, {
|
||||
hdr: true,
|
||||
fps: 60,
|
||||
clearColor: [0.018, 0.035, 0.055, 1],
|
||||
});
|
||||
await scene.ready;
|
||||
await scene.reserve({ nodes: 6, materials: 2 });
|
||||
|
||||
@@ -62,15 +75,16 @@ const lights = [
|
||||
outerAngle: 0.72,
|
||||
}),
|
||||
new PointLight(scene, {
|
||||
position: [2.2, 0.9, 1.8],
|
||||
color: [1, 0.02, 0.45],
|
||||
intensity: 16,
|
||||
range: 6,
|
||||
position: [3.4, 1.8, 3.6],
|
||||
color: [0.9, 0.04, 0.38],
|
||||
intensity: 45,
|
||||
range: 14,
|
||||
radius: 1.5,
|
||||
}),
|
||||
];
|
||||
await Promise.all(lights.map((light) => light.ready));
|
||||
|
||||
log("Icosphere ready — teal ambience, orange rim, and magenta accent are active.");
|
||||
log("Icosphere ready — teal ambience, orange rim, and soft magenta fill are active.");
|
||||
|
||||
export default {
|
||||
scene,
|
||||
@@ -121,19 +135,27 @@ await scene.batchGraphUpdates(async () => {
|
||||
const elements = Object.fromEntries(
|
||||
[
|
||||
"canvas",
|
||||
"apply-loadout-button",
|
||||
"clear-console",
|
||||
"console-count",
|
||||
"console-empty",
|
||||
"console-lines",
|
||||
"copy-loadout-code-button",
|
||||
"cursor-status",
|
||||
"dirty-indicator",
|
||||
"editor",
|
||||
"editor-diagnostics",
|
||||
"editor-mode-status",
|
||||
"encoding-status",
|
||||
"format-button",
|
||||
"fps-status",
|
||||
"fullscreen-button",
|
||||
"graph-tab-dirty",
|
||||
"inspector",
|
||||
"inspector-button",
|
||||
"loadout-name",
|
||||
"loadout-select",
|
||||
"loadout-status",
|
||||
"preview",
|
||||
"preview-message",
|
||||
"profile-adapter",
|
||||
@@ -143,6 +165,9 @@ const elements = Object.fromEntries(
|
||||
"profile-results",
|
||||
"profile-total",
|
||||
"project-title",
|
||||
"render-graph-canvas",
|
||||
"render-graph-editor",
|
||||
"render-graph-hint",
|
||||
"reset-button",
|
||||
"resize-handle",
|
||||
"resolution-status",
|
||||
@@ -156,6 +181,7 @@ const elements = Object.fromEntries(
|
||||
"tab-dirty",
|
||||
"toast",
|
||||
"type-status",
|
||||
"workspace-language",
|
||||
"workbench",
|
||||
].map((id) => [id, document.getElementById(id)]),
|
||||
);
|
||||
@@ -222,14 +248,29 @@ let currentID = "";
|
||||
let currentRevision = 0;
|
||||
let initialSource = defaultSource;
|
||||
let generation = 0;
|
||||
let lastRunSource = "";
|
||||
let dirty = false;
|
||||
let activePanel = "console";
|
||||
let activeCodingTab = "scene";
|
||||
let stopProfile;
|
||||
let profileCore;
|
||||
let toastTimer;
|
||||
let frameRequest;
|
||||
let sampledFrame = 0;
|
||||
let sampledAt = 0;
|
||||
let renderGraphEditor;
|
||||
let renderGraphMenu;
|
||||
let renderGraphMenuOutsideListener;
|
||||
let graphDirty = false;
|
||||
let loadoutsDirty = false;
|
||||
let suppressGraphChanges = false;
|
||||
let customLoadouts = [];
|
||||
let currentLoadoutID = "beauty";
|
||||
let compiledRenderGraph = "";
|
||||
|
||||
localStorage.removeItem("yawn:playground:loadouts");
|
||||
localStorage.removeItem("yawn:playground:active-loadout");
|
||||
localStorage.removeItem("yawn:playground:draft");
|
||||
|
||||
addEventListener("yawn-theme-change", (event) => {
|
||||
monaco?.editor.setTheme(event.detail.resolved === "dark" ? "yawn" : "vs");
|
||||
@@ -242,6 +283,29 @@ function savedLocation() {
|
||||
return match ? { id: match[1], revision: Number(match[2]) } : undefined;
|
||||
}
|
||||
|
||||
function validCustomLoadouts(value) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.filter(
|
||||
(loadout) =>
|
||||
loadout &&
|
||||
typeof loadout.id === "string" &&
|
||||
/^custom-[a-z0-9-]+$/u.test(loadout.id) &&
|
||||
typeof loadout.name === "string" &&
|
||||
loadout.name.trim() &&
|
||||
loadout.layout &&
|
||||
Array.isArray(loadout.layout.nodes) &&
|
||||
Array.isArray(loadout.layout.links),
|
||||
)
|
||||
.slice(0, 40)
|
||||
.map((loadout) => ({
|
||||
id: loadout.id,
|
||||
name: loadout.name.trim().slice(0, 64),
|
||||
builtIn: false,
|
||||
layout: loadout.layout,
|
||||
}));
|
||||
}
|
||||
|
||||
async function loadInitialPlayground() {
|
||||
const saved = savedLocation();
|
||||
if (saved) {
|
||||
@@ -258,6 +322,7 @@ async function loadInitialPlayground() {
|
||||
elements["revision-label"].textContent = `Revision ${result.revision}`;
|
||||
elements["share-button"].hidden = false;
|
||||
initialSource = result.code;
|
||||
customLoadouts = validCustomLoadouts(result.loadouts);
|
||||
document.title = `${result.title} — Yawn Playground`;
|
||||
return result.code;
|
||||
} catch (error) {
|
||||
@@ -266,7 +331,8 @@ async function loadInitialPlayground() {
|
||||
showToast(error.message);
|
||||
}
|
||||
}
|
||||
return localStorage.getItem("yawn:playground:draft") || defaultSource;
|
||||
customLoadouts = [];
|
||||
return defaultSource;
|
||||
}
|
||||
|
||||
function loadMonaco() {
|
||||
@@ -367,13 +433,12 @@ async function createEditor(source) {
|
||||
run,
|
||||
});
|
||||
editor.onDidChangeCursorPosition(({ position }) => {
|
||||
if (activeCodingTab !== "scene") return;
|
||||
elements["cursor-status"].textContent =
|
||||
`Ln ${position.lineNumber}, Col ${position.column}`;
|
||||
});
|
||||
model.onDidChangeContent(() => {
|
||||
markDirty(true);
|
||||
if (!currentID)
|
||||
localStorage.setItem("yawn:playground:draft", model.getValue());
|
||||
});
|
||||
monaco.editor.onDidChangeMarkers(([resource]) => {
|
||||
if (resource.toString() === model.uri.toString()) updateDiagnostics();
|
||||
@@ -384,6 +449,7 @@ async function createEditor(source) {
|
||||
|
||||
function updateDiagnostics() {
|
||||
if (!monaco || !model) return;
|
||||
if (activeCodingTab !== "scene") return;
|
||||
const markers = monaco.editor.getModelMarkers({ resource: model.uri });
|
||||
const errors = markers.filter(
|
||||
(marker) => marker.severity === monaco.MarkerSeverity.Error,
|
||||
@@ -401,10 +467,403 @@ function updateDiagnostics() {
|
||||
: "TypeScript language service ready";
|
||||
}
|
||||
|
||||
function markDirty(value) {
|
||||
function markDirty(value, target = "code") {
|
||||
dirty = value;
|
||||
elements["dirty-indicator"].classList.toggle("visible", value);
|
||||
elements["tab-dirty"].classList.toggle("visible", value);
|
||||
if (!value) {
|
||||
elements["tab-dirty"].classList.remove("visible");
|
||||
elements["graph-tab-dirty"].classList.remove("visible");
|
||||
graphDirty = false;
|
||||
loadoutsDirty = false;
|
||||
} else if (target === "graph") {
|
||||
elements["graph-tab-dirty"].classList.add("visible");
|
||||
} else {
|
||||
elements["tab-dirty"].classList.add("visible");
|
||||
}
|
||||
}
|
||||
|
||||
function allLoadouts() {
|
||||
return [...renderGraphPresets, ...customLoadouts];
|
||||
}
|
||||
|
||||
function activeLoadout() {
|
||||
return allLoadouts().find((loadout) => loadout.id === currentLoadoutID);
|
||||
}
|
||||
|
||||
function loadoutLayout(loadout = activeLoadout()) {
|
||||
if (!loadout) throw new Error("The selected render graph loadout is missing");
|
||||
return loadout.layout;
|
||||
}
|
||||
|
||||
function renderLoadoutOptions() {
|
||||
const builtIn = document.createElement("optgroup");
|
||||
const saved = document.createElement("optgroup");
|
||||
builtIn.label = "Prepared loadouts";
|
||||
saved.label = "Playground loadouts";
|
||||
for (const loadout of renderGraphPresets) {
|
||||
const option = document.createElement("option");
|
||||
option.value = loadout.id;
|
||||
option.textContent = loadout.name;
|
||||
builtIn.append(option);
|
||||
}
|
||||
for (const loadout of customLoadouts) {
|
||||
const option = document.createElement("option");
|
||||
option.value = loadout.id;
|
||||
option.textContent = loadout.name;
|
||||
saved.append(option);
|
||||
}
|
||||
elements["loadout-select"].replaceChildren(
|
||||
builtIn,
|
||||
...(customLoadouts.length ? [saved] : []),
|
||||
);
|
||||
elements["loadout-select"].value = currentLoadoutID;
|
||||
const currentLoadout = activeLoadout();
|
||||
elements["loadout-name"].value = currentLoadout?.name ?? "";
|
||||
}
|
||||
|
||||
function setLoadoutStatus(message, state = "ready") {
|
||||
elements["loadout-status"].className =
|
||||
state === "busy" ? "busy" : state === "failed" ? "failed" : "";
|
||||
elements["loadout-status"].title = message;
|
||||
elements["loadout-status"].lastChild.textContent = ` ${message}`;
|
||||
}
|
||||
|
||||
function markGraphChanged() {
|
||||
if (suppressGraphChanges) return;
|
||||
compiledRenderGraph = "";
|
||||
graphDirty = true;
|
||||
loadoutsDirty = true;
|
||||
markDirty(true, "graph");
|
||||
setLoadoutStatus("Unsaved graph changes");
|
||||
}
|
||||
|
||||
function stageActiveLoadout(layout) {
|
||||
const currentLoadout = activeLoadout();
|
||||
if (!currentLoadout)
|
||||
throw new Error("The selected render graph loadout is missing");
|
||||
const name =
|
||||
elements["loadout-name"].value.trim() ||
|
||||
`${currentLoadout.name || "Render graph"} custom`;
|
||||
let id = currentLoadout.builtIn ? "" : currentLoadout.id;
|
||||
if (!id) id = `custom-${crypto.randomUUID().slice(0, 8)}`;
|
||||
const changed =
|
||||
currentLoadout.builtIn || graphDirty || currentLoadout.name !== name;
|
||||
const staged = changed
|
||||
? {
|
||||
id,
|
||||
name: name.slice(0, 64),
|
||||
builtIn: false,
|
||||
layout,
|
||||
}
|
||||
: currentLoadout;
|
||||
if (changed) {
|
||||
const index = customLoadouts.findIndex((loadout) => loadout.id === id);
|
||||
if (index < 0) customLoadouts.push(staged);
|
||||
else customLoadouts[index] = staged;
|
||||
loadoutsDirty = true;
|
||||
}
|
||||
currentLoadoutID = id;
|
||||
graphDirty = false;
|
||||
renderLoadoutOptions();
|
||||
if (changed) markDirty(true, "graph");
|
||||
else elements["graph-tab-dirty"].classList.toggle("visible", loadoutsDirty);
|
||||
return staged;
|
||||
}
|
||||
|
||||
function generatedRenderGraphBlock(name, serialized) {
|
||||
const safeName = name.replace(/[\r\n]+/gu, " ").replaceAll("*/", "* /");
|
||||
return `${renderGraphComment} ${safeName} */ { const renderGraph = ${JSON.stringify(serialized)}; const loadoutId = await scene.compileRenderGraph(renderGraph); await scene.setLoadout(loadoutId); }`;
|
||||
}
|
||||
|
||||
function withAppendedRenderGraph(source, name, serialized) {
|
||||
const block = generatedRenderGraphBlock(name, serialized);
|
||||
return `${source.trimEnd()}\n\n${block}\n`;
|
||||
}
|
||||
|
||||
async function compileLoadout(layout) {
|
||||
const scene = current?.scene;
|
||||
if (!scene?.getRenderGraph)
|
||||
throw new Error("Run a Scene before compiling a render graph loadout");
|
||||
return compileFxNodeRenderGraph(layout, await scene.getRenderGraph());
|
||||
}
|
||||
|
||||
function closeRenderGraphMenu() {
|
||||
if (renderGraphMenuOutsideListener) {
|
||||
document.removeEventListener(
|
||||
"pointerdown",
|
||||
renderGraphMenuOutsideListener,
|
||||
true,
|
||||
);
|
||||
renderGraphMenuOutsideListener = undefined;
|
||||
}
|
||||
renderGraphMenu?.remove();
|
||||
renderGraphMenu = undefined;
|
||||
}
|
||||
|
||||
function renderGraphOption(item, request, groupTitle) {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "rg-menu-option";
|
||||
button.dataset.typeId = item.typeId;
|
||||
button.dataset.search =
|
||||
`${item.title} ${item.description} ${item.keywords || ""} ${groupTitle}`.toLowerCase();
|
||||
const title = document.createElement("span");
|
||||
const add = document.createElement("b");
|
||||
const description = document.createElement("small");
|
||||
title.textContent = item.title;
|
||||
add.textContent = "+";
|
||||
description.textContent = item.description;
|
||||
button.append(title, add, description);
|
||||
button.addEventListener("click", async () => {
|
||||
closeRenderGraphMenu();
|
||||
try {
|
||||
await request.add(item.typeId);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setLoadoutStatus(message, "failed");
|
||||
showToast(message);
|
||||
}
|
||||
});
|
||||
return button;
|
||||
}
|
||||
|
||||
function openRenderGraphMenu(request) {
|
||||
closeRenderGraphMenu();
|
||||
const menu = document.createElement("div");
|
||||
menu.className = "rg-node-menu";
|
||||
menu.dataset.renderGraphMenu = "";
|
||||
menu.setAttribute("role", "menu");
|
||||
menu.setAttribute("aria-label", "Add render graph node");
|
||||
const search = document.createElement("label");
|
||||
search.className = "rg-menu-search";
|
||||
search.innerHTML = '<span aria-hidden="true">⌕</span>';
|
||||
const input = document.createElement("input");
|
||||
input.type = "search";
|
||||
input.placeholder = "Search nodes";
|
||||
input.setAttribute("aria-label", "Search render graph nodes");
|
||||
search.append(input);
|
||||
const groups = document.createElement("div");
|
||||
groups.className = "rg-menu-groups";
|
||||
const results = document.createElement("div");
|
||||
results.className = "rg-menu-results";
|
||||
results.hidden = true;
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "rg-menu-empty";
|
||||
empty.textContent = "No render graph nodes match.";
|
||||
empty.hidden = true;
|
||||
|
||||
const openGroup = (target) => {
|
||||
for (const group of groups.children)
|
||||
group.classList.toggle("open", group === target);
|
||||
};
|
||||
for (const group of renderGraphNodeGroups) {
|
||||
const host = document.createElement("div");
|
||||
host.className = "rg-menu-group";
|
||||
host.dataset.group = group.id;
|
||||
const trigger = document.createElement("button");
|
||||
trigger.type = "button";
|
||||
trigger.className = "rg-menu-group-trigger";
|
||||
trigger.setAttribute("aria-haspopup", "menu");
|
||||
trigger.innerHTML = `<span class="rg-menu-group-icon">${group.icon}</span><span>${group.title}<br><small>${group.items.length} nodes</small></span><span class="rg-menu-chevron">›</span>`;
|
||||
const submenu = document.createElement("div");
|
||||
submenu.className = "rg-menu-submenu";
|
||||
submenu.setAttribute("role", "menu");
|
||||
const heading = document.createElement("strong");
|
||||
heading.textContent = group.title;
|
||||
submenu.append(
|
||||
heading,
|
||||
...group.items.map((item) =>
|
||||
renderGraphOption(item, request, group.title),
|
||||
),
|
||||
);
|
||||
trigger.addEventListener("pointerenter", () => openGroup(host));
|
||||
trigger.addEventListener("click", () => openGroup(host));
|
||||
host.append(trigger, submenu);
|
||||
groups.append(host);
|
||||
}
|
||||
|
||||
input.addEventListener("input", () => {
|
||||
const query = input.value.trim().toLowerCase();
|
||||
groups.hidden = Boolean(query);
|
||||
results.hidden = !query;
|
||||
results.replaceChildren();
|
||||
if (query) {
|
||||
const matches = renderGraphNodeGroups.flatMap((group) =>
|
||||
group.items
|
||||
.map((item) => renderGraphOption(item, request, group.title))
|
||||
.filter((button) => button.dataset.search.includes(query)),
|
||||
);
|
||||
results.append(...matches);
|
||||
empty.hidden = matches.length > 0;
|
||||
} else {
|
||||
empty.hidden = true;
|
||||
}
|
||||
});
|
||||
input.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
closeRenderGraphMenu();
|
||||
elements["render-graph-canvas"].focus();
|
||||
}
|
||||
});
|
||||
menu.append(search, groups, results, empty);
|
||||
document.body.append(menu);
|
||||
const canvasBounds = elements["render-graph-canvas"].getBoundingClientRect();
|
||||
const menuBounds = menu.getBoundingClientRect();
|
||||
const margin = 8;
|
||||
const x = canvasBounds.left + request.viewPosition.x;
|
||||
const y = canvasBounds.top + request.viewPosition.y;
|
||||
const needsLeftSubmenu = x + menuBounds.width + 270 > innerWidth - margin;
|
||||
menu.classList.toggle("open-left", needsLeftSubmenu);
|
||||
menu.style.left = `${Math.max(margin, Math.min(x, innerWidth - menuBounds.width - margin))}px`;
|
||||
menu.style.top = `${Math.max(margin, Math.min(y, innerHeight - menuBounds.height - margin))}px`;
|
||||
menu.style.visibility = "visible";
|
||||
renderGraphMenu = menu;
|
||||
input.focus();
|
||||
renderGraphMenuOutsideListener = (event) => {
|
||||
if (!menu.contains(event.target)) closeRenderGraphMenu();
|
||||
};
|
||||
document.addEventListener(
|
||||
"pointerdown",
|
||||
renderGraphMenuOutsideListener,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
async function switchLoadout(id) {
|
||||
const next = allLoadouts().find((loadout) => loadout.id === id);
|
||||
if (!next || !renderGraphEditor) return;
|
||||
if (graphDirty) stageActiveLoadout(await renderGraphEditor.save());
|
||||
suppressGraphChanges = true;
|
||||
try {
|
||||
currentLoadoutID = next.id;
|
||||
await renderGraphEditor.load(loadoutLayout(next));
|
||||
graphDirty = false;
|
||||
elements["graph-tab-dirty"].classList.toggle("visible", loadoutsDirty);
|
||||
renderLoadoutOptions();
|
||||
setLoadoutStatus(`${next.name} selected · Apply to update scene.ts`);
|
||||
window.__yawnRenderGraph.selected = next.id;
|
||||
window.__yawnRenderGraph.layout = loadoutLayout(next);
|
||||
} finally {
|
||||
suppressGraphChanges = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function applyLoadout() {
|
||||
if (!renderGraphEditor || elements["apply-loadout-button"].disabled) return;
|
||||
elements["apply-loadout-button"].disabled = true;
|
||||
try {
|
||||
const layout = await renderGraphEditor.save();
|
||||
const staged = stageActiveLoadout(layout);
|
||||
if (model.getValue() !== lastRunSource && !(await run()))
|
||||
throw new Error(
|
||||
"The Scene must run successfully before applying a loadout",
|
||||
);
|
||||
setLoadoutStatus(`Compiling ${staged.name}…`, "busy");
|
||||
compiledRenderGraph = await compileLoadout(layout);
|
||||
const source = withAppendedRenderGraph(
|
||||
model.getValue(),
|
||||
staged.name,
|
||||
compiledRenderGraph,
|
||||
);
|
||||
if (source !== model.getValue()) model.setValue(source);
|
||||
window.__yawnRenderGraph.selected = staged.id;
|
||||
window.__yawnRenderGraph.layout = structuredClone(layout);
|
||||
window.__yawnRenderGraph.serialized = compiledRenderGraph;
|
||||
if (!(await run()))
|
||||
throw new Error("The generated render graph loadout could not run");
|
||||
setLoadoutStatus(`${staged.name} applied from scene.ts`);
|
||||
showToast(`Appended ${staged.name} to scene.ts and reran the playground`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setLoadoutStatus(message, "failed");
|
||||
showToast(message);
|
||||
} finally {
|
||||
elements["apply-loadout-button"].disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyLoadoutCode() {
|
||||
const button = elements["copy-loadout-code-button"];
|
||||
if (!renderGraphEditor || button.disabled) return;
|
||||
button.disabled = true;
|
||||
try {
|
||||
const layout = await renderGraphEditor.save();
|
||||
compiledRenderGraph = await compileLoadout(layout);
|
||||
const name = elements["loadout-name"].value.trim() || activeLoadout().name;
|
||||
const code = generatedRenderGraphBlock(name, compiledRenderGraph);
|
||||
await navigator.clipboard.writeText(code);
|
||||
window.__yawnRenderGraph.serialized = compiledRenderGraph;
|
||||
showToast(`${activeLoadout().name} loadout code copied`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setLoadoutStatus(message, "failed");
|
||||
showToast(message);
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function initializeRenderGraphWorkspace() {
|
||||
renderLoadoutOptions();
|
||||
renderGraphEditor = await createRenderGraphEditor({
|
||||
canvas: elements["render-graph-canvas"],
|
||||
initialLayout: loadoutLayout(),
|
||||
workerUrl: "/pkg/fxnode-worker.js",
|
||||
onChange: markGraphChanged,
|
||||
onAddNodeMenu: openRenderGraphMenu,
|
||||
onError(error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setLoadoutStatus(message, "failed");
|
||||
},
|
||||
});
|
||||
window.__yawnRenderGraph = {
|
||||
editor: renderGraphEditor,
|
||||
layout: loadoutLayout(),
|
||||
presets: renderGraphPresets.map(({ id, name }) => ({ id, name })),
|
||||
selected: currentLoadoutID,
|
||||
serialized: compiledRenderGraph,
|
||||
switchLoadout,
|
||||
};
|
||||
setLoadoutStatus(`${activeLoadout().name} selected`);
|
||||
}
|
||||
|
||||
function activateCodingTab(tab) {
|
||||
activeCodingTab = tab;
|
||||
closeRenderGraphMenu();
|
||||
for (const button of document.querySelectorAll("[data-editor-tab]")) {
|
||||
const active = button.dataset.editorTab === tab;
|
||||
button.classList.toggle("active", active);
|
||||
button.setAttribute("aria-selected", String(active));
|
||||
}
|
||||
elements.editor.hidden = tab !== "scene";
|
||||
elements["render-graph-editor"].hidden = tab !== "render-graph";
|
||||
for (const target of document.querySelectorAll("[data-code-tool]"))
|
||||
target.hidden = tab !== "scene";
|
||||
for (const target of document.querySelectorAll("[data-graph-tool]"))
|
||||
target.hidden = tab !== "render-graph";
|
||||
if (tab === "render-graph") {
|
||||
elements["workspace-language"].textContent = "Render graph";
|
||||
elements["type-status"].textContent = "FxNode worker ready";
|
||||
elements["cursor-status"].textContent = "Node canvas";
|
||||
elements["editor-mode-status"].textContent = "FxNode";
|
||||
elements["encoding-status"].textContent = "S-expression v1";
|
||||
elements["editor-diagnostics"].lastChild.textContent =
|
||||
" Graph valid on save";
|
||||
elements["editor-diagnostics"].classList.remove("has-errors");
|
||||
requestAnimationFrame(() => void renderGraphEditor?.syncViewport());
|
||||
} else {
|
||||
elements["workspace-language"].textContent = "TypeScript";
|
||||
const position = editor?.getPosition();
|
||||
elements["cursor-status"].textContent = position
|
||||
? `Ln ${position.lineNumber}, Col ${position.column}`
|
||||
: "Ln 1, Col 1";
|
||||
elements["editor-mode-status"].textContent = "Spaces: 2";
|
||||
elements["encoding-status"].textContent = "UTF-8";
|
||||
updateDiagnostics();
|
||||
editor?.layout();
|
||||
editor?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
async function transpile() {
|
||||
@@ -414,10 +873,11 @@ async function transpile() {
|
||||
const output = emitted.outputFiles.find((file) => file.name.endsWith(".js"));
|
||||
if (emitted.emitSkipped || !output)
|
||||
throw new Error("TypeScript could not emit this scene.");
|
||||
return output.text
|
||||
const code = output.text
|
||||
.replace(/^\s*import\s+(?:[\s\S]*?\s+from\s+)?["'][^"']+["'];?\s*$/gm, "")
|
||||
.replace(/^\s*export\s*\{\s*\};?\s*$/gm, "")
|
||||
.replace(/\bexport\s+default\s+/, "return ");
|
||||
.replace(/\bexport\s+default\s+/, "__yawnPlaygroundDefaultExport = ");
|
||||
return `let __yawnPlaygroundDefaultExport;\n${code}\nreturn __yawnPlaygroundDefaultExport;`;
|
||||
}
|
||||
|
||||
async function disposeCurrent() {
|
||||
@@ -459,7 +919,7 @@ function replaceCanvas() {
|
||||
}
|
||||
|
||||
async function run() {
|
||||
if (!editor || elements["run-button"].disabled) return;
|
||||
if (!editor || elements["run-button"].disabled) return false;
|
||||
clearPlaygroundTimers();
|
||||
const runID = ++generation;
|
||||
elements["run-button"].disabled = true;
|
||||
@@ -500,16 +960,18 @@ async function run() {
|
||||
);
|
||||
if (runID !== generation) {
|
||||
if (result?.dispose) await result.dispose();
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
current = result;
|
||||
window.__yawnPlayground = result;
|
||||
lastRunSource = model.getValue();
|
||||
elements["preview-message"].hidden = true;
|
||||
setRunStatus(
|
||||
`Running · ${Math.round(performance.now() - started)} ms`,
|
||||
"ready",
|
||||
);
|
||||
if (activePanel === "profile") await attachProfiler();
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (runID === generation) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
@@ -518,6 +980,7 @@ async function run() {
|
||||
showPreviewMessage("Scene could not start", message);
|
||||
openInspector("console");
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
if (runID === generation) {
|
||||
elements["run-button"].disabled = false;
|
||||
@@ -641,6 +1104,10 @@ async function save() {
|
||||
if (!editor || elements["save-button"].disabled) return;
|
||||
elements["save-button"].disabled = true;
|
||||
try {
|
||||
const hadLoadoutChanges = loadoutsDirty;
|
||||
let stagedLoadout;
|
||||
if (graphDirty && renderGraphEditor)
|
||||
stagedLoadout = stageActiveLoadout(await renderGraphEditor.save());
|
||||
const response = await fetch("/api/playgrounds", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -648,6 +1115,7 @@ async function save() {
|
||||
id: currentID,
|
||||
title: elements["project-title"].value.trim() || "Untitled playground",
|
||||
code: model.getValue(),
|
||||
loadouts: customLoadouts,
|
||||
}),
|
||||
});
|
||||
const result = await response.json();
|
||||
@@ -656,11 +1124,15 @@ async function save() {
|
||||
currentID = result.id;
|
||||
currentRevision = result.revision;
|
||||
initialSource = result.code;
|
||||
customLoadouts = validCustomLoadouts(result.loadouts);
|
||||
renderLoadoutOptions();
|
||||
history.pushState(null, "", `/playground/${result.id}/${result.revision}`);
|
||||
elements["revision-label"].textContent = `Revision ${result.revision}`;
|
||||
elements["share-button"].hidden = false;
|
||||
localStorage.removeItem("yawn:playground:draft");
|
||||
markDirty(false);
|
||||
if (stagedLoadout) setLoadoutStatus("Saved · Apply to update scene.ts");
|
||||
else if (hadLoadoutChanges)
|
||||
setLoadoutStatus("Applied code · saved with playground");
|
||||
showToast(`Saved revision ${result.revision}`);
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : String(error));
|
||||
@@ -718,6 +1190,23 @@ function sampleFPS(now = performance.now()) {
|
||||
|
||||
elements["run-button"].addEventListener("click", run);
|
||||
elements["save-button"].addEventListener("click", save);
|
||||
elements["apply-loadout-button"].addEventListener("click", applyLoadout);
|
||||
elements["copy-loadout-code-button"].addEventListener("click", copyLoadoutCode);
|
||||
elements["loadout-name"].addEventListener("input", markGraphChanged);
|
||||
elements["loadout-select"].addEventListener(
|
||||
"change",
|
||||
(event) =>
|
||||
void switchLoadout(event.target.value).catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
setLoadoutStatus(message, "failed");
|
||||
showToast(message);
|
||||
}),
|
||||
);
|
||||
for (const button of document.querySelectorAll("[data-editor-tab]")) {
|
||||
button.addEventListener("click", () =>
|
||||
activateCodingTab(button.dataset.editorTab),
|
||||
);
|
||||
}
|
||||
elements["share-button"].addEventListener("click", async () => {
|
||||
await navigator.clipboard.writeText(location.href);
|
||||
showToast("Playground URL copied");
|
||||
@@ -786,14 +1275,23 @@ elements["resize-handle"].addEventListener("pointerup", (event) => {
|
||||
elements["resize-handle"].classList.remove("dragging");
|
||||
});
|
||||
addEventListener("popstate", () => location.reload());
|
||||
addEventListener("beforeunload", () => {
|
||||
addEventListener("beforeunload", (event) => {
|
||||
if (!dirty) return;
|
||||
event.preventDefault();
|
||||
event.returnValue = "";
|
||||
});
|
||||
addEventListener("pagehide", (event) => {
|
||||
if (event.persisted) return;
|
||||
cancelAnimationFrame(frameRequest);
|
||||
closeRenderGraphMenu();
|
||||
void renderGraphEditor?.destroy();
|
||||
void disposeCurrent();
|
||||
});
|
||||
|
||||
try {
|
||||
const source = await loadInitialPlayground();
|
||||
await createEditor(source);
|
||||
await initializeRenderGraphWorkspace();
|
||||
markDirty(false);
|
||||
sampleFPS();
|
||||
await run();
|
||||
|
||||
Vendored
+27
-15
@@ -69,6 +69,7 @@ declare module "@yawn/handles" {
|
||||
|
||||
export interface SceneOptions {
|
||||
arenaBytes?: number;
|
||||
clearColor?: ArrayLike<number>;
|
||||
debug?: boolean;
|
||||
fps?: number;
|
||||
hdr?: boolean;
|
||||
@@ -78,6 +79,7 @@ declare module "@yawn/handles" {
|
||||
readonly core: YawnCore;
|
||||
readonly ready: Promise<this>;
|
||||
readonly hdr: boolean;
|
||||
readonly clearColor: readonly [number, number, number, number];
|
||||
array(name: string): SharedRows;
|
||||
ensureRows(
|
||||
name: string,
|
||||
@@ -88,6 +90,9 @@ declare module "@yawn/handles" {
|
||||
reserve(additional: { nodes?: number; materials?: number }): Promise<void>;
|
||||
batchWrites<T>(operation: () => T): T;
|
||||
batchGraphUpdates<T>(operation: () => T | Promise<T>): Promise<T>;
|
||||
compileRenderGraph(serialized: string): Promise<string>;
|
||||
setLoadout(id: string): Promise<void>;
|
||||
getRenderGraph(): Promise<object>;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
@@ -140,12 +145,7 @@ declare module "@yawn/handles" {
|
||||
}
|
||||
|
||||
export type VertexKind =
|
||||
| "positions"
|
||||
| "normals"
|
||||
| "tangents"
|
||||
| "uvs"
|
||||
| "colors"
|
||||
| "indices";
|
||||
"positions" | "normals" | "tangents" | "uvs" | "colors" | "indices";
|
||||
export interface MeshOptions extends NodeOptions {
|
||||
geometryId?: number;
|
||||
material?: PBRMaterial;
|
||||
@@ -167,8 +167,10 @@ declare module "@yawn/handles" {
|
||||
): Promise<this>;
|
||||
}
|
||||
|
||||
export interface CubeOptions
|
||||
extends Omit<MeshOptions, "geometryId" | "vertexData"> {
|
||||
export interface CubeOptions extends Omit<
|
||||
MeshOptions,
|
||||
"geometryId" | "vertexData"
|
||||
> {
|
||||
size?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
@@ -177,16 +179,20 @@ declare module "@yawn/handles" {
|
||||
export class Cube extends Mesh {
|
||||
constructor(scene: Scene, options?: CubeOptions);
|
||||
}
|
||||
export interface IcosphereOptions
|
||||
extends Omit<MeshOptions, "geometryId" | "vertexData"> {
|
||||
export interface IcosphereOptions extends Omit<
|
||||
MeshOptions,
|
||||
"geometryId" | "vertexData"
|
||||
> {
|
||||
radius?: number;
|
||||
subdivisions?: number;
|
||||
}
|
||||
export class Icosphere extends Mesh {
|
||||
constructor(scene: Scene, options?: IcosphereOptions);
|
||||
}
|
||||
export interface PlaneOptions
|
||||
extends Omit<MeshOptions, "geometryId" | "vertexData"> {
|
||||
export interface PlaneOptions extends Omit<
|
||||
MeshOptions,
|
||||
"geometryId" | "vertexData"
|
||||
> {
|
||||
size?: number;
|
||||
width?: number;
|
||||
depth?: number;
|
||||
@@ -194,8 +200,10 @@ declare module "@yawn/handles" {
|
||||
export class Plane extends Mesh {
|
||||
constructor(scene: Scene, options?: PlaneOptions);
|
||||
}
|
||||
export interface SphereOptions
|
||||
extends Omit<MeshOptions, "geometryId" | "vertexData"> {
|
||||
export interface SphereOptions extends Omit<
|
||||
MeshOptions,
|
||||
"geometryId" | "vertexData"
|
||||
> {
|
||||
radius?: number;
|
||||
segments?: number;
|
||||
rings?: number;
|
||||
@@ -267,9 +275,13 @@ declare module "@yawn/handles" {
|
||||
intensity?: number;
|
||||
}
|
||||
export class PointLight extends Node {
|
||||
constructor(scene: Scene, options?: LightOptions & { range?: number });
|
||||
constructor(
|
||||
scene: Scene,
|
||||
options?: LightOptions & { range?: number; radius?: number },
|
||||
);
|
||||
intensity: number;
|
||||
range: number;
|
||||
radius: number;
|
||||
}
|
||||
export class AmbientLight extends Node {
|
||||
constructor(scene: Scene, options?: LightOptions);
|
||||
|
||||
+96
-11
@@ -16,7 +16,8 @@
|
||||
{
|
||||
"imports": {
|
||||
"@yawn/core": "/pkg/core.js",
|
||||
"@yawn/handles": "/pkg/handles.js"
|
||||
"@yawn/handles": "/pkg/handles.js",
|
||||
"@yawn/fxnode-rg": "/pkg/fxnode-rg.js"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -64,19 +65,22 @@
|
||||
|
||||
<div class="playground-tools">
|
||||
<div class="tool-group">
|
||||
<button id="snippets-button" type="button">
|
||||
<button id="snippets-button" type="button" data-code-tool>
|
||||
<span class="braces">{ }</span> Snippets
|
||||
</button>
|
||||
<button id="reset-button" type="button">
|
||||
<button id="reset-button" type="button" data-code-tool>
|
||||
<span aria-hidden="true">↶</span> Reset
|
||||
</button>
|
||||
<button id="format-button" type="button">
|
||||
<button id="format-button" type="button" data-code-tool>
|
||||
<span aria-hidden="true">≡</span> Format
|
||||
</button>
|
||||
<span class="graph-tool-hint" data-graph-tool hidden
|
||||
><b>Right click</b> the graph to add a node</span
|
||||
>
|
||||
</div>
|
||||
<div class="tool-context">
|
||||
<span class="language-dot"></span>
|
||||
<span>TypeScript</span>
|
||||
<span id="workspace-language">TypeScript</span>
|
||||
<span class="context-divider"></span>
|
||||
<span id="type-status">Starting language service…</span>
|
||||
</div>
|
||||
@@ -95,19 +99,100 @@
|
||||
</div>
|
||||
|
||||
<div class="workbench" id="workbench">
|
||||
<section class="editor-pane" aria-label="TypeScript editor">
|
||||
<div class="pane-tabs">
|
||||
<button class="pane-tab active" type="button">
|
||||
<section class="editor-pane" aria-label="Coding workspace">
|
||||
<div class="pane-tabs" role="tablist" aria-label="Coding workspace">
|
||||
<button
|
||||
class="pane-tab active"
|
||||
id="scene-tab"
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected="true"
|
||||
aria-controls="editor"
|
||||
data-editor-tab="scene"
|
||||
>
|
||||
<span class="ts-icon">TS</span> scene.ts <i id="tab-dirty"></i>
|
||||
</button>
|
||||
<button
|
||||
class="pane-tab"
|
||||
id="render-graph-tab"
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected="false"
|
||||
aria-controls="render-graph-editor"
|
||||
data-editor-tab="render-graph"
|
||||
>
|
||||
<span class="rg-tab-icon" aria-hidden="true">◇</span>
|
||||
render graph
|
||||
<i id="graph-tab-dirty"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="editor" class="editor">
|
||||
<div
|
||||
id="editor"
|
||||
class="editor coding-panel active"
|
||||
role="tabpanel"
|
||||
aria-labelledby="scene-tab"
|
||||
>
|
||||
<div class="editor-loading">Loading TypeScript editor…</div>
|
||||
</div>
|
||||
<section
|
||||
id="render-graph-editor"
|
||||
class="render-graph-editor coding-panel"
|
||||
role="tabpanel"
|
||||
aria-labelledby="render-graph-tab"
|
||||
hidden
|
||||
>
|
||||
<div class="render-graph-toolbar">
|
||||
<label>
|
||||
<span>Loadout</span>
|
||||
<select
|
||||
id="loadout-select"
|
||||
aria-label="Render graph loadout"
|
||||
></select>
|
||||
</label>
|
||||
<label class="loadout-name-field">
|
||||
<span>Name</span>
|
||||
<input
|
||||
id="loadout-name"
|
||||
maxlength="64"
|
||||
placeholder="My loadout"
|
||||
aria-label="Loadout name"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
id="apply-loadout-button"
|
||||
type="button"
|
||||
title="Apply in memory; use the playground Save button to keep it"
|
||||
>
|
||||
<span aria-hidden="true">◇</span> Apply
|
||||
</button>
|
||||
<button
|
||||
id="copy-loadout-code-button"
|
||||
class="secondary"
|
||||
type="button"
|
||||
title="Copy this loadout's compiled S-expression as Scene code"
|
||||
>
|
||||
<span aria-hidden="true">⧉</span> Copy code
|
||||
</button>
|
||||
<output id="loadout-status" aria-live="polite"
|
||||
><i></i> Standard active</output
|
||||
>
|
||||
</div>
|
||||
<div class="render-graph-canvas-wrap">
|
||||
<canvas
|
||||
id="render-graph-canvas"
|
||||
aria-label="Render graph node editor"
|
||||
></canvas>
|
||||
<div class="render-graph-empty-hint" id="render-graph-hint">
|
||||
<span>+</span>
|
||||
<strong>Add another pass</strong>
|
||||
<small>Right click anywhere to browse the node catalog</small>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<footer class="editor-status">
|
||||
<span id="cursor-status">Ln 1, Col 1</span>
|
||||
<span>Spaces: 2</span>
|
||||
<span>UTF-8</span>
|
||||
<span id="editor-mode-status">Spaces: 2</span>
|
||||
<span id="encoding-status">UTF-8</span>
|
||||
<span class="status-spacer"></span>
|
||||
<span id="editor-diagnostics"><i></i> No problems</span>
|
||||
</footer>
|
||||
|
||||
Reference in New Issue
Block a user