Optimizing the use of a custom Geometry

I have created a custom three.js Geometry which uses a DEM of the globe to implement all the vertices, faces and vertexUVs. It then maps a high-res image of the world onto the resulting mesh. The result works well (IMO). The performance could be a better, but it has a LOT vertices and faces since the DEM is 1440x720 - a million vertices and twice that many faces. You can see a writeup of the demo here, including a working example (link in the writeup). All the sources are on github here.

I have a design for a spherical LOD reduction that will cut the number of vertices by ~40% which will help a lot. My question is whether ALSO converting it to use a Buffer Geometry would make any real difference. I tend to think not as the result appears to be GPU bound in the rendering, not in the construction, but I thought I would ask here on this forum as those with more experience might correct my assessment. TIA.

THEE.Geometry is converted to THREE.BufferGeometry before uploading to the GPU, since it requires as the name says, memory buffers. So using fixed chunks of buffers can be a great improvement, not only to create geometries but also to reuse them. Allocating millions of Vector3, Face3 and all such objects is slow and consumes a lot memory. If geometries are generated frequently this can lead to huge frame delays by the GC.

Do you create indexed geometries?

For a sphere there are several techniques you could use to avoid geometry construction at all and do most (simple) operations in shaders. I actually work on a realscale planetry renderer with focus on high performance and precision to cm scales. If you’re going for a realtime LOD, like maybe a quick subdivision you should definetly use a fixed BufferGeometry and try to use only few static Vector3 as helpers. As buffers are transferable, so you could basically construct in a worker too.

Edit: Do you elevate the vertices on the CPU? The JS heap raises to almost 2 GB at the construction, while this is the most common task, and this seems to repeat very often (chunks?).

This is where it gets converted into a BufferGeometry

Ah, thanks for the feedback. A lot of food for thought. This is exactly the info I was hoping for (though obviously not entirely expecting).

Yes, the vertices are created on the CPU. I have looked a little at indexed geometries, but clearly this will require some significant “refactoring”… :slight_smile:

You can also raise the vertices on the GPU, only creating a flat sphere on CPU. You basically only need to pass your elevation map as uniform, sample from it, and push the vertex simply along the normal with that scale (as a simple approach).

Indexed geometries will share vertices, it’s good for memory and render performance, but creating such is a little costly since you have to use a lookup hashmap of each vertex.

Since we don’t have geometry shaders yet, if you add LOD, it might be better to avoid realtime construction, but depends on how large the geometry will be and all other tasks per frame, and if mobiles shouldn’t get roasted, if they are relevant for you.

Hm. Thanks for the feedback, but I’m not sure I get all this. For example , if I create a smooth sphere and then add the DEM info on the GPU I see two problems:

  • The sphere’s vertices get modified twice, once when the SphereGeometry is created by three.js, which creates them ion the CPU then pushes it to the GPU where the faces get created (I am guessing).
  • If the vertices’ positions are modified on the GPU then the normals need to be recalculated. This is a problem in WebGL 1.0 because there is no way (I know of) to find the coordinates of adjacent vertices.

Further, I am only creating one Geometry so it is not clear to me what vertices would be shared, but I am probably just not understanding as my knowledge of buffer geometries is obviously limited. Is WebGL replicating some or all of my vertices when it creates the buffers that it pushes/creates on the GPU?

As i mentioned, your Geometry will be converted into a BufferGeometry anyway before uploading to the GPU. A BufferGeometry is a “final stage” basically, since these memory buffers get uploaded.

Indices describe which vertices will be shared, if you don’t create a index, every 3 vertices will be used as a triangle. For example 4 positions can be used for 2 triangles if you tell the GPU which are shared for a triangle, and this also means only 4 normals and UV coordinates are required. The non-indexed approach means you create 3 normals, 3 uvs, 3 positions per triangle.

Basically you can even skip normals since you’re using a sphere. You only need to normalize the vertex position in the vertex shader, this is the smooth sphere normal, together with the elevation map you can sample 4 offsets on this map at the uv coordinate to extract the landscape normal from your elevation map. But all i’m suggesting here requires a little glsl knowlege.

It depends on your goals, how much effort you want to put in it or if the resolution will go higher too. I wouldn’t recommend a CPU approach, but without touching shaders i’m sure you can actually improve a lot. Actually that TIFF reader you call per row seems a pretty expensive task. You could track down few things first by disabling that and measure performance again. I just took a quick look at the code if it and that’s likely to allocate that huge amount of memory. I’m not sure how this lib is supposed to be used though.

re calculating the heights in the shader, you state “you can sample 4 offsets on this map at the uv coordinate to extract the landscape normal from your elevation map” but AFAIK, this is NOT possible in WebGL 1.0, which is what the current three.js is based on. To make that calculation, you need to call textureOffset, which only exists in GLSL 3.0, i.e. WebGL 2.0. I tried this once, see the shader code here. There was a long thread about this on a StackOverflow list here where the consensus was wait for WebGL 2.0.

For the rest of your comments, you are probably right. I will investigate as I have time. I really appreciate your taking the time to look into this.

You don’t need textureOffset for this.

Hm. Could you explain how this could be done? AFAIK, in order to calculate the normal for each face of the resulting surface (created by elevating each vertex to the appropriate height), one needs to know the heights of the three vertices which define the plane of the face. Knowing the height of the one vertex that was elevated is not sufficient. If one had textureOffset() available it is fairly straightforward (as others have demonstrated in WebGL 2), but in a WebGL 1.0 system (like three.js) it is problematic at best.

But perhaps there is some other approach of which I (and others on the Web) are unaware of. Could you please explain? TIA.

When you elevate the vertices by a heightmap they don’t need to know the neighbour vertices, you only need 4 samples of the heightmap (+y, -y, +x, -x). Or why do you think you need textureOffset?

Agreed, that’s all you need, but how does one get that? The coordinate system of uv passed in is in the range of 0…1. But as you note, one needs +x,-x, +y,-y (or -z,+z, depending on how you term the coordinates). From the example I referenced:

uniform sampler2D unit_wave
noperspective in vec2 tex_coord;
const vec2 size = vec2(2.0,0.0);
const ivec3 off = ivec3(-1,0,1);

vec4 wave = texture(unit_wave, tex_coord);
float s11 = wave.x;
float s01 = textureOffset(unit_wave, tex_coord, off.xy).x;
float s21 = textureOffset(unit_wave, tex_coord, off.zy).x;
float s10 = textureOffset(unit_wave, tex_coord, off.yx).x;
float s12 = textureOffset(unit_wave, tex_coord, off.yz).x;
vec3 va = normalize(vec3(size.xy,s21-s01));
vec3 vb = normalize(vec3(size.yx,s12-s10));
vec4 bump = vec4( cross(va,vb), s11 );

So textureOffset returns the samples from the heightmap by passing unit coords to texturOffset. Perhaps there is some other way of getting the adjacent samples in the heightmap given uv coordinates. Perhaps computing them in the CPU and passing them in as a uniform?

If you enable linear interpolation you don’t need the exact texel, but you can basically round the coordinates to the required texel and it’s neighbours. If your vertices aren’t aligned in a regular grid, and their density is higher than the map, interpolation is required anyway.

float T = 1.0 / textureSize;
vec2 center = floor(uv * (textureSize - 1.0) + 0.5) * T;

Someting like that. The offsets value would be 1 texel T for (+x, -x, +y, -y) around center.

Ah, I see (I think). Need to focus on my day job for now. Will take a look tonight. Thanks for taking the time to explain your thinking.