This is part 27 of a tutorial series about hexagon maps. The previous part completed the procedural terrain generator. In this final installment we add support for wrapping the map, by connecting the east and west edges.

This tutorial is made with Unity 2017.3.0p3.

We can already use the diameter in three places. First, in HexGrid.CreateCell , when positioning the new cell.

When working with wrapping maps, we're going to deal a lot with positions along the X dimension, measured in cell width. While we can use HexMetrics.innerRadius * 2f for that, it's convenient if we don't have to add the multiplication all the time. So let's add an additional HexMetrics.innerDiameter constant.

As this data doesn't survive recompilation while in play mode, set it in OnEnable as well.

A wrapping map requires quite some changes to logic, for example when calculating distances. So this can affect code that doesn't have a direct reference to the grid. Instead of passing this information as arguments all the time, let's add it to HexMetrics . Introduce a static integer that contains the wrap size, which matches the width of the map. If it's larger than zero, then we have a wrapping map. Add a convenient property to check this.

When loading, only read it when we have to correct file version. If not, we have and older map so it doesn't wrap. Store this info in a local variable and compare it with the correct wrapping state. If it's different, we cannot reuse the existing map topology, just like we cannot if we loaded different dimensions.

Because wrapping is defined per map, it should be saved and loaded as well. This means we have to adjust our save file format, so increment the version constant in SaveLoadMenu .

HexGrid invokes its own CreateMap in two places. We can just use its own field for the wrapping argument.

HexGrid should know whether it's currently wrapping, so give it a field for that and have CreateMap set it. Other classes will need to change their logic based on whether the grid wraps, so make the field public. This also makes it possible to set the default value via the inspector.

Adjust HexMapGenerator.GenerateMap so it accepts this new argument, then passes it on to HexGrid.CreateMap .

Add a field to keep track of this choice in NewMapMenu , along with a method to change it. Have the new toggle invoke this method when its state changes.

Whether you want a wrapping map depends on whether you're going for a local or planetary scale. We can support both by making wrapping optional. Add a new toggle to the Create New Map menu to make this possible, with wrapping as the default choice.

There are two ways to approach cylindrical wrapping. The first approach is to actually make the map cylindrical, bending its surface and everything on it so the east and west sides touch each other. You're no longer playing on a flat surface, but a real cylinder. The second approach is to stick with a flat map and use teleportation or duplication to make the wrapping work. Most games use the second approach and so will we.

If you wrap both east–west and north– south, you end up with the topology of a torus. So that's not a valid representation of a spherical body, although there are games that use that wrapping method. This tutorial only covers east–west wrapping, but you could add north–south wrapping as well, using the same approaches. It just requires more work and other metrics.

A cylinder is a poor approximation of a sphere, as it cannot represent the poles. But this hasn't stopped many games from using east–west wrapping to represent planetary maps. The polar regions are simply not part of the playable area.

We cannot wrap a hexagonal grid around a sphere, such a tiling is impossible. The best approximations use an icosahedral topology, which requires twelve cells to be pentagons. However, wrapping the grid around a cylinder is possible without distortions or exceptions. This is simply a matter of connecting the east and west sides of the map. Besides the wrapping logic, everything else can remain the same.

Our maps can be used to represent areas of varying sizes, but they're always constrained to a rectangular shape. We could make a map for a single island or an entire continent, but not an entire planet. Planets are spherical, without hard boundaries to block travel on their surface. Keep going in one direction, and at some point you'll come back to where you started.

Finally, in Water.cginc we've used 0.015 for the foam and 0.025 for the waves. Once again we can substitute double and triples the tiling scale.

We used 0.025 for the noise UV in the Roads shader. We can use three times the tiling scale instead, which at 0.02598076212 is a close match.

We've used 0.02 for our UV scale in the Terrain shader. We can use twice the tiling scale instead, which would be 0.01732050808. It's a little smaller than it used to be, scaling up the texture a bit, but its not a visually obvious change.

This leads to a tiling scale of 0.00866025404. If we use an integer multiple of that, the texturing won't be affected by chunk teleportation. Also, the textures of the east and west edges map edge will align seamlessly, once we correctly triangulate their connection.

We can solve this problem by making sure that the textures tile in multiples of the chunk size. The chunk size is derived from constants in HexMetrics , so let's create a HexMetrics.cginc shader include file and put the relevant definitions in there. The base tiling scale is derived from the chunk size and outer cell radius. If you happen to use different metrics, you have to adjust this file as well.

Besides the triangulation gap, the camera wrapping should be unnoticeable in the game view. However, there is a visual change in half the terrain and water when that happens. That's because we use the world position to sample these textures. Suddenly teleporting a chunk changes the texture alignment.

To wrap the camera too, remove the clamping of its X coordinate in WrapPosition . Instead, keep increasing X by the map width while it's below zero, and keep decreasing it while it's greater than the map width.

While we're still clamping the camera's movement, the map now tries to stay centered on the camera, teleporting chunk columns as needed. This is obvious when using a small map and a zoomed-out view, but on a large map the teleporting chunks are out of view of the camera. The original east–west edges of the map are only obvious because there is no triangulation between them yet.

Change HexMapCamera.AdjustPosition so it invokes WrapPosition instead of ClampPosition when we're dealing with a wrapping map. Initially, simply make the new WrapPosition method a duplicate of ClampPosition , with the only difference that it invokes CenterMap at the end.

For each column, check whether its index is smaller than the minimum index. If so, it's too far to the left of the center. It has to teleport to the other side of the map. This is done by making its X coordinate equal to the map width. Likewise, if the column's index is greater than the maximum index, then it's too far to the right of the center and has to be teleported in the other direction.

Note that these indices can be negative or greater that the natural maximum column index. The minimum is only zero if the camera ends up near the natural center of the map. Our job is to move columns around so they align with these relative indices. We do this by adjusting the local X coordinate of each column in a loop.

Now that we know the center column index, we can determine the minimum and maximum indices too, by simply subtracting and adding half the amount of columns. As we're using integers, this works perfectly when we have an odd number of columns. In the case of an even number there cannot be a perfectly-centered column, so one of the indices will be one step too far away. This causes a single-column bias in the direction to the farthest map edge, but that isn't a problem.

We only have to adjust the map visualization when the center column index changes. So let's keep track of it in a field. Use a default value of −1 and when creating a map, so new maps will always get centered.

Add a new CenterMap method to HexGrid , with an X position as parameter. Convert the position to a column index by dividing it by the chunk width in units. This is the index of the column that the camera is currently in, which means that it should be the center column of the map.

Because all chunks are now children of the columns, we can suffice with directly destroying all columns instead of the chunks in CreateMap . That will get rid of the chunk children as well.

Chunk should now become children of the appropriate column, instead of the grid.

As we'll move entire columns of chunks at the same time, let's group them by creating a column parent object per group. Add an array for these objects to HexGrid and initialize it in CreateChunks . We only use them as containers, so we only need to keep track of a reference to their Transform components. Just like the chunks, their initial positions are all at the local origin of the grid.

Ideally, whenever the camera moves to an adjacent column of cells, we immediately transplant the furthest cell column to the other side. However, we don't need to be so precise. Instead, we can transplant entire map chunks. This allows us to move parts of the map without having to change any meshes.

To keep the map visualization centered on the camera, we have to change where things are in response to the camera's movement. If it moves to the west, we have to take what's on the currently far east side and move that to the far west side. The same goes for the opposite direction.

When a map doesn't wrap, it has a well-defined east and west edge, and thus also a well-defined horizontal center. This isn't the case for a wrapping map. It doesn't have east and west edges, so also no center. Alternatively, we can say that the center is wherever the camera happens to be. This is useful, because we'd like the map to always be centered on our point of view. Then no matter where we are, we never see either an east or west edge of the map.

Connecting East and West

At this point the only visual clue that we're wrapping the map is the small gap between the east-most and west-most columns. This gap exists because we're currently not triangulating edge and corner connections between the cells at opposite sides of the non-wrapping map.

Edge gap.

Wrapping Neighbors To triangulate the east–west connection we have to make the cells on the opposite sides of the map neighbors of each other. We're currently not doing this, because in HexGrid.CreateCell we only establish the E–W relationship with the previous cell if its X index is greater than zero. To wrap this relationship, we also have to connect the last cell of a row with the first of the same row, when wrapping is enabled. void CreateCell (int x, int z, int i) { … if (x > 0) { cell.SetNeighbor(HexDirection.W, cells[i - 1]); if (wrapping && x == cellCountX - 1) { cell.SetNeighbor(HexDirection.E, cells[i - x]); } } … } With the E–W neighbor relationships established, we now get partial triangulation across the gap. The edge connection isn't perfect, because the perturbation doesn't tile correctly. We'll deal with that later. E–W connections. We also have to wrap the NE–SW relationships. We can do this by connecting the first cell of each even row with last cells of the previous row. That's simply the previous cell. if (z > 0) { if ((z & 1) == 0) { cell.SetNeighbor(HexDirection.SE, cells[i - cellCountX]); if (x > 0) { cell.SetNeighbor(HexDirection.SW, cells[i - cellCountX - 1]); } else if (wrapping) { cell.SetNeighbor(HexDirection.SW, cells[i - 1]); } } else { … } } NE–SW connections. Finally, the wrapping SE–NW connections are established at the end of each odd row beyond the first. Those cells are to be connected to the first cell of the previous row. if (z > 0) { if ((z & 1) == 0) { … } else { cell.SetNeighbor(HexDirection.SW, cells[i - cellCountX]); if (x < cellCountX - 1) { cell.SetNeighbor(HexDirection.SE, cells[i - cellCountX + 1]); } else if (wrapping) { cell.SetNeighbor( HexDirection.SE, cells[i - cellCountX * 2 + 1] ); } } } SE–NW connections.

Wrapping Noise To make the gap perfect we have to make sure that the noise used to perturb the vertex positions matches on the east and west edges of the map. We can use the same trick that we used for the shaders, but the noise scale that we use for perturbation is 0.003. We'd have to drastically scale it up to make it tile, which would make the perturbation more erratic. An alternative approach is to not tile the noise, but to cross-fade the noise at the edge of the map. If we cross-fade across the width of a single cell, then the perturbation will transition smoothly, without discontinuities. The noise will be a bit smoothed out in this region, and from a distance the change would appear sudden, but that's not obvious when used for a little vertex perturbation. What about the temperature jitter? We also use the same noise to jitter the temperature when generating maps. The sudden cross-fade can be much more obvious here, but only when using a strong jitter. As jitter is only there to add a little subtle variety, this limitation is acceptable. If you want strong jitter, you'd have to cross-fade over a larger distance. If we're not wrapping the map, we can make do with taking a single sample in HexMetrics.SampleNoise . But when wrapping, we have to add the cross-fade. So store the sample in a variable before returning it. public static Vector4 SampleNoise (Vector3 position) { Vector4 sample = noiseSource.GetPixelBilinear( position.x * noiseScale, position.z * noiseScale ); return sample ; } When wrapping, we need a second sample to blend with. We'll perform the transition at the east side of the map, so the second sample has to be taken at the west side. Vector4 sample = noiseSource.GetPixelBilinear( position.x * noiseScale, position.z * noiseScale ); if (Wrapping && position.x < innerDiameter) { Vector4 sample2 = noiseSource.GetPixelBilinear( (position.x + wrapSize * innerDiameter) * noiseScale, position.z * noiseScale ); } The cross-fade is done with a simple linear interpolation, from the west to the east side, across the width of a single cell. if (Wrapping && position.x < innerDiameter) { Vector4 sample2 = noiseSource.GetPixelBilinear( (position.x + wrapSize * innerDiameter) * noiseScale, position.z * noiseScale ); sample = Vector4.Lerp( sample2, sample, position.x * (1f / innerDiameter) ); } Blending noise, imperfect The result isn't an exact match. That's because part of the cells on the east side have negative X coordinates. To stay away from this area, let's shift the transition region half a cell-width to the west. if (Wrapping && position.x < innerDiameter * 1.5f ) { Vector4 sample2 = noiseSource.GetPixelBilinear( (position.x + wrapSize * innerDiameter) * noiseScale, position.z * noiseScale ); sample = Vector4.Lerp( sample2, sample, position.x * (1f / innerDiameter) - 0.5f ); } Correct cross-fade.

Editing Cells Now that it seems that we have correct triangulation, let's make sure that we can edit everywhere on the map and across the wrapping seam. As it turns out, coordinates are wrong on teleported chunks and larger brushes are cut off by the seam. Brush got cut off. To fix this, we have to make HexCoordinates aware of the wrapping. We can do this by validating the X coordinate in the constructor method. We know that the axial X coordinate is derived from the X offset coordinate by subtracting half the Z coordinate. We can use this knowledge to convert back and check whether the offset coordinate is below zero. If so, we have a coordinate beyond the east side of the unwrapped map. As we teleport at most half the map in each direction, we can suffice by adding the wrap size to X once. And when the offset coordinate is greater than the wrap size, we have to subtract instead. public HexCoordinates (int x, int z) { if (HexMetrics.Wrapping) { int oX = x + z / 2; if (oX < 0) { x += HexMetrics.wrapSize; } else if (oX >= HexMetrics.wrapSize) { x -= HexMetrics.wrapSize; } } this.x = x; this.z = z; } Sometimes I get errors when editing at the bottom or top of the map? This happens when—due to perturbation—the cursor ends up in a cell row that's outside the map. This is bug, which happens because we don't validate the coordinates in HexGrid.GetCell with a vector parameter. The fix is to rely on the GetCell method with coordinates as parameter, which performs the needed checks. public HexCell GetCell (Vector3 position) { position = transform.InverseTransformPoint(position); HexCoordinates coordinates = HexCoordinates.FromPosition(position); // int index = // coordinates.X + coordinates.Z * cellCountX + coordinates.Z / 2; // return cells[index]; return GetCell(coordinates); }

Wrapping Shores Triangulation goes well for the terrain, but it appears that water shore edges are missing along the east-west seam. They're actually not missing, but they aren't wrapping. They're flipped and stretch all the way to the other side of the map. Missing water edge. This happens because we use the neighbor's position when triangulating the shore water. To fix this, we have to detect that we're dealing with a neighbor that's on the other side of the map. To make this easy, we'll add a property for a cell's column index to HexCell . public int ColumnIndex { get; set; } Assign this index in HexGrid.CreateCell . It's simply equal to the X offset coordinate divided by the chunk size. void CreateCell (int x, int z, int i) { … cell.Index = i; cell.ColumnIndex = x / HexMetrics.chunkSizeX; … } Now we can detect that we're wrapping in HexGridChunk.TriangulateWaterShore , by comparing the column index of the current cell and its neighbor. If the neighbor's column index is more than one step smaller, then we're on the west side while the neighbor is on the east side. So we have to wrap the neighbor to the west. Conversely for the other direction. Vector3 center2 = neighbor.Position; if (neighbor.ColumnIndex < cell.ColumnIndex - 1) { center2.x += HexMetrics.wrapSize * HexMetrics.innerDiameter; } else if (neighbor.ColumnIndex > cell.ColumnIndex + 1) { center2.x -= HexMetrics.wrapSize * HexMetrics.innerDiameter; } Shore edges, but not corners. This takes care of the shore edges, but not yet the corners. We have to do the same with the next neighbor too. if (nextNeighbor != null) { Vector3 center3 = nextNeighbor.Position; if (nextNeighbor.ColumnIndex < cell.ColumnIndex - 1) { center3.x += HexMetrics.wrapSize * HexMetrics.innerDiameter; } else if (nextNeighbor.ColumnIndex > cell.ColumnIndex + 1) { center3.x -= HexMetrics.wrapSize * HexMetrics.innerDiameter; } Vector3 v3 = center3 + (nextNeighbor.IsUnderwater ? HexMetrics.GetFirstWaterCorner(direction.Previous()) : HexMetrics.GetFirstSolidCorner(direction.Previous())); … } Correctly wrapped shore.