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 about it like a postprocessing system for geometry. Ultimately it produces a geometry but with 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 that are assembled later by the NodeGeometry system
  • The system is dynamic and can produce infinite variants at run time. While there are many digital content creation tools that can create procedural meshes with the same infinite possibilities, the limiting factor again becomes downloading assets created offline. Examples would be terrain or vegetation generation.
  • It allows a new way of modelling in Babylon.js by assembling core shapes and playing around 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 an utility class, meaning it is autonomous and does not require access to an engine or a scene. So instantiation is pretty simple:

const nodeGeometry = new BABYLON.NodeGeometry("my node geometry");

Once created, the system will expect you to create a flow from a source to an endpoint. In a nutshell, the NodeGeometry will process 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:

// Create node geometry
var nodegeo = new BABYLON.NodeGeometry("nodegeo");
// Create source sphere
var sphere = new BABYLON.SphereBlock("sphere");
// Create output
var output = new BABYLON.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.build();
var mesh = nodegeo.createMesh("nodegeomesh");

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:

// Create node geometry
var nodegeo = new BABYLON.NodeGeometry("nodegeo");
// Create source sphere
var sphere = new BABYLON.SphereBlock("sphere");
var setPositions = new BABYLON.SetPositionsBlock("set positions");
sphere.geometry.connectTo(setPositions.geometry);
var getRnd = new BABYLON.RandomBlock("random");
var rndMin = new BABYLON.GeometryInputBlock("rndMin", BABYLON.NodeGeometryBlockConnectionPointTypes.Vector3);
rndMin.value = BABYLON.Vector3.Zero();
var rndMax = new BABYLON.GeometryInputBlock("rndMax", BABYLON.NodeGeometryBlockConnectionPointTypes.Vector3);
rndMax.value = BABYLON.Vector3.One();
rndMin.output.connectTo(getRnd.min);
rndMax.output.connectTo(getRnd.max);
getRnd.output.connectTo(setPositions.positions);
// Create output
var output = new BABYLON.GeometryOutputBlock("geometryout");
nodegeo.outputBlock = output;
setPositions.output.connectTo(output.geometry);
// Build and instantiate mesh
nodegeo.build();
var mesh = nodegeo.createMesh("nodegeomesh");

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:

// Create node geometry
var nodeGeometry = new BABYLON.NodeGeometry("nodegeo");
// GeometryInputBlock
var Positions = new BABYLON.GeometryInputBlock("Positions");
Positions.contextualValue = BABYLON.NodeGeometryContextualSources.Positions;
// MathBlock
var Add = new BABYLON.MathBlock("Add");
Add.operation = BABYLON.MathBlockOperations.Add;
// MathBlock
var Multiply = new BABYLON.MathBlock("Multiply");
Multiply.operation = BABYLON.MathBlockOperations.Multiply;
// GeometryInputBlock
var Normals = new BABYLON.GeometryInputBlock("Normals");
Normals.contextualValue = BABYLON.NodeGeometryContextualSources.Normals;
// VectorConverterBlock
var Converter = new BABYLON.VectorConverterBlock("Converter");
// RandomBlock
var random = new BABYLON.RandomBlock("random");
// GeometryInputBlock
var Min = new BABYLON.GeometryInputBlock("Min", BABYLON.NodeGeometryBlockConnectionPointTypes.Float);
Min.value = 0;
// GeometryInputBlock
var Max = new BABYLON.GeometryInputBlock("Max", BABYLON.NodeGeometryBlockConnectionPointTypes.Float);
Max.value = 1;
// SetPositionsBlock
var setpositions = new BABYLON.SetPositionsBlock("set positions");
// SphereBlock
var sphere = new BABYLON.SphereBlock("sphere");
// GeometryOutputBlock
var geometryout = new BABYLON.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;
nodeGeometry.build();
var mesh = nodeGeometry.createMesh("nodegeomesh");

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.

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 have the opportunity to 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 will generate several instances per face. This block does not have a density input but instead a count input that will let 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 have 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 will then pick the value 0 or 1 based on the y value of the normal. The normal that is evaluated belongs to the active geometry. In this case, the active geomerty is the sphere as the sphere is the manipulated by the InstantiateOnFacesBlock block. If the y value of the normal is greater than 0.2 then it will pass a value of 0 to the SetMaterialIDBlock. Otherwise it will pass 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 if the geometry produced by the MeshBlock should also be serialized into the json object. Use caution when doing this becuase the resulting json can quickly become very heavy.

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:

const geometry = await BABYLON.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.

const assetsManager = new BABYLON.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 to node geometry.

// 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 BABYLON.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, now is the time to get any node that may need to be assigned a value like 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.

nodeGeometry.getBlockByName("my_vector").value = new BABYLON.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.build();
const myGeometry = nodeGeometry.createMesh("myGeometry");

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 seen until nodeGeometry.build() is called and we 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 at a later point to bring the node geometry back into the scene. In this way node geometry acts a little like Asset Container where we always have the it in memory 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 expecting 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;