The NME's Save button and material.serialize() write the same JSON: the material's settings plus a blocks array in which each block records its class, properties and, per input, the block feeding it. NodeMaterial.Parse(json, scene) rebuilds and compiles the graph. The engine is not saved, so one file serves WebGL and WebGPU:
const json = highlight.serialize(); // what the NME's Save writes
console.log(`${json.blocks.length} blocks, ${JSON.stringify(json).length} bytes`);
const copy = BABYLON.NodeMaterial.Parse(json, scene); // rebuilds and compiles
copy.getBlockByName('strength').value = 2; // tweak the copy only
console.log(`copy ${copy.getBlockByName('strength').value}, ` +
`original ${highlight.getBlockByName('strength').value}`);Output
24 blocks, 13640 bytes copy 2, original 1
Textures are saved by URL (the cover block stores "url":"booknest-cover-2.png"), so the images must travel with the JSON; NodeMaterial.IgnoreTexturesAtLoadTime = true skips them when you assign your own. generateCode() goes the other way, emitting JavaScript that recreates the graph (6,910 characters for this one).
<!doctype html>
<style>
body { margin: 0; background: #f7f3ec; font: 13px system-ui, sans-serif; color: #333; }
canvas { width: 100%; max-width: 600px; height: 280px; display: block; touch-action: none; }
#json { max-width: 600px; height: 150px; overflow: auto; margin: 0; padding: 4px 8px; box-sizing: border-box;
font: 11px monospace; white-space: pre-wrap; background: #fff; }
</style>
<canvas id="view"></canvas>
<pre id="json"></pre>
<script src="https://cdn.jsdelivr.net/npm/babylonjs@9.28.0/babylon.js"></script>
<script src="https://cdn.jsdelivr.net/npm/babylonjs-gui@9.28.0/babylon.gui.min.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('#dfe3e8ff');
const camera = new BABYLON.ArcRotateCamera('cam', -Math.PI / 2, 1.25, 3.6, BABYLON.Vector3.Zero(), scene);
camera.attachControl(canvas, true);
const ui = BABYLON.GUI.AdvancedDynamicTexture.CreateFullscreenUI('ui');
// A small graph: base color + rimColor * (1 - fresnel) * strength
function createRim() {
const material = new BABYLON.NodeMaterial('highlight', scene);
const input = (name, setup) => { const node = new BABYLON.InputBlock(name); setup(node); return node.output; };
const position = input('position', (n) => n.setAsAttribute('position'));
const normal = input('normal', (n) => n.setAsAttribute('normal'));
const world = input('world', (n) => n.setAsSystemValue(BABYLON.NodeMaterialSystemValues.World));
const viewProj = input('viewProjection', (n) => n.setAsSystemValue(BABYLON.NodeMaterialSystemValues.ViewProjection));
const eye = input('cameraPosition', (n) => n.setAsSystemValue(BABYLON.NodeMaterialSystemValues.CameraPosition));
const wire = (block, inputs) => { for (const [port, src] of Object.entries(inputs)) src.connectTo(block[port]); return block; };
const worldPos = wire(new BABYLON.TransformBlock('worldPos'), { vector: position, transform: world });
const clipPos = wire(new BABYLON.TransformBlock('clipPos'), { vector: worldPos.output, transform: viewProj });
const vertexOut = wire(new BABYLON.VertexOutputBlock('vertexOut'), { vector: clipPos.output });
const worldNormal = wire(new BABYLON.TransformBlock('worldNormal'), { vector: normal, transform: world });
worldNormal.complementW = 0;
const toEye = wire(new BABYLON.ViewDirectionBlock('toEye'), { worldPosition: worldPos.output, cameraPosition: eye });
const fresnel = wire(new BABYLON.FresnelBlock('fresnel'), { worldNormal: worldNormal.output,
viewDirection: toEye.output, bias: input('bias', (n) => { n.value = 0; }), power: input('power', (n) => { n.value = 1; }) });
const edge = wire(new BABYLON.OneMinusBlock('edge'), { input: fresnel.fresnel });
const amount = wire(new BABYLON.MultiplyBlock('amount'), { left: edge.output, right: input('strength', (n) => { n.value = 1; }) });
const rim = wire(new BABYLON.ScaleBlock('rim'), { factor: amount.output,
input: input('rimColor', (n) => { n.value = BABYLON.Color3.FromHexString('#ffe082'); }) });
const sum = wire(new BABYLON.AddBlock('sum'), { left: input('base', (n) => { n.value = BABYLON.Color3.FromHexString('#1f5f8b'); }),
right: rim.output });
const fragmentOut = wire(new BABYLON.FragmentOutputBlock('fragmentOut'), { rgb: sum.output });
material.addOutputNode(vertexOut);
material.addOutputNode(fragmentOut);
material.build();
return material;
}
const highlight = createRim();
const json = highlight.serialize(); // what the NME's Save writes
const copy = BABYLON.NodeMaterial.Parse(json, scene); // rebuilds and compiles
copy.getBlockByName('strength').value = 2; // tweak the copy only
[[highlight, 'original, strength 1'], [copy, 'parsed copy, strength 2']].forEach(([material, text], i) => {
const ball = BABYLON.MeshBuilder.CreateSphere('ball' + i, { diameter: 1.3, segments: 32 }, scene);
ball.position.x = i ? 0.85 : -0.85;
ball.material = material;
const label = new BABYLON.GUI.TextBlock('', text);
Object.assign(label, { color: '#333', fontSize: 13, resizeToFit: true });
ui.addControl(label);
label.linkWithMesh(ball);
label.linkOffsetY = 95;
});
const text = JSON.stringify(json);
document.getElementById('json').textContent =
`${json.blocks.length} blocks, ${text.length} bytes of JSON; ` +
`copy strength ${copy.getBlockByName('strength').value}, original ${highlight.getBlockByName('strength').value}\n\n` +
JSON.stringify(json.blocks.find((b) => b.name === 'fresnel'), null, 1);
engine.runRenderLoop(() => scene.render());
addEventListener('resize', () => engine.resize());
</script>