The procedural generation of space implies the usage of mechanisms that create its elements independently of each other. In the future, to describe these elements, we will use the term - sector. Last time, we used the previously prepared Perlin Noise generation algorithm to simulate the earth’s surface and convert it into an isometric tilemap. The next steps will be the division of the map into sectors, their separate generation, and subsequent merging into one united object.
Goal
Generate data for the sector with Noise Perlin and combine several sectors into a united map covering the camera view.
Isometric view
Previously we use standard Unity mechanisms for isometric maps. Working with coordinates did not go beyond the usual positioning of tiles one after another and did not require a deep understanding of the process. However, our further actions will need a basic knowledge of isometric space, so let’s take a little look at the theory. As always, I’ll try not to go too deep and consider only the important for the current article elements. For more information, you can always turn to the Internet, as an option, start with Wikipedia.
Isometric projection is a method for visually representing three-dimensional objects in two dimensions with different distortion coefficients along the coordinate axes. Last time, we chose the isometric map where positioning uses the X and Y axes, and the height set with the Z-axis. Accordingly, we would like to note our transition to three-dimensional space and define terms for determining the direction. On the X-axis, we will count the length; on the Y-axis - the width and along the Z-axis - the height. Height is currently not a significant factor for us, so we will omit its presence and focus on the X and Y axes.
The simplest way to representing space is to display it on a plane, for example, the coordinates of tiles in two-dimensional space:
By default, the coordinate axes in Unity isometric tilemaps directed upward (relative to the display on the screen), respectively, this is the isometric projection of our space:
Sector
Sector - is a group of tiles with the same length and width (the height, as we agreed earlier, is omitted). I note right away that sectors behave in space in the same way as individual tiles so that we can ignore what objects we are currently dealing with. Usually, we will operate with sectors.
The sector in two-dimensional space:
The sector in isometric space:
Display isometric space on the screen
I think it is clear that changing the direction of the coordinate axes changes the application of length and width to determine the number of rows and columns needed to fill the entire screen. Direct usage of length and width will either leave the corners of the screen unfilled or will place many of the sectors beyond the boundaries of the visible region.
The most efficient sector allocation looks like that:
The number of columns can be calculated using the formula int sectorCountX = Mathf.CeilToInt (cameraWidth / (TileMapSector.SectorSizeInTiles * cellSize.x)) + 1; additional sector closes the extreme values.
Accordingly, the formula for the number of rows is int sectorCountY = 2 * (Mathf.CeilToInt (cameraHeight / (TileMapSector.SectorSizeInTiles * cellSize.y)) + 1); where an additional sector also closes the extreme values, and a doubled size is taken due to the shift of the lines relative to each other only half the height.
This formula will not give us perfect placement and will contain an equal number of sectors in each row. Still, now we will step aside resource optimization to reduce code complexity.
Screen filling
The cherry on the cake will be an algorithm for this “effective” sector allocation.
To offset along the rows, we use a sequential increase of the X and Y coordinates. For offset along with the columns, according to the isometric coordinate grid, it is necessary to increase X and to decrease Y by one.
Implementation
The central part of this article is the addition of the sectors, which required a little refactoring of the existed codebase and solving of the following four tasks:
Calculation of bias for generating Perlin Noise
Calculation of the number of sectors needed to fill the screen
Calculation of the position of sectors in space
Sector placement for screen-filling
Noise generator refactoring
The noise generator settings move to a separate structure; this will simplify their transfer between different objects.
usingUnityEngine;publicstaticclassNoiseMapGenerator{publicstaticfloat[]GenerateNoiseMap(GeneratorSettingssettings,Vector2offset){// Offset from settings
Vector2generationOffset=offset+settings.offset;// The sizes of the generated zone are equal to the size of the sector by default
returnGenerateNoiseMap(TileMapSector.SectorSizeInTiles,TileMapSector.SectorSizeInTiles,settings.seed,settings.scale,settings.octaves,settings.persistence,settings.lacunarity,generationOffset);}publicstaticfloat[]GenerateNoiseMap(intwidth,intheight,intseed,floatscale,intoctaves,floatpersistence,floatlacunarity,Vector2offset){// An array of vertex data, a one-dimensional view will help get rid of unnecessary cycles later
float[]noiseMap=newfloat[width*height];// Seed element
System.Randomrand=newSystem.Random(seed);// Octave shift to get a more interesting picture with overlapping
Vector2[]octavesOffset=newVector2[octaves];for(inti=0;i<octaves;i++){// Also use external position shift
floatxOffset=rand.Next(-100000,100000)+offset.x;floatyOffset=rand.Next(-100000,100000)+offset.y;octavesOffset[i]=newVector2(xOffset/width,yOffset/height);}if(scale<0){scale=0.0001f;}// For a more visually pleasant zoom shift our view to the center
floathalfWidth=width/2f;floathalfHeight=height/2f;// Generate points for a heightmap
for(inty=0;y<height;y++){for(intx=0;x<width;x++){// Set the values for the first octave
floatamplitude=1;floatfrequency=1;floatnoiseHeight=0;floatsuperpositionCompensation=0;// Octave Overlay Processing
for(inti=0;i<octaves;i++){// Calculate the coordinates to get from Noise Perlin
floatxResult=(x-halfWidth)/scale*frequency+octavesOffset[i].x*frequency;floatyResult=(y-halfHeight)/scale*frequency+octavesOffset[i].y*frequency;// Obtaining Altitude from the PRNG
floatgeneratedValue=Mathf.PerlinNoise(xResult,yResult);// Octave overlay
noiseHeight+=generatedValue*amplitude;// Compensate octave overlay to stay in a range [0,1]
noiseHeight-=superpositionCompensation;// Calculation of amplitude, frequency and superposition compensation for the next octave
amplitude*=persistence;frequency*=lacunarity;superpositionCompensation=amplitude/2;}// Save heightmap point
// Due to the superposition of octaves, there is a chance of going out of the range [0,1]
noiseMap[y*width+x]=Mathf.Clamp01(noiseHeight);}}returnnoiseMap;}}
Refactoring of the test displaying of the generated space
Test rendering is separated from the main class responsible for the generation of isometric maps and repeats the algorithm for generating isometric structures, but for a two-dimensional texture.
usingSystem;usingSystem.Collections.Generic;usingUnityEngine;usingUnityEngine.Tilemaps;publicclassNoiseMapRenderer:MonoBehaviour{// Structure with the dependence of the pixel color to the height of the map
[Serializable]publicstructTerrainLevel{publicfloatheight;publicColorcolor;} [SerializeField]publicList<TerrainLevel>terrainLevel=newList<TerrainLevel>();// Function for generating a test texture with the parameters specified for the noise generator
// Used from the editor extension
publicvoidGenerateMap(){// Translation of tile settings into the structure of the dependence of pixel color on height
TileMapHandlerhandler=FindObjectOfType<TileMapHandler>();List<Tile>tileList=handler.tileList;terrainLevel=newList<TerrainLevel>();floatheightOffset=1.0f/tileList.Count;for(inti=0;i<tileList.Count;i++){Colorcolor=tileList[i].sprite.texture.GetPixel(tileList[i].sprite.texture.width/2,tileList[i].sprite.texture.height/2);TerrainLevellev=newTerrainLevel();lev.color=color;lev.height=Mathf.InverseLerp(0,tileList.Count,i)+heightOffset;terrainLevel.Add(lev);}// Render a test display of space for our camera
Vector2IntsectorSize=handler.GetScreenSizeInSectors();RenderMap(sectorSize.x,sectorSize.y,handler.generatorSettings);}// Create and draw a noise-based texture
publicvoidRenderMap(intsectorLength,intsectorWidth,GeneratorSettingssettings){// Delete previously generated textures for editor mode
for(inti=transform.childCount;i>0;--i)DestroyImmediate(transform.GetChild(0).gameObject);// Initial parameters for sectors traversing
intstartX=-sectorLength;intstartY=0;intcurrentSectorX=startX;intcurrentSectorY=startY;boolincreaseX=true;// We shift the index by one to optimize the use of the loop with the remainder of the division
intlastSectorIndex=sectorLength*sectorWidth+1;for(intsectorIndex=1;sectorIndex<lastSectorIndex;sectorIndex++){// Generate the current sector
GenerateSector(newVector2Int(currentSectorX,currentSectorY),settings);// Move to the next column
currentSectorX++;currentSectorY--;// Go to the next line
if(sectorIndex%sectorLength==0){// Go to the next line, alternating between increasing X and Y
if(increaseX){currentSectorX=++startX;currentSectorY=startY;increaseX=false;}else{currentSectorX=startX;currentSectorY=++startY;increaseX=true;}}}}privatevoidGenerateSector(Vector2IntsectorCoordinates,GeneratorSettingssettings){// Calculate offset of the sector for the noise generator
intsectorSize=TileMapSector.SectorSizeInTiles;floatsectorOffsetX=sectorSize*sectorCoordinates.x*sectorSize/settings.scale;floatsectorOffsetY=sectorSize*sectorCoordinates.y*sectorSize/settings.scale;Vector2sectorOffset=newVector2(sectorOffsetX,sectorOffsetY);// Noise generation
float[]noiseMap=NoiseMapGenerator.GenerateNoiseMap(settings,sectorOffset);// Generate and position the sector texture
GameObjectsectorSprite=newGameObject();SpriteRendererspriteRenderer=sectorSprite.AddComponent<SpriteRenderer>();Texture2Dtexture=newTexture2D(sectorSize,sectorSize){wrapMode=TextureWrapMode.Clamp,filterMode=FilterMode.Point};texture.SetPixels(GenerateColorMap(noiseMap));texture.Apply();spriteRenderer.sprite=Sprite.Create(texture,newRect(0.0f,0.0f,texture.width,texture.height),newVector2(0.5f,0.5f),100.0f);sectorSprite.transform.SetParent(transform,false);sectorSprite.name=""+sectorCoordinates.x+"_"+sectorCoordinates.y;floatpositionOffsetMultiplier=sectorSize*transform.localScale.x/spriteRenderer.sprite.pixelsPerUnit;sectorSprite.transform.Translate(newVector3(sectorCoordinates.x*positionOffsetMultiplier,sectorCoordinates.y*positionOffsetMultiplier),Space.Self);}// Convert noise to color to render the texture
privateColor[]GenerateColorMap(float[]noiseMap){Color[]colorMap=newColor[noiseMap.Length];for(inti=0;i<noiseMap.Length;i++){// Basic is the color with the highest level
colorMap[i]=terrainLevel[terrainLevel.Count-1].color;foreach(varlevelinterrainLevel){// Choose a color depending on the noise level
if(noiseMap[i]<level.height){colorMap[i]=level.color;break;}}}returncolorMap;}}
usingSystem.Collections.Generic;usingUnityEngine;usingUnityEngine.Tilemaps;publicstaticclassTileMapSector{publicstaticintSectorSizeInTiles=10;publicstaticvoidGenerateSector(float[]noiseMap,List<Tile>tileList,TilemaptileMap){// Sizes of the sector
intlength=SectorSizeInTiles;intwidth=SectorSizeInTiles;// Clear previous values
tileMap.ClearAllTiles();// Bypass all sector tiles
for(inty=0;y<length;y++){for(intx=0;x<width;x++){// Noise level for the current tile
floatnoiseHeight=noiseMap[length*y+x];// The levels of the generated surface are evenly distributed on the noise scale
// "Stretch" the noise scale to the size of the array of tiles
floatcolorHeight=noiseHeight*tileList.Count;// Select a tile below the resulting value
intcolorIndex=Mathf.FloorToInt(colorHeight);// We consider addressing in arrays for maximum noise values
if(colorIndex==tileList.Count){colorIndex=tileList.Count-1;}// Tile assets allow you to use a height of 2z
// Therefore, we "stretch" the noise scale more than two times with color
floattileHeight=noiseHeight*tileList.Count*2;inttileHeightIndex=Mathf.FloorToInt(tileHeight);// We shift the obtained height to align the tiles with water and the first level of sand
tileHeightIndex-=4;if(tileHeightIndex<0){tileHeightIndex=0;}// Take an asset tile depending on the converted noise level
Tiletile=tileList[colorIndex];// Set the tile height depending on the converted noise level
Vector3Intp=newVector3Int(x-length/2,y-width/2,tileHeightIndex);tileMap.SetTile(p,tile);}}}}
Calculation of bias for generating Perlin Noise
In our implementation of the generator, accounting for coordinate offsets to obtain noise looks like this:
// Octave shift to get a more interesting picture with overlapping
Vector2[]octavesOffset=newVector2[octaves];for(inti=0;i<octaves;i++){// Also use external position shift
floatxOffset=rand.Next(-100000,100000)+offset.x;floatyOffset=rand.Next(-100000,100000)+offset.y;octavesOffset[i]=newVector2(xOffset/width,yOffset/height);}// For a more visually pleasant zoom shift our view to the center
floathalfWidth=width/2f;floathalfHeight=height/2f;// Generate points for a heightmap
for(inty=0;y<height;y++){for(intx=0;x<width;x++){// Octave Overlay Processing
for(inti=0;i<octaves;i++){// Calculate the coordinates to get from Perlin Noise
floatxResult=(x-halfWidth)/scale*frequency+octavesOffset[i].x*frequency;floatyResult=(y-halfHeight)/scale*frequency+octavesOffset[i].y*frequency;}}}
Accordingly, for the correct calculation of the displacement of sectors, the following formula is used:
usingSystem.Collections.Generic;usingUnityEngine;usingUnityEngine.Tilemaps;publicclassTileMapHandler:MonoBehaviour{// Prefab of the tilemap
publicGameObjecttileMapPrefab=null;// Grid for displaying generated tilemaps
publicGridgrid=null;// List of tiles
publicList<Tile>tileList=newList<Tile>();// Generator settings
[SerializeField]publicGeneratorSettingsgeneratorSettings;// List of generated maps
privatereadonlyList<Transform>_tileMaps=newList<Transform>();// Start is called before the first frame update
voidStart(){// Hide the test texture
NoiseMapRenderermapRenderer=FindObjectOfType<NoiseMapRenderer>();mapRenderer.gameObject.SetActive(false);// Screen size in sectors
Vector2IntscreenSizeInSectors=GetScreenSizeInSectors();// Parameters for positioning sectors in space
vartileSize=grid.cellSize;floatpositionOffsetMultiplierX=TileMapSector.SectorSizeInTiles*tileSize.x*0.5f;floatpositionOffsetMultiplierY=TileMapSector.SectorSizeInTiles*tileSize.y*0.5f;// Parameters for traversing tiles by row columns
intstartX=-screenSizeInSectors.x;intstartY=0;intcurrentSectorX=startX;intcurrentSectorY=startY;boolincreaseX=true;// We shift the index by one to optimize the use of the loop with the remainder of the division
intlastSectorIndex=screenSizeInSectors.x*screenSizeInSectors.y+1;for(intcurrentSectorIndex=1;currentSectorIndex<lastSectorIndex;currentSectorIndex++){// Sector object
Transformsector=GetTileMapSector(newVector2Int(currentSectorX,currentSectorY));sector.SetParent(grid.transform);// Calculate the position of the sector in space
floatpositionX=currentSectorX*positionOffsetMultiplierX-currentSectorY*positionOffsetMultiplierX;floatpositionY=currentSectorX*positionOffsetMultiplierY+currentSectorY*positionOffsetMultiplierY;sector.Translate(newVector3(positionX,positionY,0),Space.Self);sector.name=""+currentSectorX+","+currentSectorY;// Save the sector
_tileMaps.Add(sector);// Move to the next column
currentSectorX++;currentSectorY--;// Go to the next line
if(currentSectorIndex%screenSizeInSectors.x==0){if(increaseX){currentSectorX=++startX;currentSectorY=startY;increaseX=false;}else{currentSectorX=startX;currentSectorY=++startY;increaseX=true;}}}}// Screen size in sectors
publicVector2IntGetScreenSizeInSectors(){// Camera height
floatcameraHeight=Camera.main.orthographicSize*2;// Camera width equals camera height * aspect ratio
floatscreenAspect=Camera.main.aspect;floatcameraWidth=cameraHeight*screenAspect;// Calculation of the number of tiles for tiling the screen
varcellSize=grid.cellSize;intsectorCountX=Mathf.CeilToInt(cameraWidth/(TileMapSector.SectorSizeInTiles*cellSize.x))+1;intsectorCountY=2*(Mathf.CeilToInt(cameraHeight/(TileMapSector.SectorSizeInTiles*cellSize.y))+1);returnnewVector2Int(sectorCountX,sectorCountY);}// Sector generation
privateTransformGetTileMapSector(Vector2Intsector){// Tile map object
GameObjectsectorGameObject=Instantiate(tileMapPrefab);// Calculate the sector offset for the noise generator
intsectorSize=TileMapSector.SectorSizeInTiles;floatsectorOffsetX=sectorSize*sector.x*sectorSize/generatorSettings.scale;floatsectorOffsetY=sectorSize*sector.y*sectorSize/generatorSettings.scale;Vector2sectorOffset=newVector2(sectorOffsetX,sectorOffsetY);TilemapsectorTileMap=sectorGameObject.GetComponent<Tilemap>();// Tile map sector generation
TileMapSector.GenerateSector(NoiseMapGenerator.GenerateNoiseMap(generatorSettings,sectorOffset),tileList,sectorTileMap);returnsectorGameObject.transform;}}
Result
Test texture:
The result of generating an isometric map divided into sectors:
Conclusion
The addition of sectors is a small step for our example of procedural generation. Still, it solved such critical tasks as positioning the generation area of Perlin Noise, underlying mechanisms for working with an isometric coordinate system and dividing space into sectors. These three tasks are preparation for dynamically sector loading with movement and actual creation of infinite space. But we will deal with this in the next article of our
series. See you next time! =)