Every block extends NodeMaterialBlock and exposes typed connection points: TransformBlock has vector and transform inputs and an output; FresnelBlock has worldNormal, viewDirection, bias and power. a.output.connectTo(b.input) wires two ports and throws on a type mismatch. An InputBlock feeds the graph from a mesh attribute (position, normal, uv), a system value the engine updates (World, ViewProjection, CameraPosition, DeltaTime), or a uniform you set through value; flag it isConstant and it is inlined. Two outputs end every material graph: VertexOutputBlock (the clip-space position) and FragmentOutputBlock (the color). setToDefault() builds the smallest one, eight blocks in 9.28.0: position, World, ViewProjection and color inputs, two TransformBlocks and the two outputs. build() walks back from the outputs, so unconnected blocks cost nothing, and neutral blocks move to the vertex shader when their inputs allow.
<!doctype html>
<style>
body { margin: 0; background: #f7f3ec; font: 13px system-ui, sans-serif; color: #333; }
#view { width: 100%; max-width: 600px; height: 190px; display: block; touch-action: none; }
#graph { width: 100%; max-width: 600px; height: 250px; display: block; background: #2b2f36; }
#info { max-width: 600px; margin: 0; padding: 4px 8px; font-family: monospace; white-space: pre-wrap; }
</style>
<canvas id="view"></canvas>
<canvas id="graph" width="600" height="250"></canvas>
<pre id="info"></pre>
<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);
const material = new BABYLON.NodeMaterial('handBuilt', scene);
// InputBlocks feed the graph: a mesh attribute, system values the engine updates, and a uniform
const position = new BABYLON.InputBlock('position');
position.setAsAttribute('position');
const world = new BABYLON.InputBlock('World');
world.setAsSystemValue(BABYLON.NodeMaterialSystemValues.World);
const viewProjection = new BABYLON.InputBlock('ViewProjection');
viewProjection.setAsSystemValue(BABYLON.NodeMaterialSystemValues.ViewProjection);
const color = new BABYLON.InputBlock('color');
color.value = BABYLON.Color4.FromHexString('#1f5f8bff'); // a uniform: change .value at run time
// Two TransformBlocks, each with a vector input, a transform input and an output
const worldPos = new BABYLON.TransformBlock('worldPos');
const clipPos = new BABYLON.TransformBlock('clipPos');
const vertexOut = new BABYLON.VertexOutputBlock('vertexOut'); // clip-space position
const fragmentOut = new BABYLON.FragmentOutputBlock('fragmentOut'); // the color
// a.output.connectTo(b.input) wires two typed ports
position.output.connectTo(worldPos.vector);
world.output.connectTo(worldPos.transform);
worldPos.output.connectTo(clipPos.vector);
viewProjection.output.connectTo(clipPos.transform);
clipPos.output.connectTo(vertexOut.vector);
color.output.connectTo(fragmentOut.rgba);
let mismatch = '';
try {
world.output.connectTo(fragmentOut.rgb); // a Matrix into a Color3 port
} catch (error) {
mismatch = error.message;
}
material.addOutputNode(vertexOut);
material.addOutputNode(fragmentOut);
material.build(); // walks back from the two outputs
const book = BABYLON.MeshBuilder.CreateBox('book', { width: 1, height: 1.4, depth: 0.25 }, scene);
book.material = material;
let t = 0;
scene.onBeforeRenderObservable.add(() => {
t += engine.getDeltaTime() / 1000;
book.rotation.y = t * 0.5;
const hue = (t * 40) % 360; // update the uniform every frame
color.value = BABYLON.Color3.FromHSV(hue, 0.6, 0.75).toColor4(1);
});
document.getElementById('info').textContent =
`${material.attachedBlocks.length} blocks. Wrong type throws: "${mismatch}"`;
// 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(material, document.getElementById('graph').getContext('2d'));
engine.runRenderLoop(() => scene.render());
addEventListener('resize', () => engine.resize());
</script>