API

Node Geometry

What Is Node Geometry

The NodeGeometry feature was inspired by Blender Geometry Nodes, where you can use a node system to build geometry procedurally. Think of it as a post-processing system for geometry. Ultimately, it produces geometry through a procedural approach.

Why Use Node Geometry?

Several reasons come to mind:

  • The cost of downloading assets on the web is a strong limiting factor. Procedural data can help solve that by limiting the download to the core pieces, which are assembled later by the NodeGeometry system.
  • The system is dynamic and can produce infinite variants at runtime. While many digital content creation tools can create procedural meshes with the same endless possibilities, the limiting factor again becomes downloading assets created offline. Examples include terrain or vegetation generation.
  • It allows a new way of modeling in Babylon.js by assembling core shapes and experimenting with a node system.

Example

Here is a complete example of an advanced NodeGeometry which is used to generate a city with different buildings:

Using NodeGeometry to generate a procedural city

How to Use

The NodeGeometry class is a utility class, meaning it is autonomous and does not require access to an engine or a scene. So instantiation is straightforward:

import { NodeGeometry } from "@babylonjs/core/Meshes/Node/nodeGeometry";
const nodeGeometry = new NodeGeometry("my node geometry");

Once created, the system expects you to create a flow from a source to an endpoint. In a nutshell, NodeGeometry processes a VertexData structure (see more in the section Create Custom Meshes From Scratch).

By default, the NodeGeometry system supports the following sources:

  • Box
  • Capsule
  • Cylinder
  • Disc
  • Grid
  • Icosphere
  • Mesh
  • Plane
  • Sphere
  • Torus

Here is for example a complete graph that simply generates a sphere:

import { GeometryOutputBlock } from "@babylonjs/core/Meshes/Node/Blocks/geometryOutputBlock";
import { SphereBlock } from "@babylonjs/core/Meshes/Node/Blocks/Sources/sphereBlock";
import { NodeGeometry } from "@babylonjs/core/Meshes/Node/nodeGeometry";
// Create node geometry
var nodegeo = new NodeGeometry("nodegeo");
// Create source sphere
var sphere = new SphereBlock("sphere");
// Create output
var output = new GeometryOutputBlock("geometryout");
nodegeo.outputBlock = output;
sphere.geometry.connectTo(output.geometry);

While this example is very simple, it isn't hard to imagine how adding more operations to the graph will present opportunities to alter the geometry flow which is represented by the VertexData provided by the source node. Updating that VertexData will influence the geometry passed to the GeometryOutputBlock.

The GeometryOutputBlock will collect the final VertexData that can then be used to generate a mesh:

// Build and instantiate mesh
nodegeo.onBuildObservable.addOnce(() => {
var mesh = nodegeo.createMesh("nodegeomesh");
})
nodegeo.build();

Updating the Geometry Flow

Now that we have the basics in place, we can start to introduce blocks which will update the data.

The simplest blocks will be the setXXX blocks which focus on generating data for a specific mesh component like positions, normals, colors, uvs, etc.:

  • setPositionsBlock
  • setNormalsBlock
  • setTangentsBlock
  • setColorsBlock
  • setUVsBlock

These blocks are updating the VertexData by generating an entry per vertex inside the geometry. For that specific reason you can imagine them like a loop going through the list of vertices and inserting data from their input.

One way to insert data into the block is to simply use a RandomBlock which will generate a random value. In this example we are setting random positions for each vertex in the sphere:

import { Vector3 } from "@babylonjs/core/Maths/math.vector";
import { GeometryInputBlock } from "@babylonjs/core/Meshes/Node/Blocks/geometryInputBlock";
import { GeometryOutputBlock } from "@babylonjs/core/Meshes/Node/Blocks/geometryOutputBlock";
import { RandomBlock } from "@babylonjs/core/Meshes/Node/Blocks/randomBlock";
import { SetPositionsBlock } from "@babylonjs/core/Meshes/Node/Blocks/Set/setPositionsBlock";
import { SphereBlock } from "@babylonjs/core/Meshes/Node/Blocks/Sources/sphereBlock";
import { NodeGeometryBlockConnectionPointTypes } from "@babylonjs/core/Meshes/Node/Enums/nodeGeometryConnectionPointTypes";
import { NodeGeometry } from "@babylonjs/core/Meshes/Node/nodeGeometry";
// Create node geometry
var nodegeo = new NodeGeometry("nodegeo");
// Create source sphere
var sphere = new SphereBlock("sphere");
var setPositions = new SetPositionsBlock("set positions");
sphere.geometry.connectTo(setPositions.geometry);
var getRnd = new RandomBlock("random");
var rndMin = new GeometryInputBlock("rndMin", NodeGeometryBlockConnectionPointTypes.Vector3);
rndMin.value = Vector3.Zero();
var rndMax = new GeometryInputBlock("rndMax", NodeGeometryBlockConnectionPointTypes.Vector3);
rndMax.value = Vector3.One();
rndMin.output.connectTo(getRnd.min);
rndMax.output.connectTo(getRnd.max);
getRnd.output.connectTo(setPositions.positions);
// Create output
var output = new GeometryOutputBlock("geometryout");
nodegeo.outputBlock = output;
setPositions.output.connectTo(output.geometry);
// Build and instantiate mesh
nodegeo.onBuildObservable.addOnce(() => {
var mesh = nodegeo.createMesh("nodegeomesh");
});
nodegeo.build();

The setPositionsBlock will call the RandomBlock once per vertex to generate the final mesh:

Using NodeGeometry to generate a random based sphere

Contextual Values

To go further you may want to READ from the geometry. To do that the system provides several contextual values that are capable of pulling data FROM the active geometry.

The active geometry is the geometry connected to the block that seeks to read contextual values. In our example below, that will be the SetPositionsBlock.

We can rewrite our graph but instead of adding random values directly we can add the normal of each vertex scaled by a random value to the position of each vertex.

The associated code is:

import { GeometryInputBlock } from "@babylonjs/core/Meshes/Node/Blocks/geometryInputBlock";
import { GeometryOutputBlock } from "@babylonjs/core/Meshes/Node/Blocks/geometryOutputBlock";
import { MathBlock, MathBlockOperations } from "@babylonjs/core/Meshes/Node/Blocks/mathBlock";
import { RandomBlock } from "@babylonjs/core/Meshes/Node/Blocks/randomBlock";
import { SetPositionsBlock } from "@babylonjs/core/Meshes/Node/Blocks/Set/setPositionsBlock";
import { SphereBlock } from "@babylonjs/core/Meshes/Node/Blocks/Sources/sphereBlock";
import { VectorConverterBlock } from "@babylonjs/core/Meshes/Node/Blocks/vectorConverterBlock";
import { NodeGeometryBlockConnectionPointTypes } from "@babylonjs/core/Meshes/Node/Enums/nodeGeometryConnectionPointTypes";
import { NodeGeometryContextualSources } from "@babylonjs/core/Meshes/Node/Enums/nodeGeometryContextualSources";
import { NodeGeometry } from "@babylonjs/core/Meshes/Node/nodeGeometry";
// Create node geometry
var nodeGeometry = new NodeGeometry("nodegeo");
// GeometryInputBlock
var Positions = new GeometryInputBlock("Positions");
Positions.contextualValue = NodeGeometryContextualSources.Positions;
// MathBlock
var Add = new MathBlock("Add");
Add.operation = MathBlockOperations.Add;
// MathBlock
var Multiply = new MathBlock("Multiply");
Multiply.operation = MathBlockOperations.Multiply;
// GeometryInputBlock
var Normals = new GeometryInputBlock("Normals");
Normals.contextualValue = NodeGeometryContextualSources.Normals;
// VectorConverterBlock
var Converter = new VectorConverterBlock("Converter");
// RandomBlock
var random = new RandomBlock("random");
// GeometryInputBlock
var Min = new GeometryInputBlock("Min", NodeGeometryBlockConnectionPointTypes.Float);
Min.value = 0;
// GeometryInputBlock
var Max = new GeometryInputBlock("Max", NodeGeometryBlockConnectionPointTypes.Float);
Max.value = 1;
// SetPositionsBlock
var setpositions = new SetPositionsBlock("set positions");
// SphereBlock
var sphere = new SphereBlock("sphere");
// GeometryOutputBlock
var geometryout = new GeometryOutputBlock("geometryout");
// Connections
sphere.geometry.connectTo(setpositions.geometry);
Positions.output.connectTo(Add.left);
Normals.output.connectTo(Multiply.left);
Min.output.connectTo(random.min);
Max.output.connectTo(random.max);
random.output.connectTo(Converter.xIn);
random.output.connectTo(Converter.yIn);
random.output.connectTo(Converter.zIn);
Converter.xyzOut.connectTo(Multiply.right);
Multiply.output.connectTo(Add.right);
Add.output.connectTo(setpositions.positions);
setpositions.output.connectTo(geometryout.geometry);
// Output nodes
nodeGeometry.outputBlock = geometryout;
nodegeo.onBuildObservable.addOnce(() => {
var mesh = nodegeo.createMesh("nodegeomesh");
});
nodegeo.build();

Which will generate the following mesh: Using NodeGeometry to generate a random based sphere

To better understand the graph, here is a visual representation. See the section on the Node Geometry Editor below. NodeGeometry representation

Please note that we used the VectorConverter to produce a Vector3 out of the RandomBlock generating a float. We are also using the MathBlock twice to get Add and Multiply operations. We also have access to all trigonometry operations with the GeometryTrigonometryBlock.

To complete this graph, we need to add a ComputeNormalsBlock to make sure the normals are rebuilt using the new positions: NodeGeometry representation

This will produce our weird random based sphere: Using NodeGeometry to generate a random based sphere

The list of available contextual values contains the following:

  • positions: Contextual value pointing at the positions array of the active geometry
  • normals: Contextual value pointing at the normals array of the active geometry
  • colors: Contextual value pointing at the colors array of the active geometry
  • tangents: Contextual value pointing at the tangents array of the active geometry
  • uvs1: Contextual value pointing at the uvs array of the active geometry
  • uvs2: Contextual value pointing at the uvs2 array of the active geometry
  • uvs3: Contextual value pointing at the uvs3 array of the active geometry
  • uvs4: Contextual value pointing at the uvs4 array of the active geometry
  • uvs5: Contextual value pointing at the uvs5 array of the active geometry
  • uvs6: Contextual value pointing at the uvs6 array of the active geometry
  • vertexID: Contextual value representing the vertex index of the current vertex of the active geometry

Many of the previous contextual values CANNOT be used with the InstantiateOnFacesBlock block. We will detail which of them can be used below.

Node Geometry Editor

As we can see above, creating even a relatively simple NodeGeometry with code can quickly become very long. This is why we introduced a visual tool to help build NodeGeometry graphs. Learn how to use the editor in the tools section of our documentation.

Instancing Geometries

Ok, now it is time to really unleash the core power of the NodeGeometry!

With the InstantiateOnVerticesBlock class and the InstantiateOnFacesBlock class, you can instantiate a new geometry per vertex, or multiple times per face.

So let's look at this graph: Using InstantiateOnVerticesBlock

The InstantiateOnVerticesBlock block is used to place a box on each vertex of the sphere: Instancing boxes on a sphere

You can apply rotation or scaling per instance by connecting values to the rotation and scaling inputs.

The density input can be used to affect a percentage of the overall vertices. For example, placing an instance of the box only on 15% of the active geometry's vertices.

The InstantiateOnFacesBlock class works similarly but generates several instances per face. This block does not have a density input but instead a count input that lets you decide how many instances in total you want to distribute across all faces of the active geometry.

When using an InstantiateOnFacesBlock, you can only use the following contextual values:

  • positions: Contextual value pointing at the positions array of the active geometry
  • normals: Contextual value pointing at the normals array of the active geometry
  • faceID: Contextual value representing the face index of the current face of the active geometry

Controlling the Flow

The ConditionalBlock is the central block if we want to control what is going on with the geometry flow. It can help decide which branch of the node graph to take based on a condition that can be:

  • Equals
  • Not equals
  • Greater than
  • Greater or equals
  • Lower than
  • Lower or equals
  • Xor
  • Or
  • And

For instance, we can decide to create a new sphere made of boxes, but we want one hemisphere to use one material and the other hemisphere to use another. This graph will do it: Instancing boxes on a sphere with different material per hemisphere

The ConditionBlock is used here with a Greater than setup and picks the value 0 or 1 based on the y value of the normal. The normal being evaluated belongs to the active geometry. In this case, the active geometry is the sphere, as it is the geometry manipulated by the InstantiateOnFacesBlock block. If the y value of the normal is greater than 0.2, it passes a value of 0 to the SetMaterialIDBlock. Otherwise, it passes a value of 1.

The outcome: Instancing boxes on a sphere with different material per hemisphere

Random and Noise

In order to get random values, we have already seen the RandomBlock. However, there is another one that can be used which is the NoiseBlock.

This block will generate a noise pattern based on a Perlin noise algorithm.

Here is our example again with the SetPositions block: Using noise pattern

Which will produce this mesh: Using noise pattern to generate a sphere

Material ID

If working with multiple mesh sources is desired, we can merge them easily with the MergeBlock: using MergeBlock in a graph

Which will generate this mesh: Using MergeBlock to merge multiple geometries

The generated mesh will be made of one unified geometry and be rendered with one draw call.

But we can go further and actually attach a material ID per geometry with the SetMaterialID block: using MergeBlock and SetMaterialID in a graph

Which will generate this mesh: using MergeBlock and SetMaterialID for a mesh

The mesh will now have a list of subMeshes in mesh.subMeshes and will be rendered with a MultiMaterial using one material per ID.

Serialization

A NodeGeometry entity can be serialized to a JSON object:

geometry.serialize(true);

The boolean indicates whether the geometry produced by the MeshBlock should also be serialized into the JSON object. Use caution when doing this because the resulting JSON can quickly become very large.

To load a NodeGeometry from a JSON object, call this code:

nodeGeometry.parseSerializedObject(json);

If using the Node Geometry Editor instead, NodeGeometry can be loaded directly from our snippet server:

import { NodeGeometry } from "@babylonjs/core/Meshes/Node/nodeGeometry";
const geometry = await NodeGeometry.ParseFromSnippetAsync("IJA02K#11");

Loading and Updating From File

In addition to loading from the snippet server, node geometry can be loaded and parsed directly from local storage. This allows a common node geometry flow to be shared between multiple projects while allowing updates to input parameters or source meshes. Asset Manager is a great way to load node geometry files to your scene and can also be used to load meshes to use with the node geometry at the same time. Simply add a new TextFileAssetTask for each node geometry file that needs to be loaded.

import { AssetsManager } from "@babylonjs/core/Misc/assetsManager";
const assetsManager = new AssetsManager(scene);
const nodeGeometryFile = assetsManager.addTextFileTask("load my node geometry", "nodeGeometry.json");
// load all tasks
assetsManager.load();

Asset Manager has a callback we can use to convert the loaded text into a JSON object and then parse it into node geometry.

import { NodeGeometry } from "@babylonjs/core/Meshes/Node/nodeGeometry";
// callback
assetsManager.onFinish = async (tasks) {
console.log("all tasks successful", tasks);
// files loaded as text need to be parsed to JSON to use
const nodeGeometryJSON = JSON.parse(nodeGeometryFile.text);
// parse json object into node geometry
const nodeGeometry = await NodeGeometry.Parse(nodeGeometryJSON);
}

Remember that we still need to build the node geometry and then use createMesh to actually render it into the scene. However, before we build the node geometry, this is the time to get any node that may need to be assigned a value such as a color, vector, or mesh. There are a few options for finding blocks within the graph, such as getBlockByName, getBlockByPredicate, or getInputBlocks. These methods can be used to find specific blocks and set their values.

import { Vector3 } from "@babylonjs/core/Maths/math.vector";
nodeGeometry.getBlockByName("my_vector").value = new Vector3(1.0, 0.0, 1.0);
nodeGeometry.getBlockByName("my_mesh").mesh = myLoadedMesh;

Once we are done setting parameters or attaching meshes to our node geometry, we then call build and createMesh.

nodeGeometry.onBuildObservable.addOnce(() => {
const myGeometry = nodeGeometry.createMesh("myGeometry");
})
nodeGeometry.build();

The order of operations here is important. If node geometry is built and then we try to update any values on the blocks within the graph, no changes will be visible until nodeGeometry.build() is called and we call createMesh again. This also means that we can load and build a node geometry and keep it in memory until we need it with a call to createMesh. Or we could dispose of a mesh created from nodeGeometry and simply call createMesh again later to bring the node geometry back into the scene. In this way, node geometry acts a little like an Asset Container, where we always have it in memory and ready to create new meshes whenever we need them.

Optimizations

The NodeGeometry class uses the CPU to process data, which means that we have to be cautious if we expect to generate several meshes.

For instance let's take this graph: Instancing meshes on a sphere

We can see that the InstantiateOnVerticesBlock will call the Transform of the geometry flow for each vertex of the sphere. Note the use of the MergeBlock to combine multiple geometries. In this case, as nothing in the instance part of the graph is using contextual values - such as reading positions or normals - we can ask the GeometryTransformBlock to not reevaluate its context on each call.

To do so simply call:

myBlock.evaluateContext = false;

Iterating and Debugging

When creating NodeGeometry in a scene, it can always be edited simply by launching the Node Geometry Editor with the NodeGeometry mesh selected in the inspector. The Edit button to launch the Node Geometry Editor can be found under the Node Geometry section of the mesh properties which appears right after the Transforms section.

The button to open the Node Geometry Editor shown in the Node Geometry section of mesh properties

Launching the editor from here will connect the editor to the scene allowing changes to the graph to be reflected in the scene. This is similar to the Node Material Editor with one specific difference. Where the Node Material Editor will automatically update the material in the scene whenever there is a change to the graph, the Node Geometry Editor requires a manual refresh to update the generated mesh. This is due to the increased time needed to build the graph and generate a new mesh which would cause performance issues if updates were forced with each change. To update the Node Geometry mesh in the connected scene, simply click on an empty part of the graph to get the top level options in the properties panel of the editor. Under the Sync section, there will be two buttons, one labeled Update mesh in scene and the second labeled Rebuild.

Pressing the Update mesh in scene button in the Sync category will regenerate the mesh in the connected scene to reflect any updates to the graph

The Update in scene button will regenerate the mesh in the scene based on the current graph, so any changes made to the graph will be reflected in the new mesh. The Rebuild button will only rebuild the graph within the tool, which will update the mesh generated in the preview window. While the graph automatically rebuilds with every change to the graph, the rebuild button is useful when the graph includes nodes that produce procedurally generated output like Random or Instantiate. Pressing the rebuild node will allow testing of the graph to see the types of output generated by procedural nodes without needing to make a change to the graph itself.

There may be times when it is necessary to debug the interaction between the NodeGeometry graph and the code generating the scene. If, for some reason, the NodeGeometry graph fails to generate a mesh, the ability to open the Node Geometry Editor from the inspector is lost because there is no mesh holding the button that launches the editor. In cases like this, there is still a way to debug the graph by opening the Node Geometry Editor with the call:

nodeGeometry.edit();

This will open the editor with the referenced graph loaded into it. Note, however, that launching the editor this way does not attach to a mesh in the scene which means the Update mesh in scene button will not be available.