The NME, introduced in Babylon.js 4.1 89,603 , runs three ways: standalone at nme.babylonjs.com, saving to Babylon's snippet server; from the Inspector (Inspector and Tools), whose node-material pane has an Edit button; and from your page, where material.edit() downloads the editor script for your exact version (https://cdn.babylonjs.com/v9.28.0/nodeEditor/babylon.nodeEditor.js 89,603 ) and opens it in a pop-up bound to the live material, so every change shows in your scene at once:
editButton.addEventListener('click', () => { // pop-ups need a user gesture
highlight.edit({ nodeEditorConfig: { backgroundColor: new BABYLON.Color4(1, 1, 1, 1) } });
});The editor shows blocks as boxes with input ports on the left and outputs on the right, beside a preview mesh and a property pane. Ship the material as JSON (Saving Node Materials) or code, and keep edit(), which downloads about 2 MB of editor script, out of production.
<!doctype html>
<style>
body { margin: 0; background: #f7f3ec; font: 13px system-ui, sans-serif; color: #333; }
#view { width: 100%; max-width: 600px; height: 200px; display: block; touch-action: none; }
#graph { width: 100%; max-width: 600px; height: 250px; display: block; background: #2b2f36; }
#bar { max-width: 600px; display: flex; gap: 10px; align-items: center; padding: 6px 8px; }
button { font: inherit; padding: 4px 12px; }
</style>
<canvas id="view"></canvas>
<canvas id="graph" width="600" height="250"></canvas>
<div id="bar"><button id="edit">Edit in the Node Material Editor</button><span id="note"></span></div>
<script src="https://cdn.jsdelivr.net/npm/babylonjs@9.28.0/babylon.js"></script>
<script>
const canvas = document.getElementById('view');
const engine = new BABYLON.Engine(canvas, true);
const scene = new BABYLON.Scene(engine);
scene.clearColor = BABYLON.Color4.FromHexString('#f7f3ecff');
const camera = new BABYLON.ArcRotateCamera('cam', -Math.PI / 2 - 0.5, 1.2, 3, BABYLON.Vector3.Zero(), scene);
camera.attachControl(canvas, true);
// The smallest graph: position -> World -> ViewProjection -> vertex output; a color -> fragment output
const highlight = new BABYLON.NodeMaterial('highlight', scene);
highlight.setToDefault();
highlight.build(); // build() also registers the attached blocks
highlight.getBlockByName('color').value = BABYLON.Color4.FromHexString('#e09a10ff'); // a uniform
const book = BABYLON.MeshBuilder.CreateBox('book', { width: 1, height: 1.4, depth: 0.25 }, scene);
book.material = highlight;
scene.onBeforeRenderObservable.add(() => { book.rotation.y += engine.getDeltaTime() / 2000; });
// edit() downloads the editor script for this exact version and opens it in a pop-up
document.getElementById('edit').addEventListener('click', () => { // pop-ups need a user gesture
if (window.top !== window) { // this preview is a sandboxed frame
document.getElementById('note').textContent = 'Pop-ups are blocked here; run the page on its own.';
return;
}
highlight.edit({ nodeEditorConfig: { backgroundColor: new BABYLON.Color4(1, 1, 1, 1) } });
});
// Draw the graph the way the editor shows it: blocks as boxes, inputs left, outputs right
function drawGraph(material, ctx) {
const depth = new Map();
const visit = (block, d) => {
if ((depth.get(block) ?? -1) >= d) return;
depth.set(block, d);
for (const input of block.inputs) if (input.connectedPoint) visit(input.connectedPoint.ownerBlock, d + 1);
};
material.attachedBlocks.filter((b) => b.outputs.every((o) => !o.hasEndpoints)).forEach((b) => visit(b, 0));
const maxDepth = Math.max(...depth.values());
const rows = new Map(), boxes = new Map();
for (const [block, d] of depth) {
const row = rows.get(d) || 0;
rows.set(d, row + 1);
const ports = block.inputs.filter((p) => p.isConnected).concat(block.outputs.filter((p) => p.hasEndpoints));
const x = 14 + (maxDepth - d) * (560 / (maxDepth + 1)), y = 16 + row * 115;
boxes.set(block, { x, y, w: 124, h: 26 + ports.length * 14 });
}
const portY = (block, point) => {
const box = boxes.get(block);
const ports = block.inputs.filter((p) => p.isConnected).concat(block.outputs.filter((p) => p.hasEndpoints));
return box.y + 32 + ports.indexOf(point) * 14;
};
ctx.lineWidth = 2;
for (const block of boxes.keys()) for (const input of block.inputs) {
const from = input.connectedPoint;
if (!from) continue;
const a = boxes.get(from.ownerBlock), b = boxes.get(block);
const [x1, y1, x2, y2] = [a.x + a.w, portY(from.ownerBlock, from), b.x, portY(block, input)];
ctx.strokeStyle = '#8fb3d9';
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.bezierCurveTo(x1 + 40, y1, x2 - 40, y2, x2, y2); ctx.stroke();
}
ctx.font = '11px sans-serif';
for (const [block, { x, y, w, h }] of boxes) {
ctx.fillStyle = '#454b55'; ctx.fillRect(x, y, w, h);
ctx.fillStyle = block.isInput ? '#7a5c2e' : '#35577a'; ctx.fillRect(x, y, w, 20);
let title = block.name; // shorten names that overflow the box
while (ctx.measureText(title).width > w - 12) title = title.slice(0, -2) + '…';
ctx.fillStyle = '#fff'; ctx.fillText(title, x + 6, y + 14);
const ports = block.inputs.filter((p) => p.isConnected).concat(block.outputs.filter((p) => p.hasEndpoints));
ports.forEach((p) => {
const isInput = block.inputs.includes(p), py = portY(block, p);
ctx.fillStyle = '#ddd';
ctx.textAlign = isInput ? 'left' : 'right';
ctx.fillText(p.name, isInput ? x + 8 : x + w - 8, py + 4);
ctx.beginPath(); ctx.arc(isInput ? x : x + w, py, 3.5, 0, Math.PI * 2); ctx.fill();
ctx.textAlign = 'left';
});
}
}
drawGraph(highlight, document.getElementById('graph').getContext('2d'));
engine.runRenderLoop(() => scene.render());
addEventListener('resize', () => engine.resize());
</script>