Creating Variations in Instances Using Node Materials
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.
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.
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.
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.
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.
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.
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.
Senior Technical Artist, Babylon.js