Procedural generation of 3d objects with three.js
Or how to create 8 sextillions of fence models
The problem
Let’s imagine we have a product that we sell to customers, and the product has multiple options. How to explain to our customers what all these options mean? How the product will look if the customer will select option A or option B.
The most simple approach is to use a separate image for each of the options. Customers will select A and will see the image associated with it. How many images will you need to prepare? It depends on the options structure. If this is a linear structure, let’s say the product has a single param X and this param has options A, B, C, and D, it will mean that you need 4 images. But what will happen if the product has three params:
P1: [A, B, C, D],
P2: [A, B, C],
P3: [A, B]
How to calculate the number of combinations? There is a special rule in combinatorics for such problems, it’s quite intuitive and it is called “The rule of product” or “Multiplication principle”. The math is simple, we just need to multiply the number of options in each of the parameters and we will get the needed number. For this example, it will be 4x3x2 = 24 variations.
That sounds not too scary, can we estimate the number of variations for 8 parameters? Let’s say that each parameter will have at least 2 options. What will happen then? We can say that the number of variations will be at least 2⁸ = 256. But if we keep in mind the first two params had 4 and 3 options it will be 384 variations. Let’s try to draw an estimation chart of 20 parameters for our space of variations.
This is an estimation based on the idea that each of the parameters has only 2 options. After 19 parameters it’s already more than 1 million variations. Still feasible. But what if some of the parameters have more than 2 options? Space fast grows to more than trillions of variations and soon our predefined set of images will require more atoms than we have in the visible part of the Universe.
In the particular project we have a set of 28 parameters with the next number of options:
[16, 9, 5, 12, 3, 6, 7, 10, 6, 8, 4, 2, 13, 12, 9, 11, 4, 3, 3, 7, 4, 8, 5, 3, 11, 8, 4, 3]
And it gives us a space of ~8.24²¹ of variations. Or if follow the name of large number notation: 8 Sextillions variants.
Solution
We can solve the visualization problem using the procedural creation of images for each of the user’s selections in real-time. It will allow us to not store gigabytes of images and still provide a visual representation of the selected options.
We have defined a configuration of our product as a set of values: config = [A, C, A, B, D…N]; where order matters. Each of the values is associated with related data and can be transformed into an object that we call modelState:
{
‘A’: {code: string, slug: string, title: string, size: number[], ‘C’: {code, slug, …
}
This object includes information about size and code that can be used for making construction decisions in the 3d model building process.
We are using a Factory pattern for hiding complexity and providing a more convenient interface. So our ProductFactory should accept the modelState as a parameter and build the product.
new ProductFactory(modelState).buildProductA();
The Factory
Inside the Factory, we define several Builders for different types of products. And the Builder is the place where we start fighting with the complexity of variations. To tackle the complexity we split the decisions into several big pieces.
The first one is Measurements. And this class allows us to make calculations based on the modelState, without any knowledge about 3d space.
And the second piece is a Placer/Provider. The abstract Placer class has access to the related Provider and it can do only one thing to place() parts from the provider in the 3d space.
We extend Placer class with concrete Placers e.g. Picket Placer, it goes along with concrete Provider e.g. Picket Provider. Together they encapsulate the logic of this part of our model. The provider takes the measurements, creates required Parts, and puts them into a global model PartsPool. Then the placer can find needed Parts and place them one by one in line with the defined plan.
All Placers are working in a sequence. The order is important in this process since inside the placer we should get information about the positions or dimensions of previously placed parts. It’s similar to the way people build objects in the real world. For example, first, you need to place a foundation, then add some posts, then attach rails, etc.
But how can you create the procedural 3d objects with correct texturing?
Geometry and Textures
And here’s the three.js fun begins. This framework provides us with a nice 3-dimensional space and a bunch of utilities to work with textures and vectors. To simplify the work with objects we can define an abstract class for PartInstance with a set of common methods, such as material, geometry, materialOptions, name, etc. This class can be extended with other classes for particular geometry kinds. In our case, we have defined a set of Box classes and Cylinder classes.
Creating geometry for boxes is trivial — we can just create a box geometry with given parameters like this:
new THREE.BoxBufferGeometry(x, y, z);
But If you were working with 3d objects in any graphic editor you might know that wrapping texture on objects is not so easy. The wrapping process requires knowledge about particular geometry faces. You need to know the coordinates of each face and the dimensions of the texture that you want to wrap around the object.
To implement this in three.js for a box we should define 6 geometry groups:
geometry.addGroup(0, 6, Side.Right);
geometry.addGroup(6, 6, Side.Left);
…
geometry.addGroup(30, 6, Side.Back);
Then we have to calculate a set of UV coordinates in each of the groups. And save it as a geometry attribute.
geometry.setAttribute(‘uv’, new THREE.BufferAttribute(uvs, 2));
Three.js provides a class for creating a Mesh, which is basically a combination of Geometry and Material. And it helps with assigning material to the surfaces. But it doesn’t do the correct texture wrapping.
The Material can be either a single THREE.Material or an array of materials. If you try to combine an array of materials with a geometry without groups, the mesh class will assign each of the textures to a separate Polygon with an aspect ratio of 1:1. That’s why it’s important to properly calculate UV coordinates in groups. In this way, we can set a correct aspect ratio for each of the Box Polygons.
Performance
What is THREE.Material? It’s a class for describing the appearance of objects. And inside each instance of the class, it has a TextureMap which can be simply presented as a Bitmap or an Image. Each Material includes multiple maps, such as normal, displacement, emissive, etc. And as you already know for each of the Box mesh we define 6 Materials. It means that for each box we will create a separate set of Bitmap entities in memory. And what if your model includes hundreds of boxes? It will consume 10Gb of ram in seconds.
If you will have a single MaterialFactory it will allow you to not create a new Material for each request, but reuse the existing one. Sounds nice, but there is a problem. If the Materials are the same they always will look the same.
And here our geometry groups help again. When the Mesh class wraps a Material around a Geometry it checks the UV attribute and if we define an offset in the Texture space we can emulate a random look for each new Box instance.
Technically we have only one instance of the Bitmap in the memory.
You can try our Fence Builder live on https://fencequoting.com
Reducing the space of variations with Rules
Of course, most of the model variations will never be built. And we shouldn’t allow users to build the models with any parameters, some of them just don’t make any sense. For avoiding wrong combinations we have built two systems: the restriction system and the Rules system. But it’s a separate big topic.