Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How does the .clone() method in Three.js save memory?

For the game I'm writing, I'm adding 100,000 trees, each of which is a merged geometry. When I add these from a cloned model using tree.clone(), I save tons of memory by doing this, but the game runs at 3 FPS because of the 100k geometries.

In order to get the game up to 60 FPS, I'd need to merge these trees together into a few geometries total. However, when I do this, chrome crashes due to using too much memory.

What's the reason for the extreme memory usage when merging these trees? Is it because I'm undoing the positives of using the .clone() function?

like image 269
DonutGaz Avatar asked Dec 23 '22 21:12

DonutGaz


1 Answers

You need to look into instancing, your use case is exactly what the thing is made for.

Alternatively, using BufferGeometry instead of regular geometry, should be less memory intensive.

edit

What's actually eating your memory is the overhead of the merge operation when it operates on THREE.Geometry. The reason for this is that you have to allocate a ton of JS objects, like Vector3,Vector2, Face3, etc. Which then are discarded since the original geometry doesn't exist any more. This stresses everything, even if you didn't experience a crash, you'd experience slow down from garbage collection. The reason buffer geometry works better is because it's using typed arrays. For starters all your floats are floats not doubles, only primitives are being copied around, no object allocation etc etc.

You are eating more memory on the gpu though, since instead of storing one instance of geometry and referring to it in multiple draw calls, you now hold N instances within the same buffer (same data just repeated, and pre-transformed). This is where instancing would help. In summary you have:

Mesh (Node, Object)

Describes a 3d object within a "scene graph". Is it a child of some other node, does it have children, where its positioned (Translation), how its rotated, and is it scaled. This is the THREE.Object3D class. Extending from that is THREE.Mesh that references a geometry and a material, along with some other properties.

Geometry

Holds the geometry data, (which is actually referred to as "mesh" in modeling programs), file formats etc. hence the ambiguity. In your example that would be a single "tree":

  • describing how far away a leaf is from the root or how segmented is the trunk or branches (vertices)

  • where the leaves or trunk look up textures (UVS),

  • how it reacts to light (explicit normals , optional, but there are actually techniques for rendering foliage with overriden/modified/non-regular normals)

What's important to understand is that this stuff exists in "object (model) space". Let's say that a modeler modeled this, this would mean that he designated the object as "vertical" (the trunk goes up say Z axis, the ground is considered XY) thus giving it the initial Rotation, he put the root nicely at 0,0,0 thus giving it the initial Translation, by default we can assume that the Scale part is 1,1,1.

This tree can now be scattered around a 3d scene, be it in a modeling program or three.js. Let's say we import it into something like Blender. It would come in at the center of the world, at 0,0,0, rotated by 0,0,0, and at its own scale of 1,1,1. We realize that the modeler worked in inches, and our world in meters, so we scale it in all three directions by some constant. Now we realize that the tree is under ground, so we move it up by X units, until it sits on the terrain. But now its going through a house, so we move it sideways, or perhaps in all three directions because the house is on a hill and now it sits where we want it. We now observe that the silhouette is not aesthetically pleasing so we rotate it around the "vertical" axis by N degrees.

Let's observe now what happened. We haven't made a single clone of the tree, we scaled it, moved it around, and rotated it.We haven't modified the geometry in any way (adding leaves, branches, deleting something, changing uvs), it's still the same tree. If we say that the geometry is it's own entity, we have a scene graph node that has its TRS set. In context of three.js this is THREE.Mesh (inherits from Object3D), which has .rotation,.scale, position(translation) and finally .geometry (in your case a "tree").

Now lets say that we need a new tree for the forest. This tree is actually going to be the exact copy of the previous tree, but residing at a different place (T), rotated along the Z axis (R), and scaled non-uniformly (S). We only need a new node that has a different TRS, lets call it tree_02, it's using the same geometry, lets call it treeGeometryOption_1. Since tree geometry one has a defined set of UVS, it also has a corresponding texture. A texture goes into a material, and the material describes the properties, how shiny is the leaf, how dull is the trunk, is it using a normal map, does it have a color overlay, etc.

This means that you can have some sort of TreeMasterMaterial that sets these properties, and then have a treeOptionX_material corresponding to a geoemetry. Ie. if a leaf looks up the uvs in some range, the texture there should be green, and more shiny, then the range that trunk looks up.

Now lets reiterate the entire process. We imported the initial tree model, and gave it some scale, rotation and position. This is a node, with a geometry attached to it. We then made multiple copies of that node, which all refers to the same geometry TreeOption1. Since the geometry is the same, all of these clones can have the same material treeOption1_material which has it's own set of textures.

This was all a super lengthy explanation of why the clone code looks like this:

return 
  new this.constructor( //a new instance of Mesh
    this.geometry ,     //with the sources geometry (reference)
    this.material       //with the sources material (reference)
  ).copy(this)          //utility to copy other properties per class (Points, Mesh...)

The other answer is misleading and makes it sound like:

return 
  new this.constructor( //a new instance of Mesh
    this.geometry.clone() ,  //this would be devastating for memory
    this.material.clone()    //this is actually sort of common to be done, but it would be done after the clone operation
  ).copy(this)          

Let's say we want to tint the trees to have different colors, for example 3.

var materialOptions = [];

colorOptions.forEach( color =>{

 var mOption = masterTreeMaterial.clone()
 //var mOption = myImportedTreeMesh.material.clone(); //lets say youve loaded the mesh with a material 

 mOption.color.copy( color ); //generate three copies of the material with different color tints

 materialOptions.push( mOption );
});

scatterTrees( myImportedTreeMesh , materialOptions ); 

//where you would have something like
var newTree = myImportedTreeMesh.clone(); //newTree has the same geometry, same material - the master one
newTree.material = someMaterialOption; //assign it a different material

//set the node TRS
newTree.position.copy( somePosition );
newTree.rotation.copy( someRotation );
newTree.scale.copy( someScale );

Now what happens is that this generates many many draw calls. For each tree to be drawn, a set of low level instructions need to be sent to set up the uniforms (matrices for TRS ), textures (when trees with different materials are drawn) and this produces overhead. If these are combined and the draw call number is reduced, the overhead is reduced, and webgl can handle transforming many vertices, so a low poly object can be drawn at 60fps thousands of times, but not with thousands of draw calls.

This is the cause of your 3 fps result.

Fancy optimizations aside, the brute force way would be to merge multiple trees into a single object. If we combine multiple THREE.Mesh nodes into one, we only have room for one TRS. What do we do with the thousands of individual trees scattered over the terrain, what happened to their TRSs? They get baked into geometry.

First of all, each node now requires a clone of the geometry, since the geometry is going to be modified. The first step is multiplying every vertex by the TRS matrix of that node. This is now a tree that does not sit at 0,0,0 and is no longer in inches, but sits somewhere at XYZ relative to the terrain, and is in meters.

After doing this a thousand times, these thousand individual tree geometries need to be merged into one. Easy peasy, it's just making a new geometry, and filling it's vertices, faces, and uvs with these thousand new geometries. You can imagine the overhead thats involved when these numbers are high, JS has its limitations, GC can be slow, and this is a lot of data, because it's 3d.

This should answer the question from the title. If we go in reverse, we can say that you had a game that was consuming lots of memory ( by having a model - geometry, of a "forest") but was running at 60fps. You used the clone method to save memory, by breaking the forest apart into individual trees, extracting the TRS at every root, and by using a single tree reference for each node.

Now the gpu holds only a low poly model of a tree, instead of holding a giant model of a forest. Memory saved. Draw call abundance.

FPS RIP.

How do both, reduce the number of draw calls, while rendering a model of a tree, not a forest?

By using a feature called instancing. Webgl allows for a special draw call to be issued. It uses an attribute buffer to set up multiple TRS information at the same time. 10000 nodes would have a buffer of 10000 TRS matrices, this is 16 floats, describing a single triangle with no uvs or normals takes 9, so you can see where this is going. You hold one instance of the tree geometry on the gpu, and instead of setting up thousands of draw calls, you set one with the data for all of them. If the objects are static, the overhead is minimal, since you'd set both buffers at once.

Three.js abstracts this quite nicely with THREE.InstancedBufferGeometry (or something like that).

One tree, many nodes: T memory N draw calls

One forest, single node: T * N memory 1 draw call

One tree, instanced many times: T + N memory (kind of, but it's mostly just T) 1 draw call

/edit

100k is quite a lot, three is probably better at handling this than before, but it used to tank your fps whenever you had more than 65536 vertices. I'm not sure off the top of my head, but it's now addressed by either breaking it up into multiple drawcalls internally, or the fact that webgl can address more than 2^16 vertices.

I save tons of memory by doing this, but the game runs at 3 FPS because of the 100k geometries.

You still have one geometry, you have 100k "nodes" that all point to the same instance of geometry. It's the overhead of the 100k draw calls that is slowing this down.

There are many ways to skin this cat.

like image 177
pailhead Avatar answered Dec 26 '22 10:12

pailhead