Terrain height map banding

Hello :wave:
Im making a terrain system. I have a 1024x1024 PNG height map from which I sample height and apply it to my 1024x1024 wide terrain (on the CPU). The vertices are displaced and the computed normals seem good. However im running into an issue - the terrain mesh geometry isnt smooth as I hoped it would be. Effects similar to texture banding can be seen:

This seems to be a vertex position issue and not a normals issue as wireframe also displays this pattern:

The problem is even more visible when I increase the mesh resolution x4 :

Different combination of texture resolution and terrain resolution still run into the same thing.

Here is my heightmap texture:

It’s made in blender based on noise and baked from multires modifier on a plane. I load it using THREE.TextureLoader and then use canvas2d to get it’s pixels and save the data into a 2D Float32Array.

Loading the height map texture:
textureLoader.load( `world_map_1k.png`, ( loadedTexture ) => {

    // Extract pixel data from the loaded texture
    const canvas = document.createElement('canvas');
    const context = canvas.getContext('2d');
    canvas.width = loadedTexture.image.width;
    canvas.height = loadedTexture.image.height;
    context.drawImage( loadedTexture, 0, 0 );
    const imageData = context.getImageData( 0, 0, canvas.width, canvas.height );
    const pixels = imageData.data;

    let index = 0;
    // Convert RGB values to floats and store in the 2D array
    for ( let i = 0; i < pixels.length; i += 4 ) {
        heightMap.data[ index++ ] = ( ( pixels[ i ] / 255.0 ) - 0.5 ) * 2.0; // -1.0 : 1.0
    } // Only save R channel since it's a black  white texture anyway
});

From that heightMap I later sample height using bilinear interpolation between 4 nearest data points:

Float2DArray.js sample method:
// class Float2DArray
sample( x, y ) {
    if ( x < 0 || x > 1 || y < 0 || y > 1 ) return 1;
    // Multiply input coordinate x by (width - 1) to ensure a coordinate 1.0 maps to highest valid index in the array (width - 1).
    const fractionalX = x * ( this.#width - 1 );
    const fractionalY = y * ( this.#height - 1 );
    // Find the four surrounding integer indices that enclose the fractional indices.
    const x1 = Math.floor( fractionalX );
    const x2 = Math.ceil( fractionalX );
    const y1 = Math.floor( fractionalY );
    const y2 = Math.ceil( fractionalY );
    // Get 2 interpolated X values
    let valueX1, valueX2;
    if ( x1 === x2 ) { // Both X are the same so we take values of [x][y1] and [x][y2]
        valueX1 = this.get( x1, y1 );
        valueX2 = this.get( x1, y2 );
    } else {
        const wX = x2 - fractionalX; // weight on X
        valueX1 = this.get( x1, y1 ) * wX + this.get( x2, y1 ) * ( 1 - wX ); // lower X value
        valueX2 = this.get( x1, y2 ) * wX + this.get( x2, y2 ) * ( 1 - wX ); // upper X value
    }
    const wY = y2 - fractionalY; // weight on Y
    const interpolatedValue = valueX1 * wY + valueX2 * ( 1 - wY );

    return interpolatedValue;
}

I also tested with noise textures not generated by me, but for example from the internet, and they also have this issue. I might presume this issue is not image specific?
Could it be that context.getImageData() gets UInt8 values which dont have enough color precision, producing banding inside my application?

What could be the reason for this rude phenomenon? Does anybody know?

Thanks for reading and have a nice day! :smiley_cat:

It’s the limitation of using an image. The numbers of steps available is very limited (128 -256 max)
To go beyond this limit you have to process the data with a smoothing function to average positions, and get billions more.

Here is an exemple on how to solve this using single or multi smooth-banding
(loading is slow, it’s an extreme case with millions triangles to demonstrate it’s efficacity)

2 Likes

do not round your x,y dolphin

instead take their fract() and use it to lerp between neighbor pixels:

image

you can go even smoother by taking more pixels into account and using splines

3 Likes

Oh so its actually a somewhat natural phenomenon? I knew it could be an issue with using a texture but thought using filtering would solve this. Anyhow, thank you for this. It’s exactly what I was looking for! :heartbeat:

@makc3d OMG YOURE ALIVE!!
But i am not rounding. Im only using ceil and floor to get indices of the 4 data points, then I actually do use the fractionals to interpolate between them

Update for anyone finding this in the future. Since the creation of this topic, I’ve switched to LOD instancing the terrain patches and displacing the height on the GPU. Took some fighting to then make sure height values on the GPU and CPU were synced, but turns out this was mainly a precision issue.

I very highly recommend exporting the heightmap into Open EXR format - Blender has options for both Half Float and Full Float. It’s very good for textures that store data and not colors. Its also great because the EXRLoader provides you with a DataTexture, so you can access it’s correct data on the CPU immediately, directly, without being forced to deal with annoying Canvas2D APIs like drawImage and getImageData which give you only low UInt8 precision resulting in banding shown in first post. Additionally you save time as you have the data on hand.

IMPORTANT ! Make sure to change the loader type to full float!
exrLoader.type = THREE.FloatType;
This will make sure that when you load the texture, the texture data you get is inside a Float32Array container and not UInt16Array which is what happens with the default EXR loader type THREE.HalfFloatType. Actually using FloatType works even when your exported heightmap is half float precision, so not having to transform UInt16 values to Floats is one headache less.

Half float precision was enough for me to get smooth terrain data into 3js. In fact it’s so smooth there is no need to perform any smoothing on the data as suggested in post #2 AND even prevented terrain tearing between patches of different LODs ! I didn’t have to do any stitching on my terrain! Whoo-hoo! :partying_face: :tada:
(this might vary depending on your terrain height/resolution, but for now I’m saved :relieved: )

That’s it. Hope it helps anyone in the future. Thanks!

4 Likes

Sooo… how are you accessing height data on the CPU corresponding to the GPU value?

EXRLoader does all the heavy work :smiling_face:

const exrLoader = new EXRLoader();
exrLoader.type = THREE.FloatType;
exrLoader.load( heightMapFilePath, ( texture, textureData ) => {
  const heightMapTexture = texture; // Texture of height data for GPU
  const heightMapData = textureData.data; // Same data for CPU
}

Thanks to THREE.FloatType the textureData is stored within a Float32Array in 0.0 to 1.0 range

5 Likes

Here’s my implementation of terrain that is similar to your approach. Hit Y to see the wireframe…
The terrain is all generated on the GPU and rendered to a floating point rendertarget, which is then plugged into the .displacementMap of the terrain patches.

https://manthrax.github.io/sekt/client/index.html

2 Likes

tbh it looks like you went overboard with the triangles, it is on the slow side for me while there seems to be not enough geometry details to call for this much tris

Yah I get a solid 144 fps. What are you running on?
The density is settable in code. I just picked a density that looked good and gave me the resolution I wanted when walking around on the ground. If I wanted to turn it into something for widespread use, I’d have to do a lot of tweaks. Also the GPU terrain generation doesn’t resolve the same on all GPUs so I’d have to do something for that as well.

cheap ass windows laptop, not unlike the most of non-mobile world

1 Like

Yeah. I’ll have to lower the density when I dig back into it again.