Creating Variations in Instances Using Node Materials

Babylon.js
5 min readJan 18, 2023

--

One of the main challenges for creating scenes in Babylon.js is managing the trade-off between scene complexity and draw calls which has a direct effect on the frame rate you can maintain in your scene. Too many draw calls can become a drag on the rendering performance of the scene, but your scene may require a large number of meshes generated at run time.

An example of such a scene might be a procedurally generated forest made up of hundreds of tree meshes. Creating the forest as instances of a single tree mesh solves a lot of these problems because these instances are batched as one draw call while still being able to have individual positions, rotations, and scales. You can even set a custom color per instance with instanced buffers, but what if your asset’s material is driven by textures instead of vertex color? How can you make them all to look different from one another? This is where node materials paired with some clever planning in your textures can come to the rescue.

a render of a three row by five column grid of wooden spheres that appear to be made of different species of wood.
Mesh instances using random colors to drive texture offsets in the shader.

In the example above — which is a playground that can be viewed at this link — we have instances of a sphere using a wood grain material. Each sphere is using the same texture set and node material, but we are able to are able to generate a unique look for each instance. The technique employed here is just a simple offset in the UV coordinates of each texture to create the variations. Both the color variant and the specific grain pattern are changed through the same offset technique used in slightly different ways.

Two textures side by side. The left texture is a stack of five bands of color gradients simulating different species of wood. The right texture is a tiling black and white simulation of wood grain
The base color texture showing the color channels on the left and the alpha channel on the right. The color channels determine the color of the wood and the alpha channel drives the wood grain pattern.

In both cases, these texture offsets are being driven by the random color stored in the instance buffer of each instance. Looking at the texture above, we can see there are five color gradients which are packed in the RGB channels of the base color texture. A tiling grain pattern for the wood is stored in the alpha channel for the texture. Instead of using this channel for alpha transparency, we will use it to sample colors from the color channels to create the grain pattern for the wood. The physical size of the texture is driven by the detail needed in the wood grain rather than in the color channels. This is because the color gradients only need a single pixel in height to generate the gradient where we need a larger texture for the detail in the grain. While there are only 5 variations in color used for this texture, we could feasibly have hundreds of color variations contained in this texture because of the height required by the grain texture.

A tangent space normal texture on the left and a roughness texture on the right. The normal texture adds small holes in the surface detail while the roughness texture for the wood grain shows how reflective the surface is.
The normal texture is packed with the roughness texture to save on texture loads. Since wood is non-metallic, a metallic texture is eliminated in favor of a metallic factor in the shader to save on texture loads.

We also use a normal texture to add some surface detail and a roughness texture to simulate surface reflections in the physically-based rendering (PBR) material. We are using a metallic-roughness lighting model for the material and we know that wood is not metallic. This means we can simply use a factor of 0.0 for the metallic component of the material and skip a metallic texture. To optimize the texture set, we can simply pack the roughness texture into the alpha channel of the normal texture to reduce our PBR texture count to two. We will apply the UV offsets we generate to both the base color and normal/roughness textures in the same way to keep the textures aligned.

To generate the UV offsets needed for the variations, we will create a new instanced buffer color for each instance. We use the red channel for the color variation and the green channel to offset the wood grain. We could also use the blue channel to create a different offset for U and V coordinates of the grain texture to generate even more variations. Creating the color for each instance is as simple as:

instance.instancedBuffers.color = new BABYLON.Color3(Math.floor(Math.random() * 5) * 0.2 + 0.01, Math.random(), 0);

This will generate a random value for the red channel from the following set: 0.1, 0.21, 0.41, 0.61, or 0.81 which will give us the V coordinate of one of the five gradient bands. It will also generate a random value between 0.0 and 1.0 for the green channel which will be used to drive the offset of the grain, normal, and roughness channels. Now that we have the textures needed for the material and each instance has a unique color to drive the shader, we just need to wire up a node material to use this data.

A node graph illustrating how to wire mesh.color to offset mesh.uv for the material.
This section of the node graph illustrates how to wire the mesh.color to offset mesh.uv for each texture.

We simply need to use the mesh.color block and split the color into individual channels. The red channel, which will contain the color gradient offset value, is simply wired to the V component of the color texture UVs. The value in the green channel is added to the V value of the mesh.uv block which generates a random offset in V. This value is applied to the UV coordinates for the normal and roughness textures and is also applied to the base color texture which will affect the grain texture in the alpha channel. The grain texture, which is a grayscale texture, is then connected to the U channel for the UVs of the base color texture.

An image of three textures. A texture containing five color gradient bands on the left, a texture containing a grayscale representation of wood grain in the center, and a render of the final wood grain material applied to a sphere on the right.
The grain pattern on the sphere is made up of one of the color gradients on the left sampled by the grayscale grain pattern in the middle which determines how the grain is colored in the material.

What this does is map the grayscale value of each pixel in the grain texture to a point along the color gradient from 0.0 to 1.0. This sampling of the gradient at different U coordinates as driven by the grain texture is what gives the wood grain the color patterns seen in the image above.

a render of a six row by ten column grid of wooden spheres that appear to be made of different species of wood.
Scaling up variations in your instances is easy with just a little planning when creating your textures.

As you can see, even if we have limited data that can be stored on an instance, we can still use those values to do quite a bit to modify the textures used in a shader to create a unique look for each instance. I hope this gives you ideas about how you can push instances to do more when combined with node materials.

Patrick Ryan
Senior Technical Artist, Babylon.js

--

--

Babylon.js

Babylon.js: Powerful, Beautiful, Simple, Open — Web-Based 3D At Its Best. https://www.babylonjs.com/