Search code examples
c#debuggingunity-game-engineterrainperlin-noise

Perlin terrain pages mismatched: elusive bug


I've been developing a game in the Unity Game Engine that I hope will be able to use paged terrain for an infinite world (common theme nowadays). My terrain generator uses perlin noise exclusively. But at this time, development has been seriously slowed by a bug: The page edges don't match up, not even close. The problem is somewhere in the code where I generate terrain, or possibly in another piece of code that runs every time I've finished generating a page. But the problem is definitely not in the unity function calls, so you can help me even if you aren't familiar with unity.

Possible causes... 1) Perlin noise sampled for the wrong location for each page, math fail. 2) Random number generator is being reseeded between the generation of pages. 3) Perlin noise is being reseeded between the generation of pages. 4) ? I don't know.

NOTE: I've pretty much ruled out numbers 2 and 3, looking at all my other code. I only seed the generators once each, and Unity wouldn't just happen to reseed Random between runs of the generator.

Actually, if you can describe a way of debugging that would make this easier, please advise. It would help me much more in the long run.

Here is the code I run once for each page, with full commenting so you don't have to know Unity3D...

 /* x and y are the indices of the tile being loaded. The game maintains a
    square of pages loaded, where the number of pages per side is equal to
    loadSquares (see below). x and y are the indices of the page to generate
    within that square. */
    public void generate(int x, int y)
    {
         /* pagePos represents the world x and y coordinates of the bottom left
            corner of the page being generated. This is given by...
              new Vector2((-(float)loadSquares / 2.0f + x + xCoord) * tileSize, 
                          (-(float)loadSquares / 2.0f + y + zCoord) * tileSize);
            This is because the origin tile's center is at 0, 0. xCoord represents
            the tile that the target object is on, which is what the loaded square
            is centered around. tileSize is the length of each side of each tile.
         */
        Vector2 pagePos = getPagePos(x, y);
        //Here I get the number of samples x and y in the heightmap.
        int xlim = td[x,y].heightmapWidth;
        int ylim = td[x,y].heightmapHeight;
            //The actual data
        float[,] array = new float[xlim, ylim];
            //These will represent the minimum and maximum values in this tile.
            //I will need them to convert the data to something unity can use.
        float min = 0.0f;
        float max = 0.0f;
        for(int cx = 0; cx < xlim; cx++)
        {
            for(int cy = 0; cy < ylim; cy++)
            {
                //Here I actually sample the perlin function.
                //Right now it doesn't look like terrain (intentionally, testing).
                array[cx,cy] = sample(
                    new Vector3((float)cx / (float)(xlim - 1) * tileSize + pagePos.x, 
                                (float)cy / (float)(ylim - 1) * tileSize + pagePos.y, 
                    122.79f));
                //On the first iteration, set min and max
                if(cx == 0 && cy == 0)
                {
                    min = array[cx,cy];
                    max = min;
                }
                else
                {
                    //update min and max
                    min = Mathf.Min(min, array[cx,cy]);
                    max = Mathf.Max(max, array[cx,cy]);
                }
            }
        }
        //Set up the Terrain object to receive the data
        float diff = max != min ? max - min : 10.0f;
        tr[x,y].position = new Vector3(pagePos.x, min, pagePos.y);
        td[x,y].size = new Vector3(tileSize, diff, tileSize);
        //Convert the data to fit in the Terrain object
     /* Unity's terrain only accepts values between 0.0f and 1.0f. Therefore,
        I shift the terrain vertically in the code above, and I
        squish the data to fit below.
     */
        for(int cx = 0; cx < xlim; cx++)
        {
            for(int cy = 0; cy < ylim; cy++)
            {
                array[cx,cy] -= min;
                array[cx,cy] /= diff;
            }
        }
        //Set the data in the Terrain object
        td[x,y].SetHeights(0, 0, array);
    }
}

Interesting and probably important detail: The tiles are properly connected with the tiles adjacent to their corners on the x/z directions. There's a sampling shift going on any time that tiles don't have the same x-to-z delta. In the image below, the right-side tile is +x and +z from the other tile. All of the tiles with this relationship are properly connected.

enter image description here

Here's the project files uploaded in a zip. Tell me if it's not working or something... http://www.filefactory.com/file/4fc75xtd3yzl/n/FPS_zip

To see the terrain, press Play after switching to testScene if it doesn't start there. GameObject generates the data and the terrain objects (it has the RandomTerrain script from scripts/General/ attached). You can modify the parameters to the perlin noise from there. Please note that right now, only the first perlin octave, o_elevator is active in the terrain generation. All of the other public perlin octave variables have no influence, for the purpose of solving this glitch.


Solution

  • TL;DR: You need to drop the getTargetCoords and use tr[x,y].position = new Vector3(pagePos.y, 0.0f, pagePos.x).

    How to get to the above: work in 1 dimension at a time. That means turning this (from your attached unity code):

                array[cx,cy] = sample(
                    new Vector3((float)cx * tileSize / (float)(xlim - 1) + pagePos.x, 0.0f,
                                (float)cy * tileSize / (float)(ylim - 1) + pagePos.y));
    

    into this:

                array[cx,cy] = sample(
                    new Vector3(
                        (float)cx * tileSize / (float)(xlim - 1) + pagePos.x,
                        0.0f,
                        0.0f
                    ) 
                ); 
    

    I quickly realized something was wrong with your getPagePos, because it was adding a value from getTargetCoords, and that function used a position which was set by the getPagePos function! Circles within circles, folks.

    protected void getTargetCoords()
    {
        xCoord = Mathf.RoundToInt(target.position.x / tileSize);
        zCoord = Mathf.RoundToInt(target.position.z / tileSize);
    }
    

    So lets lose that function and references to it. In fact, let's simplify getPagePos for now:

    protected Vector2 getPagePos(int x, int y)
    {
        return new Vector2( 0.0f,0.0f);
    }
    

    OH MY GOD IS TAWNOS CRAZY?! Well, yes, but that's beside the point. Assuming your sample function is correct (an assumption I granted you based on your assertion), then we should start from getting simple values (zero!) and verifying with each additional assumption added.

    With getPagePos simplified, let's take another look at (float)cx * tileSize / (float)(xlim - 1) + pagePos.x. What's it trying to do? Well, it appears that it's trying to find the world-coordinate point of the current sample by offsetting from the tile's origin. Let's verify that is a true assumption: there are xLim samples spread over tileSize units, so each step size in world coordinates is tileSize / xLim. Multiplying that by the current value will get our offset, so this code looks right.

    Now, let's get a simple tile offset and look that everything matches. Rather than trying to center the drop on the middle of all tiles, I'm going to do a simple grid offset (this can be adjusted later to bring back centering).

        return new Vector2( 
            tileSize * (float)x,
            0.0f
        );
    

    When you run this, you may see what the problem is pretty quickly. The bottom edge maps the top edge of the tile next to it, rather than the right edge matching the left. Without digging too deeply into it, this may be caused by the way your sampling method works. I'm not sure. Here's the updated functions:

    protected Vector2 getPagePos(int x, int y)
    {
        float halfTile = loadSquares / 2.0f;
        return new Vector2( 
            (-halfTile * tileSize) + (x * tileSize),
            (-halfTile * tileSize) + (y * tileSize)
        );
    }
    
    public void generate(int x, int y)
    {
        if(x >= loadSquares || x < 0 || y >= loadSquares || y < 0) return;
        t[x,y] = Terrain.CreateTerrainGameObject(new TerrainData()).GetComponent<Terrain>();
        t[x,y].name = "Terrain [" + x + "," + y + "]";
        td[x,y] = t[x,y].terrainData;
        td[x,y].heightmapResolution = resolution;
    
        tr[x,y] = t[x,y].transform;
        Vector2 pagePos = getPagePos(x, y);
        //Actual data generation happens here.
        int xLim = td[x,y].heightmapWidth;
        int yLim = td[x,y].heightmapHeight;
    
        float[,] array = new float[xLim, yLim];
        float min = int.MaxValue;
        float max = int.MinValue;
        for(int cx = 0; cx < xLim; cx++)
        {
            for(int cy = 0; cy < yLim; cy++)
            {
                array[cx,cy] = sample(
                    new Vector3(
                        (float)cx * (tileSize / (float)(xLim - 1)) + pagePos.x, 
                        0.0f,
                        (float)cy * (tileSize / (float)(yLim - 1)) + pagePos.y
                    ) 
                ); 
    
                if(min > array[cx,cy]) 
                    min = array[cx,cy];
                if(max < array[cx,cy]) 
                    max = array[cx,cy];
    
            }
        }
    
        //Set up the Terrain object to receive the data
        float diff = max != min ? max - min : 10.0f;
        tr[x,y].position = new Vector3(pagePos.y, min, pagePos.x);
        td[x,y].size = new Vector3(tileSize, diff, tileSize);
    
        //Convert the data to fit in the Terrain object
        for(int cx = 0; cx < xLim; cx++)
        {
            for(int cy = 0; cy < yLim; cy++)
            {
                array[cx,cy] -= min;
                array[cx,cy] /= diff;
            }
        }
        //Set the data in the Terrain object
        td[x,y].SetHeights(0, 0, array);
    }