Hexagon maps in Unity: fog of war, map research, procedural generation

Original author: Jasper Flick
  • Transfer
Parts 1-3: mesh, colors and cell heights;

Parts 4-7: bumps, rivers and roads;

Parts 8-11: water, landforms and fortress walls;

Parts 12-15: saving and loading, textures, distances;

Parts 16-19: path finding, player squads, animations

Parts 20-23: fog of war, map exploration, procedural generation

Parts 24-27: water cycle, erosion, biomes, cylindrical map

Part 20: the fog of war


  • Save the cell data in the texture.
  • Change relief types without triangulation.
  • We track visibility.
  • Darken everything invisible.

In this part, we will add the fog of war effect to the map.

Now the series will be created on Unity 2017.1.0.


Now we see that we can and cannot see.

Cell Data in the Shader


Many strategy games use the fog of war concept. This means that the player’s vision is limited. He can only see what is close to his units or controlled area. Although we can see the relief, we don’t know what is happening there. Usually the invisible terrain is rendered darker. To realize this, we need to track the visibility of the cell and render it accordingly.

The simplest way to change the appearance of hidden cells is to add a visibility metric to the mesh data. However, at the same time, we will have to start a new relief triangulation with a change in visibility. This is a bad decision because visibility is constantly changing during the game.

The technique of rendering over the topography of a translucent surface is often used, which partially masks cells invisible to the player. This method is suitable for relatively flat terrain in combination with a limited viewing angle. But since our terrain can contain very varying heights and objects that can be viewed from different angles, for this we need a highly detailed mesh that matches the shape of the terrain. This method will be more expensive than the simplest approach mentioned above.

Another approach is to transfer the data of the cells to the shader when rendering separately from the relief mesh. This will allow us to perform triangulation only once. Cell data can be transferred using texture. Changing the texture is a much simpler process than triangulating the terrain. In addition, executing several additional texture samples is faster than rendering a single translucent layer.

What about using shader arrays?
You can also transfer cell data to the shader using an array of vectors. However, shader arrays have a size limit, measured in thousands of bytes, and textures can contain millions of pixels. To support large maps, we will use textures.

Cell Data Management


We need a way to control the texture containing the cell data. Let's create a new component HexCellShaderDatathat will do this.

using UnityEngine;
public class HexCellShaderData : MonoBehaviour {
	Texture2D cellTexture;
}

When creating or loading a new map, we need to create a new texture with the correct size. Therefore, we add an initialization method that creates a texture to it. We use a RGBA texture without mip textures and linear color space. We do not need to mix cell data, so we use point filtering. In addition, data should not be collapsed. Each pixel in the texture will contain data from one cell.

	public void Initialize (int x, int z) {
		cellTexture = new Texture2D(
			x, z, TextureFormat.RGBA32, false, true
		);
		cellTexture.filterMode = FilterMode.Point;
		cellTexture.wrapMode = TextureWrapMode.Clamp;
	}

Should the texture size match the size of the map?
No, it just needs to have enough pixels to hold all the cells. With exact match to the size of the map, a texture with sizes that are not powers of two (non-power-of-two, NPOT) will most likely be created, and this texture format is not the most effective. Although we can configure the code to work with textures the size of a power of two, this is a minor optimization, which complicates access to cell data.

In fact, we don’t have to create a new texture every time we create a new map. It is enough to resize the texture if it already exists. We don’t even need to check if we already have the right size, because we Texture2D.Resizeare smart enough to do it for us.

	public void Initialize (int x, int z) {
		if (cellTexture) {
			cellTexture.Resize(x, z);
		}
		else {
			cellTexture = new Texture2D(
				cellCountX, cellCountZ, TextureFormat.RGBA32, false, true
			);
			cellTexture.filterMode = FilterMode.Point;
			cellTexture.wrapMode = TextureWrapMode.Clamp;
		}
	}

Instead of applying cell data one pixel at a time, we use a color buffer and apply the data of all cells at a time. For this we will use an array Color32. If necessary, we will create a new instance of the array at the end Initialize. If we already have an array of the correct size. then we clear its contents.

	Texture2D cellTexture;
	Color32[] cellTextureData;
	public void Initialize () {
		…
		if (cellTextureData == null || cellTextureData.Length != x * z) {
			cellTextureData = new Color32[x * z];
		}
		else {
			for (int i = 0; i < cellTextureData.Length; i++) {
				cellTextureData[i] = new Color32(0, 0, 0, 0);
			}
		}
	}

What is color32?
Standard uncompressed RGBA textures contain four-byte pixels. Each of the four color channels receives a byte, that is, they have 256 possible values. When using the Unity structure, Colorits floating-point components in the interval 0–1 are converted to bytes in the interval 0–255. When sampling, the GPU performs the inverse transform.

The structure Color32works directly with bytes, so they take up less space and do not require conversion, which increases the efficiency of their use. Since we store cell data instead of colors, it will be more logical to work directly with raw texture data, rather than with Color.

The creation and initialization of these cells in the shader should be done HexGrid. Therefore, we add a field to it cellShaderDataand create a component inside Awake.

	HexCellShaderData cellShaderData;
	void Awake () {
		HexMetrics.noiseSource = noiseSource;
		HexMetrics.InitializeHashGrid(seed);
		HexUnit.unitPrefab = unitPrefab;
		cellShaderData = gameObject.AddComponent();
		CreateMap(cellCountX, cellCountZ);
	}

When creating a new card must be initiated and cellShaderData.

	public bool CreateMap (int x, int z) {
		…
		cellCountX = x;
		cellCountZ = z;
		chunkCountX = cellCountX / HexMetrics.chunkSizeX;
		chunkCountZ = cellCountZ / HexMetrics.chunkSizeZ;
		cellShaderData.Initialize(cellCountX, cellCountZ);
		CreateChunks();
		CreateCells();
		return true;
	}

Editing Cell Data


Until now, when changing the properties of a cell, it was necessary to update one or several fragments, but now it may be necessary to update the data of the cells. This means that cells must have a link to the cell data in the shader. To do this, add property to HexCell.

	public HexCellShaderData ShaderData { get; set; }

We HexGrid.CreateCellassign this property to the shader data component.

	void CreateCell (int x, int z, int i) {
		…
		HexCell cell = cells[i] = Instantiate(cellPrefab);
		cell.transform.localPosition = position;
		cell.coordinates = HexCoordinates.FromOffsetCoordinates(x, z);
		cell.ShaderData = cellShaderData;
		…
	}

Now we can get cells to update their shader data. While we are not tracking visibility, we can use shader data for something else. The relief type of the cell determines the texture used to render it. It does not affect the geometry of the cell, so we can store the elevation type index in the cell data, and not in the mesh data. This will allow us to get rid of the need for triangulation when changing the type of relief of the cell.

Add to the HexCellShaderDatamethod RefreshTerrainto simplify this task for a specific cell. Let's leave this method empty for now.

	public void RefreshTerrain (HexCell cell) {
	}

Change it HexCell.TerrainTypeIndexso that it calls this method, and does not order to update the fragments.

	public int TerrainTypeIndex {
		get {
			return terrainTypeIndex;
		}
		set {
			if (terrainTypeIndex != value) {
				terrainTypeIndex = value;
//				Refresh();
				ShaderData.RefreshTerrain(this);
			}
		}
	}

We will also call it in HexCell.Loadafter receiving the type of terrain of the cell.

	public void Load (BinaryReader reader) {
		terrainTypeIndex = reader.ReadByte();
		ShaderData.RefreshTerrain(this);
		elevation = reader.ReadByte();
		RefreshPosition();
		…
	}

Cell index


To change these cells, we need to know the index of the cell. The easiest way to do this is by adding to the HexCellproperty Index. It will indicate the index of the cell in the list of cells in the map, which corresponds to its index in the given cells in the shader.

	public int Index { get; set; }

This index is already in HexGrid.CreateCell, so just assign it to the created cell.

	void CreateCell (int x, int z, int i) {
		…
		cell.coordinates = HexCoordinates.FromOffsetCoordinates(x, z);
		cell.Index = i;
		cell.ShaderData = cellShaderData;
		…
	}

Now it HexCellShaderData.RefreshTerraincan use this index to specify cell data. Let's save the elevation type index in the alpha component of its pixel by simply converting the type to byte. This will support up to 256 types of terrain, which will be enough for us.

	public void RefreshTerrain (HexCell cell) {
		cellTextureData[cell.Index].a = (byte)cell.TerrainTypeIndex;
	}

To apply data to a texture and pass it to the GPU, we need to call Texture2D.SetPixels32, and then Texture2D.Apply. As in the case of fragments, we will postpone these operations by LateUpdateso that they can be performed no more often than once per frame, regardless of the number of changed cells.

	public void RefreshTerrain (HexCell cell) {
		cellTextureData[cell.Index].a = (byte)cell.TerrainTypeIndex;
		enabled = true;
	}
	void LateUpdate () {
		cellTexture.SetPixels32(cellTextureData);
		cellTexture.Apply();
		enabled = false;
	}

To ensure that the data will be updated after creating a new map, enable the component after initialization.

	public void Initialize (int x, int z) {
		…
		enabled = true;
	}

Triangulation of cell indices


Since we now store the elevation type index in these cells, we no longer need to include them in the triangulation process. But in order to use cell data, the shader must know which indexes to use. Therefore, you need to store cell indices in the mesh data, replacing the elevation type indices. In addition, we still need the color channel of the mesh to mix cells when using these cells.

Remove from the HexMeshobsolete common fields useColorsand useTerrainTypes. Replace them with one field useCellData.

//	public bool useCollider, useColors, useUVCoordinates, useUV2Coordinates;
//	public bool useTerrainTypes;
	public bool useCollider, useCellData, useUVCoordinates, useUV2Coordinates;

Rename the list terrainTypesto cellIndices. Let's also refactor-rename colorsto cellWeights- this name will do better.

//	[NonSerialized] List vertices, terrainTypes;
//	[NonSerialized] List colors;
	[NonSerialized] List vertices, cellIndices;
	[NonSerialized] List cellWeights;
	[NonSerialized] List uvs, uv2s;
	[NonSerialized] List triangles;

Change Clearso that when using these cells, he gets two lists together, and not separately.

	public void Clear () {
		hexMesh.Clear();
		vertices = ListPool.Get();
		if (useCellData) {
			cellWeights = ListPool.Get();
			cellIndices = ListPool.Get();
		}
//		if (useColors) {
//			colors = ListPool.Get();
//		}
		if (useUVCoordinates) {
			uvs = ListPool.Get();
		}
		if (useUV2Coordinates) {
			uv2s = ListPool.Get();
		}
//		if (useTerrainTypes) {
//			terrainTypes = ListPool.Get();
//		}
		triangles = ListPool.Get();
	}

Perform the same grouping in Apply.

	public void Apply () {
		hexMesh.SetVertices(vertices);
		ListPool.Add(vertices);
		if (useCellData) {
			hexMesh.SetColors(cellWeights);
			ListPool.Add(cellWeights);
			hexMesh.SetUVs(2, cellIndices);
			ListPool.Add(cellIndices);
		}
//		if (useColors) {
//			hexMesh.SetColors(colors);
//			ListPool.Add(colors);
//		}
		if (useUVCoordinates) {
			hexMesh.SetUVs(0, uvs);
			ListPool.Add(uvs);
		}
		if (useUV2Coordinates) {
			hexMesh.SetUVs(1, uv2s);
			ListPool.Add(uv2s);
		}
//		if (useTerrainTypes) {
//			hexMesh.SetUVs(2, terrainTypes);
//			ListPool.Add(terrainTypes);
//		}
		hexMesh.SetTriangles(triangles, 0);
		ListPool.Add(triangles);
		hexMesh.RecalculateNormals();
		if (useCollider) {
			meshCollider.sharedMesh = hexMesh;
		}
	}

We remove all methods AddTriangleColorand AddTriangleTerrainTypes. Replace them with appropriate methods AddTriangleCellDatathat add indexes and weights at a time.

	public void AddTriangleCellData (
		Vector3 indices, Color weights1, Color weights2, Color weights3
	) {
		cellIndices.Add(indices);
		cellIndices.Add(indices);
		cellIndices.Add(indices);
		cellWeights.Add(weights1);
		cellWeights.Add(weights2);
		cellWeights.Add(weights3);
	}
	public void AddTriangleCellData (Vector3 indices, Color weights) {
		AddTriangleCellData(indices, weights, weights, weights);
	}

We do the same in the appropriate method AddQuad.

	public void AddQuadCellData (
		Vector3 indices,
		Color weights1, Color weights2, Color weights3, Color weights4
	) {
		cellIndices.Add(indices);
		cellIndices.Add(indices);
		cellIndices.Add(indices);
		cellIndices.Add(indices);
		cellWeights.Add(weights1);
		cellWeights.Add(weights2);
		cellWeights.Add(weights3);
		cellWeights.Add(weights4);
	}
	public void AddQuadCellData (
		Vector3 indices, Color weights1, Color weights2
	) {
		AddQuadCellData(indices, weights1, weights1, weights2, weights2);
	}
	public void AddQuadCellData (Vector3 indices, Color weights) {
		AddQuadCellData(indices, weights, weights, weights, weights);
	}

HexGridChunk Refactoring


At this stage, we get a HexGridChunklot of compiler errors that need to be fixed. But first, for the sake of consistency, we refactor-rename static colors to weights.

	static Color weights1 = new Color(1f, 0f, 0f);
	static Color weights2 = new Color(0f, 1f, 0f);
	static Color weights3 = new Color(0f, 0f, 1f);

Let's start with the fix TriangulateEdgeFan. He used to need a type, but now he needs a cell index. Replace the code AddTriangleColorwith the AddTriangleTerrainTypescorresponding code AddTriangleCellData.

	void TriangulateEdgeFan (Vector3 center, EdgeVertices edge, float index) {
		terrain.AddTriangle(center, edge.v1, edge.v2);
		terrain.AddTriangle(center, edge.v2, edge.v3);
		terrain.AddTriangle(center, edge.v3, edge.v4);
		terrain.AddTriangle(center, edge.v4, edge.v5);
		Vector3 indices;
		indices.x = indices.y = indices.z = index;
		terrain.AddTriangleCellData(indices, weights1);
		terrain.AddTriangleCellData(indices, weights1);
		terrain.AddTriangleCellData(indices, weights1);
		terrain.AddTriangleCellData(indices, weights1);
//		terrain.AddTriangleColor(weights1);
//		terrain.AddTriangleColor(weights1);
//		terrain.AddTriangleColor(weights1);
//		terrain.AddTriangleColor(weights1);
//		Vector3 types;
//		types.x = types.y = types.z = type;
//		terrain.AddTriangleTerrainTypes(types);
//		terrain.AddTriangleTerrainTypes(types);
//		terrain.AddTriangleTerrainTypes(types);
//		terrain.AddTriangleTerrainTypes(types);
	}

This method is called in several places. Let's go over them and make sure that the index of the cell is transferred there, and not the type of terrain.

		TriangulateEdgeFan(center, e, cell.Index);

Next TriangulateEdgeStrip. Everything is a bit more complicated here, but we use the same approach. Also, refactor-rename names of the parameters c1and c2in w1and w2.

	void TriangulateEdgeStrip (
		EdgeVertices e1, Color w1, float index1,
		EdgeVertices e2, Color w2, float index2,
		bool hasRoad = false
	) {
		terrain.AddQuad(e1.v1, e1.v2, e2.v1, e2.v2);
		terrain.AddQuad(e1.v2, e1.v3, e2.v2, e2.v3);
		terrain.AddQuad(e1.v3, e1.v4, e2.v3, e2.v4);
		terrain.AddQuad(e1.v4, e1.v5, e2.v4, e2.v5);
		Vector3 indices;
		indices.x = indices.z = index1;
		indices.y = index2;
		terrain.AddQuadCellData(indices, w1, w2);
		terrain.AddQuadCellData(indices, w1, w2);
		terrain.AddQuadCellData(indices, w1, w2);
		terrain.AddQuadCellData(indices, w1, w2);
//		terrain.AddQuadColor(c1, c2);
//		terrain.AddQuadColor(c1, c2);
//		terrain.AddQuadColor(c1, c2);
//		terrain.AddQuadColor(c1, c2);
//		Vector3 types;
//		types.x = types.z = type1;
//		types.y = type2;
//		terrain.AddQuadTerrainTypes(types);
//		terrain.AddQuadTerrainTypes(types);
//		terrain.AddQuadTerrainTypes(types);
//		terrain.AddQuadTerrainTypes(types);
		if (hasRoad) {
			TriangulateRoadSegment(e1.v2, e1.v3, e1.v4, e2.v2, e2.v3, e2.v4);
		}
	}

Change the calls to this method so that the cell index is passed to them. We also keep the variable names consistent.

		TriangulateEdgeStrip(
			m, weights1, cell.Index,
			e, weights1, cell.Index
		);
	…
			TriangulateEdgeStrip(
				e1, weights1, cell.Index,
				e2, weights2, neighbor.Index, hasRoad
			);
	…
	void TriangulateEdgeTerraces (
		EdgeVertices begin, HexCell beginCell,
		EdgeVertices end, HexCell endCell,
		bool hasRoad
	) {
		EdgeVertices e2 = EdgeVertices.TerraceLerp(begin, end, 1);
		Color w2 = HexMetrics.TerraceLerp(weights1, weights2, 1);
		float i1 = beginCell.Index;
		float i2 = endCell.Index;
		TriangulateEdgeStrip(begin, weights1, i1, e2, w2, i2, hasRoad);
		for (int i = 2; i < HexMetrics.terraceSteps; i++) {
			EdgeVertices e1 = e2;
			Color w1 = w2;
			e2 = EdgeVertices.TerraceLerp(begin, end, i);
			w2 = HexMetrics.TerraceLerp(weights1, weights2, i);
			TriangulateEdgeStrip(e1, w1, i1, e2, w2, i2, hasRoad);
		}
		TriangulateEdgeStrip(e2, w2, i1, end, weights2, i2, hasRoad);
	}

Now we move on to the angle methods. These changes are simple, but they need to be made in a large amount of code. First in TriangulateCorner.

	void TriangulateCorner (
		Vector3 bottom, HexCell bottomCell,
		Vector3 left, HexCell leftCell,
		Vector3 right, HexCell rightCell
	) {
		…
		else {
			terrain.AddTriangle(bottom, left, right);
			Vector3 indices;
			indices.x = bottomCell.Index;
			indices.y = leftCell.Index;
			indices.z = rightCell.Index;
			terrain.AddTriangleCellData(indices, weights1, weights2, weights3);
//			terrain.AddTriangleColor(weights1, weights2, weights3);
//			Vector3 types;
//			types.x = bottomCell.TerrainTypeIndex;
//			types.y = leftCell.TerrainTypeIndex;
//			types.z = rightCell.TerrainTypeIndex;
//			terrain.AddTriangleTerrainTypes(types);
		}
		features.AddWall(bottom, bottomCell, left, leftCell, right, rightCell);
	}

Further in TriangulateCornerTerraces.

	void TriangulateCornerTerraces (
		Vector3 begin, HexCell beginCell,
		Vector3 left, HexCell leftCell,
		Vector3 right, HexCell rightCell
	) {
		Vector3 v3 = HexMetrics.TerraceLerp(begin, left, 1);
		Vector3 v4 = HexMetrics.TerraceLerp(begin, right, 1);
		Color w3 = HexMetrics.TerraceLerp(weights1, weights2, 1);
		Color w4 = HexMetrics.TerraceLerp(weights1, weights3, 1);
		Vector3 indices;
		indices.x = beginCell.Index;
		indices.y = leftCell.Index;
		indices.z = rightCell.Index;
		terrain.AddTriangle(begin, v3, v4);
		terrain.AddTriangleCellData(indices, weights1, w3, w4);
//		terrain.AddTriangleColor(weights1, w3, w4);
//		terrain.AddTriangleTerrainTypes(indices);
		for (int i = 2; i < HexMetrics.terraceSteps; i++) {
			Vector3 v1 = v3;
			Vector3 v2 = v4;
			Color w1 = w3;
			Color w2 = w4;
			v3 = HexMetrics.TerraceLerp(begin, left, i);
			v4 = HexMetrics.TerraceLerp(begin, right, i);
			w3 = HexMetrics.TerraceLerp(weights1, weights2, i);
			w4 = HexMetrics.TerraceLerp(weights1, weights3, i);
			terrain.AddQuad(v1, v2, v3, v4);
			terrain.AddQuadCellData(indices, w1, w2, w3, w4);
//			terrain.AddQuadColor(w1, w2, w3, w4);
//			terrain.AddQuadTerrainTypes(indices);
		}
		terrain.AddQuad(v3, v4, left, right);
		terrain.AddQuadCellData(indices, w3, w4, weights2, weights3);
//		terrain.AddQuadColor(w3, w4, weights2, weights3);
//		terrain.AddQuadTerrainTypes(indices);
	}

Then in TriangulateCornerTerracesCliff.

	void TriangulateCornerTerracesCliff (
		Vector3 begin, HexCell beginCell,
		Vector3 left, HexCell leftCell,
		Vector3 right, HexCell rightCell
	) {
		float b = 1f / (rightCell.Elevation - beginCell.Elevation);
		if (b < 0) {
			b = -b;
		}
		Vector3 boundary = Vector3.Lerp(
			HexMetrics.Perturb(begin), HexMetrics.Perturb(right), b
		);
		Color boundaryWeights = Color.Lerp(weights1, weights3, b);
		Vector3 indices;
		indices.x = beginCell.Index;
		indices.y = leftCell.Index;
		indices.z = rightCell.Index;
		TriangulateBoundaryTriangle(
			begin, weights1, left, weights2, boundary, boundaryWeights, indices
		);
		if (leftCell.GetEdgeType(rightCell) == HexEdgeType.Slope) {
			TriangulateBoundaryTriangle(
				left, weights2, right, weights3,
				boundary, boundaryWeights, indices
			);
		}
		else {
			terrain.AddTriangleUnperturbed(
				HexMetrics.Perturb(left), HexMetrics.Perturb(right), boundary
			);
			terrain.AddTriangleCellData(
				indices, weights2, weights3, boundaryWeights
			);
//			terrain.AddTriangleColor(weights2, weights3, boundaryColor);
//			terrain.AddTriangleTerrainTypes(indices);
		}
	}

And a little different in TriangulateCornerCliffTerraces.

	void TriangulateCornerCliffTerraces (
		Vector3 begin, HexCell beginCell,
		Vector3 left, HexCell leftCell,
		Vector3 right, HexCell rightCell
	) {
		float b = 1f / (leftCell.Elevation - beginCell.Elevation);
		if (b < 0) {
			b = -b;
		}
		Vector3 boundary = Vector3.Lerp(
			HexMetrics.Perturb(begin), HexMetrics.Perturb(left), b
		);
		Color boundaryWeights = Color.Lerp(weights1, weights2, b);
		Vector3 indices;
		indices.x = beginCell.Index;
		indices.y = leftCell.Index;
		indices.z = rightCell.Index;
		TriangulateBoundaryTriangle(
			right, weights3, begin, weights1, boundary, boundaryWeights, indices
		);
		if (leftCell.GetEdgeType(rightCell) == HexEdgeType.Slope) {
			TriangulateBoundaryTriangle(
				left, weights2, right, weights3,
				boundary, boundaryWeights, indices
			);
		}
		else {
			terrain.AddTriangleUnperturbed(
				HexMetrics.Perturb(left), HexMetrics.Perturb(right), boundary
			);
			terrain.AddTriangleCellData(
				indices, weights2, weights3, boundaryWeights
			);
//			terrain.AddTriangleColor(weights2, weights3, boundaryWeights);
//			terrain.AddTriangleTerrainTypes(indices);
		}
	}

The previous two methods are used TriangulateBoundaryTriangle, which also requires updating.

	void TriangulateBoundaryTriangle (
		Vector3 begin, Color beginWeights,
		Vector3 left, Color leftWeights,
		Vector3 boundary, Color boundaryWeights, Vector3 indices
	) {
		Vector3 v2 = HexMetrics.Perturb(HexMetrics.TerraceLerp(begin, left, 1));
		Color w2 = HexMetrics.TerraceLerp(beginWeights, leftWeights, 1);
		terrain.AddTriangleUnperturbed(HexMetrics.Perturb(begin), v2, boundary);
		terrain.AddTriangleCellData(indices, beginWeights, w2, boundaryWeights);
//		terrain.AddTriangleColor(beginColor, c2, boundaryColor);
//		terrain.AddTriangleTerrainTypes(types);
		for (int i = 2; i < HexMetrics.terraceSteps; i++) {
			Vector3 v1 = v2;
			Color w1 = w2;
			v2 = HexMetrics.Perturb(HexMetrics.TerraceLerp(begin, left, i));
			w2 = HexMetrics.TerraceLerp(beginWeights, leftWeights, i);
			terrain.AddTriangleUnperturbed(v1, v2, boundary);
			terrain.AddTriangleCellData(indices, w1, w2, boundaryWeights);
//			terrain.AddTriangleColor(c1, c2, boundaryColor);
//			terrain.AddTriangleTerrainTypes(types);
		}
		terrain.AddTriangleUnperturbed(v2, HexMetrics.Perturb(left), boundary);
		terrain.AddTriangleCellData(indices, w2, leftWeights, boundaryWeights);
//		terrain.AddTriangleColor(c2, leftColor, boundaryColor);
//		terrain.AddTriangleTerrainTypes(types);
	}

The last method that requires change is TriangulateWithRiver.

	void TriangulateWithRiver (
		HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e
	) {
		…
		terrain.AddTriangle(centerL, m.v1, m.v2);
		terrain.AddQuad(centerL, center, m.v2, m.v3);
		terrain.AddQuad(center, centerR, m.v3, m.v4);
		terrain.AddTriangle(centerR, m.v4, m.v5);
		Vector3 indices;
		indices.x = indices.y = indices.z = cell.Index;
		terrain.AddTriangleCellData(indices, weights1);
		terrain.AddQuadCellData(indices, weights1);
		terrain.AddQuadCellData(indices, weights1);
		terrain.AddTriangleCellData(indices, weights1);
//		terrain.AddTriangleColor(weights1);
//		terrain.AddQuadColor(weights1);
//		terrain.AddQuadColor(weights1);
//		terrain.AddTriangleColor(weights1);
//		Vector3 types;
//		types.x = types.y = types.z = cell.TerrainTypeIndex;
//		terrain.AddTriangleTerrainTypes(types);
//		terrain.AddQuadTerrainTypes(types);
//		terrain.AddQuadTerrainTypes(types);
//		terrain.AddTriangleTerrainTypes(types);
		…
	}

For everything to work, we need to indicate that we will use the cell data for the child element of the relief of the prefab fragment.


The relief uses cell data.

At this stage, the mesh contains cell indexes instead of elevation type indices. Since the elevation shader still interprets them as elevation indices, we will see that the first cell is rendered with the first texture and so on until the last relief texture is reached.


Using cell indices as elevation texture indices.

I can not get the refactored code to work. What am I doing wrong?
At one time, we changed a large amount of triangulation code, so there is a high probability of errors or oversights. If you cannot find the error, then try downloading the package from this section and extract the appropriate files. You can import them into a separate project and compare with your own code.

Transfer cell data to a shader


To use these cells, the terrain shader must have access to them. This can be implemented through the shader property. In this case, it is required to HexCellShaderDataset the property of the relief material. Or we can make the texture of these cells globally visible to all shaders. This is convenient because we need it in several shaders, so we will use this approach.

After creating the cell texture, call the static method Shader.SetGlobalTextureto make it globally visible as _HexCellData .

	public void Initialize (int x, int z) {
		…
		else {
			cellTexture = new Texture2D(
				x, z, TextureFormat.RGBA32, false, true
			);
			cellTexture.filterMode = FilterMode.Point;
			cellTexture.wrapMode = TextureWrapMode.Clamp;
			Shader.SetGlobalTexture("_HexCellData", cellTexture);
		}
		…
	}

When using the shader property, Unity makes the texture size available to the shader via the textureName_TexelSize variable . This is a four-component vectorizer containing values ​​that are inverse to the width and height, as well as the width and height themselves. But when setting the global texture, this is not performed. Therefore, we will do it ourselves using Shader.SetGlobalVectorafter creating or resizing the texture.

		else {
			cellTexture = new Texture2D(
				x, z, TextureFormat.RGBA32, false, true
			);
			cellTexture.filterMode = FilterMode.Point;
			cellTexture.wrapMode = TextureWrapMode.Clamp;
			Shader.SetGlobalTexture("_HexCellData", cellTexture);
		}
		Shader.SetGlobalVector(
			"_HexCellData_TexelSize",
			new Vector4(1f / x, 1f / z, x, z)
		);

Shader Data Access


Create a new shader include file in the materials folder called HexCellData . Inside it, we define variables for information about the texture and size of these cells. We also create a function to get the cell data for the given vertex mesh data.

sampler2D _HexCellData;
float4 _HexCellData_TexelSize;
float4 GetCellData (appdata_full v) {
}


New include file.

Cell indices are stored in v.texcoord2, as was the case with terrain types. Let's start with the first index - v.texcoord2.x. Unfortunately, we cannot directly use the index to sample the texture of these cells. We will have to convert it to UV coordinates.

The first step in creating the U coordinate is dividing the cell index by the width of the texture. We can do this by multiplying it by _HexCellData_TexelSize.x.

float4 GetCellData (appdata_full v) {
	float2 uv;
	uv.x = v.texcoord2.x * _HexCellData_TexelSize.x;
}

The result will be a number in the form ZU, where Z is the row index and U is the coordinate of the U cell. We can extract the string by rounding the number down and then subtracting it from the number to get the U coordinate.

float4 GetCellData (appdata_full v) {
	float2 uv;
	uv.x = v.texcoord2.x * _HexCellData_TexelSize.x;
	float row = floor(uv.x);
	uv.x -= row;
}

The V coordinate is dividing the line by the height of the texture.

float4 GetCellData (appdata_full v) {
	float2 uv;
	uv.x = v.texcoord2.x * _HexCellData_TexelSize.x;
	float row = floor(uv.x);
	uv.x -= row;
	uv.y = row * _HexCellData_TexelSize.y;
}

Since we are sampling the texture, we need to use the coordinates at the centers of the pixels, not at their edges. This way we guarantee that the correct pixels are sampled. Therefore, after dividing by the size of the texture, add ½.

float4 GetCellData (appdata_full v) {
	float2 uv;
	uv.x = (v.texcoord2.x + 0.5) * _HexCellData_TexelSize.x;
	float row = floor(uv.x);
	uv.x -= row;
	uv.y = (row + 0.5) * _HexCellData_TexelSize.y;
}

This gives us the correct UV coordinates for the index of the first cell stored in the vertex data. But on top we can have up to three different indices. Therefore, we will make it GetCellDatawork for any index. Add an integer parameter to it index, which we will use to access the vector component with the cell index.

float4 GetCellData (appdata_full v, int index) {
	float2 uv;
	uv.x = (v.texcoord2[index] + 0.5) * _HexCellData_TexelSize.x;
	float row = floor(uv.x);
	uv.x -= row;
	uv.y = (row + 0.5) * _HexCellData_TexelSize.y;
}

Now that we have all the necessary coordinates for these cells, we can sample _HexCellData. Since we are sampling the texture in the vertex program, we need to explicitly tell the shader which mip texture to use. This can be done using a function tex2Dlodthat requires the coordinates of four textures. Since these cells do not have mip-textures, we assign zero values ​​to the extra coordinates.

float4 GetCellData (appdata_full v, int index) {
	float2 uv;
	uv.x = (v.texcoord2[index] + 0.5) * _HexCellData_TexelSize.x;
	float row = floor(uv.x);
	uv.x -= row;
	uv.y = (row + 0.5) * _HexCellData_TexelSize.y;
	float4 data = tex2Dlod(_HexCellData, float4(uv, 0, 0));
}

The fourth data component contains an elevation type index, which we store directly as bytes. However, the GPU automatically converted it to a floating point value in the range 0–1. To convert it back to the correct value, multiply it by 255. After that, you can return the data.

	float4 data = tex2Dlod(_HexCellData, float4(uv, 0, 0));
	data.w *= 255;
	return data;

To use this functionality, enable HexCellData in the Terrain shader . Since I placed this shader in Materials / Terrain , I need to use the relative path ../HexCellData.cginc .

		#include "../HexCellData.cginc"
		UNITY_DECLARE_TEX2DARRAY(_MainTex);

In the vertex program, we obtain cell data for all three cell indices stored in the vertex data. Then assign data.terraintheir elevation indices.

		void vert (inout appdata_full v, out Input data) {
			UNITY_INITIALIZE_OUTPUT(Input, data);
//			data.terrain = v.texcoord2.xyz;
			float4 cell0 = GetCellData(v, 0);
			float4 cell1 = GetCellData(v, 1);
			float4 cell2 = GetCellData(v, 2);
			data.terrain.x = cell0.w;
			data.terrain.y = cell1.w;
			data.terrain.z = cell2.w;
		}

At this point, the map again began to display the correct terrain. The big difference is that editing only terrain types no longer leads to new triangulations. If during editing any other cell data is changed, then triangulation will be performed as usual.

unitypackage

Visibility


Having created the basis of these cells, we can move on to support visibility. To do this, we use the shader, the cells themselves and the objects that determine the visibility. Note that the triangulation process knows absolutely nothing about this.

Shader


Let's start by telling the Terrain shader about visibility. It will receive visibility data from the vertex program and pass it to the fragment program using the structure Input. Since we pass three separate elevation indices, we will pass three visibility values ​​as well.

		struct Input {
			float4 color : COLOR;
			float3 worldPos;
			float3 terrain;
			float3 visibility;
		};

To store visibility, we use the first component of these cells.

		void vert (inout appdata_full v, out Input data) {
			UNITY_INITIALIZE_OUTPUT(Input, data);
			float4 cell0 = GetCellData(v, 0);
			float4 cell1 = GetCellData(v, 1);
			float4 cell2 = GetCellData(v, 2);
			data.terrain.x = cell0.w;
			data.terrain.y = cell1.w;
			data.terrain.z = cell2.w;
			data.visibility.x = cell0.x;
			data.visibility.y = cell1.x;
			data.visibility.z = cell2.x;
		}

A visibility of 0 means that the cell is currently invisible. If it were visible, it would have the value of visibility 1. Therefore, we can darken the terrain by multiplying the result GetTerrainColorby the corresponding vector of visibility. Thus, we individually modulate the relief color of each mixed cell.

		float4 GetTerrainColor (Input IN, int index) {
			float3 uvw = float3(IN.worldPos.xz * 0.02, IN.terrain[index]);
			float4 c = UNITY_SAMPLE_TEX2DARRAY(_MainTex, uvw);
			return c * (IN.color[index] * IN.visibility[index]);
		}


Cells turned black.

Can't we instead combine visibility in a vertex program?
This is also possible, and in this case, a fragmentary program will only need to transmit one indicator of visibility. When transmitting indicators for each cell involved in the mixing, three terrain samples are mixed in isolation. As a result, the visible cells will contribute more to the mixing area. When using one indicator, you must first perform the mixing, after which the final interpolated visibility is applied. Both approaches will work, but visually they will be different.

Complete darkness is a bust for temporarily invisible cells. So that we can still see the relief, we need to increase the indicator used for hidden cells. Let's move from 0–1 to ¼ – 1, which can be done using the function lerpat the end of the vertex program.

		void vert (inout appdata_full v, out Input data) {
			…
			data.visibility.x = cell0.x;
			data.visibility.y = cell1.x;
			data.visibility.z = cell2.x;
			data.visibility = lerp(0.25, 1, data.visibility);
		}


Shaded cells.

Cell visibility tracking


For visibility to work, cells must track their visibility. But how does a cell determine if it is visible? We can do this by tracking the number of entities that see it. When someone begins to see a cell, he must report this cell. And when someone stops seeing the cell, he must also notify her about it. The cell simply keeps track of the number of watchers, whatever those entities are. If a cell has a visibility value of at least 1, then it is visible, otherwise it is invisible. To implement this behavior, we add HexCelltwo methods and a property to the variable.

	public bool IsVisible {
		get {
			return visibility > 0;
		}
	}
	…
	int visibility;
	…
	public void IncreaseVisibility () {
		visibility += 1;
	}
	public void DecreaseVisibility () {
		visibility -= 1;
	}

Next, add to the HexCellShaderDatamethod RefreshVisibility, which does the same thing as RefreshTerrain, just for the sake of visibility. Save the data in the component R of the data cells. Since we work with bytes that are converted to values ​​0–1, we use to indicate visibility (byte)255.

	public void RefreshVisibility (HexCell cell) {
		cellTextureData[cell.Index].r = cell.IsVisible ? (byte)255 : (byte)0;
		enabled = true;
	}

We will call this method with increasing and decreasing visibility, changing the value between 0 and 1.

	public void IncreaseVisibility () {
		visibility += 1;
		if (visibility == 1) {
			ShaderData.RefreshVisibility(this);
		}
	}
	public void DecreaseVisibility () {
		visibility -= 1;
		if (visibility == 0) {
			ShaderData.RefreshVisibility(this);
		}
	}

Creating squad visibility


Let's make it so that the units can see the cell they occupy. This is accomplished using a call IncreaseVisibilityto the new location of the unit during the task HexUnit.Location. We also call for the old location (if it exists) DecreaseVisibility.

	public HexCell Location {
		get {
			return location;
		}
		set {
			if (location) {
				location.DecreaseVisibility();
				location.Unit = null;
			}
			location = value;
			value.Unit = this;
			value.IncreaseVisibility();
			transform.localPosition = value.Position;
		}
	}


Units can see where they are.

Finally we we used visibility! When added to a map, units make their cell visible. In addition, their scope is teleported when moving to their new location. But their scope remains active when removing units from the map. To fix this, we will reduce the visibility of their location when destroying units.

	public void Die () {
		if (location) {
			location.DecreaseVisibility();
		}
		location.Unit = null;
		Destroy(gameObject);
	}

Visibility range


So far, we see only the cell in which the detachment is located, and this limits the possibilities. At least we need to see neighboring cells. In the general case, units can see all cells within a certain distance, which depends on the unit.

Let's add to the HexGridmethod to find all the cells visible from one cell taking into account the range. We can create this method by duplicating and changing Search. Change its parameters and make it return a list of cells for which you can use the list pool.

At each iteration, the current cell is added to the list. There is no longer any final cell, so the search will never end when it reaches this point. We also get rid of the logic of moves and the cost of moving. Make the propertiesPathFromthey were no longer asked because we do not need them, and we do not want to interfere with the path along the grid.

At each step, the distance simply increases by 1. If it exceeds the range, then this cell is skipped. And we do not need a search heuristic, so we initialize it with a value of 0. That is, in essence, we returned to the Dijkstra algorithm.

	List GetVisibleCells (HexCell fromCell, int range) {
		List visibleCells = ListPool.Get();
		searchFrontierPhase += 2;
		if (searchFrontier == null) {
			searchFrontier = new HexCellPriorityQueue();
		}
		else {
			searchFrontier.Clear();
		}
		fromCell.SearchPhase = searchFrontierPhase;
		fromCell.Distance = 0;
		searchFrontier.Enqueue(fromCell);
		while (searchFrontier.Count > 0) {
			HexCell current = searchFrontier.Dequeue();
			current.SearchPhase += 1;
			visibleCells.Add(current);
//			if (current == toCell) {
//				return true;
//			}
//			int currentTurn = (current.Distance - 1) / speed;
			for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) {
				HexCell neighbor = current.GetNeighbor(d);
				if (
					neighbor == null ||
					neighbor.SearchPhase > searchFrontierPhase
				) {
					continue;
				}
//				…
//				int moveCost;
//				…
				int distance = current.Distance + 1;
				if (distance > range) {
					continue;
				}
//				int turn = (distance - 1) / speed;
//				if (turn > currentTurn) {
//					distance = turn * speed + moveCost;
//				}
				if (neighbor.SearchPhase < searchFrontierPhase) {
					neighbor.SearchPhase = searchFrontierPhase;
					neighbor.Distance = distance;
//					neighbor.PathFrom = current;
					neighbor.SearchHeuristic = 0;
					searchFrontier.Enqueue(neighbor);
				}
				else if (distance < neighbor.Distance) {
					int oldPriority = neighbor.SearchPriority;
					neighbor.Distance = distance;
//					neighbor.PathFrom = current;
					searchFrontier.Change(neighbor, oldPriority);
				}
			}
		}
		return visibleCells;
	}

Can't we use a simpler algorithm to find all the cells within range?
We can, but this approach allows us to support more complex visibility algorithms that we will implement in the future tutorial.

Also add HexGridmethods IncreaseVisibilityand DecreaseVisibility. They get the cell and range, take a list of the corresponding cells and increase / decrease their visibility. When done, they should return the list back to its pool.

	public void IncreaseVisibility (HexCell fromCell, int range) {
		List cells = GetVisibleCells(fromCell, range);
		for (int i = 0; i < cells.Count; i++) {
			cells[i].IncreaseVisibility();
		}
		ListPool.Add(cells);
	}
	public void DecreaseVisibility (HexCell fromCell, int range) {
		List cells = GetVisibleCells(fromCell, range);
		for (int i = 0; i < cells.Count; i++) {
			cells[i].DecreaseVisibility();
		}
		ListPool.Add(cells);
	}

To use these methods HexUnitrequires access to the grid, so add a property to it Grid.

	public HexGrid Grid { get; set; }

When you add a squad to a grid, it will assign a grid to this property HexGrid.AddUnit.

	public void AddUnit (HexUnit unit, HexCell location, float orientation) {
		units.Add(unit);
		unit.Grid = this;
		unit.transform.SetParent(transform, false);
		unit.Location = location;
		unit.Orientation = orientation;
	}

To begin with, a range of visibility of three cells will be enough. To do this, we add to the HexUnitconstant, which in the future can always turn into a variable. Then we will make the squad invoke methods for the grid IncreaseVisibilityand DecreaseVisibility, transmitting also its visibility range, and not just go to this place.

	const int visionRange = 3;
	…
	public HexCell Location {
		get {
			return location;
		}
		set {
			if (location) {
//				location.DecreaseVisibility();
				Grid.DecreaseVisibility(location, visionRange);
				location.Unit = null;
			}
			location = value;
			value.Unit = this;
//			value.IncreaseVisibility();
			Grid.IncreaseVisibility(value, visionRange);
			transform.localPosition = value.Position;
		}
	}
	…
	public void Die () {
		if (location) {
//			location.DecreaseVisibility();
			Grid.DecreaseVisibility(location, visionRange);
		}
		location.Unit = null;
		Destroy(gameObject);
	}


Units with visibility range that can overlap.

Visibility when moving


At the moment, the area of ​​visibility of the squad after the move command is immediately teleported to the end point. It would have looked better if the unit and its field of visibility moved together. The first step to this is that we will no longer set property Locationc HexUnit.Travel. Instead, we will directly change the field location, avoiding the property code. Therefore, we will manually clear the old location and configure a new location. Visibility will remain unchanged.

	public void Travel (List path) {
//		Location = path[path.Count - 1];
		location.Unit = null;
		location = path[path.Count - 1];
		location.Unit = this;
		pathToTravel = path;
		StopAllCoroutines();
		StartCoroutine(TravelPath());
	}

Inside coroutines, TravelPathwe will reduce the visibility of the first cell only after completion LookAt. After that, before moving to a new cell, we will increase the visibility from this cell. Having finished with this, we again reduce the visibility from it. Finally, increase the visibility from the last cell.

	IEnumerator TravelPath () {
		Vector3 a, b, c = pathToTravel[0].Position;
//		transform.localPosition = c;
		yield return LookAt(pathToTravel[1].Position);
		Grid.DecreaseVisibility(pathToTravel[0], visionRange);
		float t = Time.deltaTime * travelSpeed;
		for (int i = 1; i < pathToTravel.Count; i++) {
			a = c;
			b = pathToTravel[i - 1].Position;
			c = (b + pathToTravel[i].Position) * 0.5f;
			Grid.IncreaseVisibility(pathToTravel[i], visionRange);
			for (; t < 1f; t += Time.deltaTime * travelSpeed) {
				…
			}
			Grid.DecreaseVisibility(pathToTravel[i], visionRange);
			t -= 1f;
		}
		a = c;
		b = location.Position; // We can simply use the destination here.
		c = b;
		Grid.IncreaseVisibility(location, visionRange);
		for (; t < 1f; t += Time.deltaTime * travelSpeed) {
			…
		}
		…
	}


Visibility on the move.

All this works, except when a new order is issued at the moment the detachment moves. This leads to teleportation, which should also apply to visibility. To realize this, we need to track the current location of the squad while moving.

	HexCell location, currentTravelLocation;

We will update this location every time we hit a new cell while moving, until the squad reaches the final cell. Then it must be reset.

	IEnumerator TravelPath () {
		…
		for (int i = 1; i < pathToTravel.Count; i++) {
			currentTravelLocation = pathToTravel[i];
			a = c;
			b = pathToTravel[i - 1].Position;
			c = (b + currentTravelLocation.Position) * 0.5f;
			Grid.IncreaseVisibility(pathToTravel[i], visionRange);
			for (; t < 1f; t += Time.deltaTime * travelSpeed) {
				transform.localPosition = Bezier.GetPoint(a, b, c, t);
				Vector3 d = Bezier.GetDerivative(a, b, c, t);
				d.y = 0f;
				transform.localRotation = Quaternion.LookRotation(d);
				yield return null;
			}
			Grid.DecreaseVisibility(pathToTravel[i], visionRange);
			t -= 1f;
		}
		currentTravelLocation = null;
		…
	}

Now after completing the turn in, TravelPathwe can check if the old intermediate location of the path is known. If yes, then you need to reduce the visibility in this cell, and not at the beginning of the path.

	IEnumerator TravelPath () {
		Vector3 a, b, c = pathToTravel[0].Position;
		yield return LookAt(pathToTravel[1].Position);
		Grid.DecreaseVisibility(
			currentTravelLocation ? currentTravelLocation : pathToTravel[0],
			visionRange
		);
		…
	}

We also need to correct the visibility after recompilation that occurred during the movement of the squad. If the intermediate location is still known, then reduce the visibility in it and increase the visibility at the end point, and then reset the intermediate location.

	void OnEnable () {
		if (location) {
			transform.localPosition = location.Position;
			if (currentTravelLocation) {
				Grid.IncreaseVisibility(location, visionRange);
				Grid.DecreaseVisibility(currentTravelLocation, visionRange);
				currentTravelLocation = null;
			}
		}
	}

unitypackage

Visibility of roads and water


Although relief color changes are based on visibility, this does not affect roads and water. They look too bright for invisible cells. To apply visibility to roads and water, we need to add cell indices and blend weights to their mesh data. Therefore, we will check the children of the Use Cell Data for the Rivers , Roads , Water , Water Shore and Estuaries of the prefab fragment.

Roads


We will start from the roads. The method is HexGridChunk.TriangulateRoadEdgeused to create a small part of the road in the center of the cell, so it needs one cell index. Add a parameter to it and generate cell data for the triangle.

	void TriangulateRoadEdge (
		Vector3 center, Vector3 mL, Vector3 mR, float index
	) {
		roads.AddTriangle(center, mL, mR);
		roads.AddTriangleUV(
			new Vector2(1f, 0f), new Vector2(0f, 0f), new Vector2(0f, 0f)
		);
		Vector3 indices;
		indices.x = indices.y = indices.z = index;
		roads.AddTriangleCellData(indices, weights1);
	}

Another easy way to create roads is TriangulateRoadSegment. It is used both inside and between cells, so it should work with two different indexes. For this, it is convenient to use the index vector parameter. Since road segments can be parts of ledges, weights must also be passed through parameters.

	void TriangulateRoadSegment (
		Vector3 v1, Vector3 v2, Vector3 v3,
		Vector3 v4, Vector3 v5, Vector3 v6,
		Color w1, Color w2, Vector3 indices
	) {
		roads.AddQuad(v1, v2, v4, v5);
		roads.AddQuad(v2, v3, v5, v6);
		roads.AddQuadUV(0f, 1f, 0f, 0f);
		roads.AddQuadUV(1f, 0f, 0f, 0f);
		roads.AddQuadCellData(indices, w1, w2);
		roads.AddQuadCellData(indices, w1, w2);
	}

Now let's move on to TriangulateRoad, which creates roads inside the cells. It also needs an index parameter. He passes this data to the road methods he calls, and adds it to the triangles he creates.

	void TriangulateRoad (
		Vector3 center, Vector3 mL, Vector3 mR,
		EdgeVertices e, bool hasRoadThroughCellEdge, float index
	) {
		if (hasRoadThroughCellEdge) {
			Vector3 indices;
			indices.x = indices.y = indices.z = index;
			Vector3 mC = Vector3.Lerp(mL, mR, 0.5f);
			TriangulateRoadSegment(
				mL, mC, mR, e.v2, e.v3, e.v4,
				weights1, weights1, indices
			);
			roads.AddTriangle(center, mL, mC);
			roads.AddTriangle(center, mC, mR);
			roads.AddTriangleUV(
				new Vector2(1f, 0f), new Vector2(0f, 0f), new Vector2(1f, 0f)
			);
			roads.AddTriangleUV(
				new Vector2(1f, 0f), new Vector2(1f, 0f), new Vector2(0f, 0f)
			);
			roads.AddTriangleCellData(indices, weights1);
			roads.AddTriangleCellData(indices, weights1);
		}
		else {
			TriangulateRoadEdge(center, mL, mR, index);
		}
	}

It remains to add the required method arguments to TriangulateRoad, TriangulateRoadEdgeand TriangulateRoadSegment, to fix all the compiler errors.

	void TriangulateWithoutRiver (
		HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e
	) {
		TriangulateEdgeFan(center, e, cell.Index);
		if (cell.HasRoads) {
			Vector2 interpolators = GetRoadInterpolators(direction, cell);
			TriangulateRoad(
				center,
				Vector3.Lerp(center, e.v1, interpolators.x),
				Vector3.Lerp(center, e.v5, interpolators.y),
				e, cell.HasRoadThroughEdge(direction), cell.Index
			);
		}
	}
	…
	void TriangulateRoadAdjacentToRiver (
		HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e
	) {
		…
		TriangulateRoad(roadCenter, mL, mR, e, hasRoadThroughEdge, cell.Index);
		if (previousHasRiver) {
			TriangulateRoadEdge(roadCenter, center, mL, cell.Index);
		}
		if (nextHasRiver) {
			TriangulateRoadEdge(roadCenter, mR, center, cell.Index);
		}
	}
	…
	void TriangulateEdgeStrip (
		…
	) {
		…
		if (hasRoad) {
			TriangulateRoadSegment(
				e1.v2, e1.v3, e1.v4, e2.v2, e2.v3, e2.v4, w1, w2, indices
			);
		}
	}

Now the mesh data is correct, and we will move on to the Road shader . It needs a vertex program and it must contain HexCellData .

		#pragma surface surf Standard fullforwardshadows decal:blend vertex:vert
		#pragma target 3.0
		#include "HexCellData.cginc"

Since we do not mix several materials, it will be enough for us to pass one indicator of visibility into the fragment program.

		struct Input {
			float2 uv_MainTex;
			float3 worldPos;
			float visibility;
		};

It is enough for a new vertex program to receive data from two cells. We immediately mix their visibility, adjust it and add to the output.

		void vert (inout appdata_full v, out Input data) {
			UNITY_INITIALIZE_OUTPUT(Input, data);
			float4 cell0 = GetCellData(v, 0);
			float4 cell1 = GetCellData(v, 1);
			data.visibility = cell0.x * v.color.x + cell1.x * v.color.y;
			data.visibility = lerp(0.25, 1, data.visibility);
		}

In the fragment program, we just need to add visibility to the color.

		void surf (Input IN, inout SurfaceOutputStandard o) {
			float4 noise = tex2D(_MainTex, IN.worldPos.xz * 0.025);
			fixed4 c = _Color * ((noise.y * 0.75 + 0.25) * IN.visibility);
			…
		}


Roads with visibility.

Open water


It may seem that visibility has already affected the water, but this is just the surface of a terrain immersed in water. Let's start by applying visibility to open water. For this we need to change HexGridChunk.TriangulateOpenWater.

	void TriangulateOpenWater (
		HexDirection direction, HexCell cell, HexCell neighbor, Vector3 center
	) {
		…
		water.AddTriangle(center, c1, c2);
		Vector3 indices;
		indices.x = indices.y = indices.z = cell.Index;
		water.AddTriangleCellData(indices, weights1);
		if (direction <= HexDirection.SE && neighbor != null) {
			…
			water.AddQuad(c1, c2, e1, e2);
			indices.y = neighbor.Index;
			water.AddQuadCellData(indices, weights1, weights2);
			if (direction <= HexDirection.E) {
				…
				water.AddTriangle(
					c2, e2, c2 + HexMetrics.GetWaterBridge(direction.Next())
				);
				indices.z = nextNeighbor.Index;
				water.AddTriangleCellData(
					indices, weights1, weights2, weights3
				);
			}
		}
	}

We also need to add cell data to the fans of the triangles near the coasts.

	void TriangulateWaterShore (
		HexDirection direction, HexCell cell, HexCell neighbor, Vector3 center
	) {
		…
		water.AddTriangle(center, e1.v1, e1.v2);
		water.AddTriangle(center, e1.v2, e1.v3);
		water.AddTriangle(center, e1.v3, e1.v4);
		water.AddTriangle(center, e1.v4, e1.v5);
		Vector3 indices;
		indices.x = indices.y = indices.z = cell.Index;
		water.AddTriangleCellData(indices, weights1);
		water.AddTriangleCellData(indices, weights1);
		water.AddTriangleCellData(indices, weights1);
		water.AddTriangleCellData(indices, weights1);
		…
	}

The Water shader needs to be changed in the same way as the Road shader , but it needs to combine the visibility of not two, but three cells.

		#pragma surface surf Standard alpha vertex:vert
		#pragma target 3.0
		#include "Water.cginc"
		#include "HexCellData.cginc"
		sampler2D _MainTex;
		struct Input {
			float2 uv_MainTex;
			float3 worldPos;
			float visibility;
		};
		…
		void vert (inout appdata_full v, out Input data) {
			UNITY_INITIALIZE_OUTPUT(Input, data);
			float4 cell0 = GetCellData(v, 0);
			float4 cell1 = GetCellData(v, 1);
			float4 cell2 = GetCellData(v, 2);
			data.visibility =
				cell0.x * v.color.x + cell1.x * v.color.y + cell2.x * v.color.z;
			data.visibility = lerp(0.25, 1, data.visibility);
		}
		void surf (Input IN, inout SurfaceOutputStandard o) {
			float waves = Waves(IN.worldPos.xz, _MainTex);
			fixed4 c = saturate(_Color + waves);
			o.Albedo = c.rgb * IN.visibility;
			…
		}


Open water with visibility.

Coast and estuary


To support the coast, we need to change again HexGridChunk.TriangulateWaterShore. We already created an index vector, but we used only one cell index for open water. The coast also needs a neighbor index, so change the code.

		Vector3 indices;
//		indices.x = indices.y = indices.z = cell.Index;
		indices.x = indices.z = cell.Index;
		indices.y = neighbor.Index;

Add the cell data to the quads and the triangle of the coast. We also pass the indexes on the call TriangulateEstuary.

		if (cell.HasRiverThroughEdge(direction)) {
			TriangulateEstuary(
				e1, e2, cell.IncomingRiver == direction, indices
			);
		}
		else {
			…
			waterShore.AddQuadUV(0f, 0f, 0f, 1f);
			waterShore.AddQuadCellData(indices, weights1, weights2);
			waterShore.AddQuadCellData(indices, weights1, weights2);
			waterShore.AddQuadCellData(indices, weights1, weights2);
			waterShore.AddQuadCellData(indices, weights1, weights2);
		}
		HexCell nextNeighbor = cell.GetNeighbor(direction.Next());
		if (nextNeighbor != null) {
			…
			waterShore.AddTriangleUV(
				…
			);
			indices.z = nextNeighbor.Index;
			waterShore.AddTriangleCellData(
				indices, weights1, weights2, weights3
			);
		}

Add the necessary parameter to TriangulateEstuaryand take care of these cells for the coast and the mouth. Do not forget that the mouth is made of trapezoid with two triangles of the coast on the sides. We make sure that the weights are transferred in the correct order.

	void TriangulateEstuary (
		EdgeVertices e1, EdgeVertices e2, bool incomingRiver, Vector3 indices
	) {
		waterShore.AddTriangle(e2.v1, e1.v2, e1.v1);
		waterShore.AddTriangle(e2.v5, e1.v5, e1.v4);
		waterShore.AddTriangleUV(
			new Vector2(0f, 1f), new Vector2(0f, 0f), new Vector2(0f, 0f)
		);
		waterShore.AddTriangleUV(
			new Vector2(0f, 1f), new Vector2(0f, 0f), new Vector2(0f, 0f)
		);
		waterShore.AddTriangleCellData(indices, weights2, weights1, weights1);
		waterShore.AddTriangleCellData(indices, weights2, weights1, weights1);
		estuaries.AddQuad(e2.v1, e1.v2, e2.v2, e1.v3);
		estuaries.AddTriangle(e1.v3, e2.v2, e2.v4);
		estuaries.AddQuad(e1.v3, e1.v4, e2.v4, e2.v5);
		estuaries.AddQuadUV(
			new Vector2(0f, 1f), new Vector2(0f, 0f),
			new Vector2(1f, 1f), new Vector2(0f, 0f)
		);
		estuaries.AddTriangleUV(
			new Vector2(0f, 0f), new Vector2(1f, 1f), new Vector2(1f, 1f)
		);
		estuaries.AddQuadUV(
			new Vector2(0f, 0f), new Vector2(0f, 0f),
			new Vector2(1f, 1f), new Vector2(0f, 1f)
		);
		estuaries.AddQuadCellData(
			indices, weights2, weights1, weights2, weights1
		);
		estuaries.AddTriangleCellData(indices, weights1, weights2, weights2);
		estuaries.AddQuadCellData(indices, weights1, weights2);
		…
	}

In the WaterShore shader, you need to make the same changes as in the Water shader , mixing the visibility of the three cells.

		#pragma surface surf Standard alpha vertex:vert
		#pragma target 3.0
		#include "Water.cginc"
		#include "HexCellData.cginc"
		sampler2D _MainTex;
		struct Input {
			float2 uv_MainTex;
			float3 worldPos;
			float visibility;
		};
		…
		void vert (inout appdata_full v, out Input data) {
			UNITY_INITIALIZE_OUTPUT(Input, data);
			float4 cell0 = GetCellData(v, 0);
			float4 cell1 = GetCellData(v, 1);
			float4 cell2 = GetCellData(v, 2);
			data.visibility =
				cell0.x * v.color.x + cell1.x * v.color.y + cell2.x * v.color.z;
			data.visibility = lerp(0.25, 1, data.visibility);
		}
		void surf (Input IN, inout SurfaceOutputStandard o) {
			…
			fixed4 c = saturate(_Color + max(foam, waves));
			o.Albedo = c.rgb * IN.visibility;
			…
		}

The Estuary shader mixes the visibility of two cells, just like the Road shader . He already has a vertex program, because we need him to transmit the UV-coordinates of the rivers.

		#include "Water.cginc"
		#include "HexCellData.cginc"
		sampler2D _MainTex;
		struct Input {
			float2 uv_MainTex;
			float2 riverUV;
			float3 worldPos;
			float visibility;
		};
		half _Glossiness;
		half _Metallic;
		fixed4 _Color;
		void vert (inout appdata_full v, out Input o) {
			UNITY_INITIALIZE_OUTPUT(Input, o);
			o.riverUV = v.texcoord1.xy;
			float4 cell0 = GetCellData(v, 0);
			float4 cell1 = GetCellData(v, 1);
			o.visibility = cell0.x * v.color.x + cell1.x * v.color.y;
			o.visibility = lerp(0.25, 1, o.visibility);
		}
		void surf (Input IN, inout SurfaceOutputStandard o) {
			…
			fixed4 c = saturate(_Color + water);
			o.Albedo = c.rgb * IN.visibility;
			…
		}


Coast and estuary with visibility.

Rivers


The last water regions to work with are the rivers. Add an HexGridChunk.TriangulateRiverQuadindex vector to the parameter and add it to the mesh so that it can maintain the visibility of two cells.

	void TriangulateRiverQuad (
		Vector3 v1, Vector3 v2, Vector3 v3, Vector3 v4,
		float y, float v, bool reversed, Vector3 indices
	) {
		TriangulateRiverQuad(v1, v2, v3, v4, y, y, v, reversed, indices);
	}
	void TriangulateRiverQuad (
		Vector3 v1, Vector3 v2, Vector3 v3, Vector3 v4,
		float y1, float y2, float v, bool reversed, Vector3 indices
	) {
		…
		rivers.AddQuadCellData(indices, weights1, weights2);
	}

TriangulateWithRiverBeginOrEndcreates river endpoints with a quad and a triangle in the center of the cell. Add the necessary cell data for this.

	void TriangulateWithRiverBeginOrEnd (
		HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e
	) {
		…
		if (!cell.IsUnderwater) {
			bool reversed = cell.HasIncomingRiver;
			Vector3 indices;
			indices.x = indices.y = indices.z = cell.Index;
			TriangulateRiverQuad(
				m.v2, m.v4, e.v2, e.v4,
				cell.RiverSurfaceY, 0.6f, reversed, indices
			);
			center.y = m.v2.y = m.v4.y = cell.RiverSurfaceY;
			rivers.AddTriangle(center, m.v2, m.v4);
			…
			rivers.AddTriangleCellData(indices, weights1);
		}
	}

We already have these cell indices in TriangulateWithRiver, so we just pass them on the call TriangulateRiverQuad.

	void TriangulateWithRiver (
		HexDirection direction, HexCell cell, Vector3 center, EdgeVertices e
	) {
		…
		if (!cell.IsUnderwater) {
			bool reversed = cell.IncomingRiver == direction;
			TriangulateRiverQuad(
				centerL, centerR, m.v2, m.v4,
				cell.RiverSurfaceY, 0.4f, reversed, indices
			);
			TriangulateRiverQuad(
				m.v2, m.v4, e.v2, e.v4,
				cell.RiverSurfaceY, 0.6f, reversed, indices
			);
		}
	}

We also add index support to waterfalls that pour into deep water.

	void TriangulateWaterfallInWater (
		Vector3 v1, Vector3 v2, Vector3 v3, Vector3 v4,
		float y1, float y2, float waterY, Vector3 indices
	) {
		…
		rivers.AddQuadCellData(indices, weights1, weights2);
	}

And finally, change it TriangulateConnectionso that it passes the necessary indexes to the methods of rivers and waterfalls.

	void TriangulateConnection (
		HexDirection direction, HexCell cell, EdgeVertices e1
	) {
		…
		if (hasRiver) {
			e2.v3.y = neighbor.StreamBedY;
			Vector3 indices;
			indices.x = indices.z = cell.Index;
			indices.y = neighbor.Index;
			if (!cell.IsUnderwater) {
				if (!neighbor.IsUnderwater) {
					TriangulateRiverQuad(
						e1.v2, e1.v4, e2.v2, e2.v4,
						cell.RiverSurfaceY, neighbor.RiverSurfaceY, 0.8f,
						cell.HasIncomingRiver && cell.IncomingRiver == direction,
						indices
					);
				}
				else if (cell.Elevation > neighbor.WaterLevel) {
					TriangulateWaterfallInWater(
						e1.v2, e1.v4, e2.v2, e2.v4,
						cell.RiverSurfaceY, neighbor.RiverSurfaceY,
						neighbor.WaterSurfaceY, indices
					);
				}
			}
			else if (
				!neighbor.IsUnderwater &&
				neighbor.Elevation > cell.WaterLevel
			) {
				TriangulateWaterfallInWater(
					e2.v4, e2.v2, e1.v4, e1.v2,
					neighbor.RiverSurfaceY, cell.RiverSurfaceY,
					cell.WaterSurfaceY, indices
				);
			}
		}
		…
	}

The River shader needs to make the same changes as the Road shader .

		#pragma surface surf Standard alpha vertex:vert
		#pragma target 3.0
		#include "Water.cginc"
		#include "HexCellData.cginc"
		sampler2D _MainTex;
		struct Input {
			float2 uv_MainTex;
			float visibility;
		};
		…
		void vert (inout appdata_full v, out Input data) {
			UNITY_INITIALIZE_OUTPUT(Input, data);
			float4 cell0 = GetCellData(v, 0);
			float4 cell1 = GetCellData(v, 1);
			data.visibility = cell0.x * v.color.x + cell1.x * v.color.y;
			data.visibility = lerp(0.25, 1, data.visibility);
		}
		void surf (Input IN, inout SurfaceOutputStandard o) {
			float river = River(IN.uv_MainTex, _MainTex);
			fixed4 c = saturate(_Color + river);
			o.Albedo = c.rgb * IN.visibility;
			…
		}


Rivers with visibility.

unitypackage

Objects and Visibility


Now visibility works for the entire procedurally generated terrain, but so far it does not affect terrain features. Buildings, farms and trees are created from prefabs, and not from procedural geometry, so we cannot add cell indices and mix weights with their vertices. Since each of these objects belongs to only one cell, we need to determine which cell they are in. If we can do this, then we will get access to the data of the corresponding cells and apply visibility.

We can already transform the XZ positions of the world into cell indices. This transformation was used to edit terrain and manage squads. However, the corresponding code is nontrivial. It uses integer operations and requires logic to work with edges. This is impractical for a shader, so we can bake the bulk of the logic in a texture and use it.

We are already using a texture with a hexagonal pattern to project the grid over the topography. This texture defines a cell area of ​​2 × 2. Therefore, we can easily calculate in which area we are. After that, you can apply a texture containing X and Z offsets for the cells in this area and use this data to calculate the cell in which we are located.

Here is a similar texture. The X offset is stored in its red channel, and the Z offset is stored in the green channel. Since it covers the area of ​​2 × 2 cells, we need offsets from 0 and 2. Such data cannot be stored in the color channel, so the offsets are reduced by half. We do not need clear edges of the cells, so a small texture is enough.


The texture of the grid coordinates.

Add texture to the project. Set its Wrap Mode to Repeat , just like the other mesh texture. We do not need any mixing, so for Blend Mode we will choose Point . Also turn off Compression so that the data is not distorted. Turn off the sRGB mode so that when rendering in linear mode, no color space conversions are performed. And finally, we do not need mip textures.


Texture import options.

Object Shader with Visibility


Create a new Feature shader to add visibility support to objects. This is a simple surface shader with a vertex program. Add HexCellData to it and pass the visibility indicator to the fragment program, and as usual, consider it in color. The difference here is that we cannot use GetCellDatait because the required mesh data does not exist. Instead, we have a position in the world. But for now, leave visibility equal to 1.

Shader "Custom/Feature" {
	Properties {
		_Color ("Color", Color) = (1,1,1,1)
		_MainTex ("Albedo (RGB)", 2D) = "white" {}
		_Glossiness ("Smoothness", Range(0,1)) = 0.5
		_Metallic ("Metallic", Range(0,1)) = 0.0
		[NoTilingOffset] _GridCoordinates ("Grid Coordinates", 2D) = "white" {}
	}
	SubShader {
		Tags { "RenderType"="Opaque" }
		LOD 200
		CGPROGRAM
		#pragma surface surf Standard fullforwardshadows vertex:vert
		#pragma target 3.0
		#include "../HexCellData.cginc"
		sampler2D _MainTex, _GridCoordinates;
		half _Glossiness;
		half _Metallic;
		fixed4 _Color;
		struct Input {
			float2 uv_MainTex;
			float visibility;
		};
		void vert (inout appdata_full v, out Input data) {
			UNITY_INITIALIZE_OUTPUT(Input, data);
			float3 pos = mul(unity_ObjectToWorld, v.vertex);
			data.visibility = 1;
		}
		void surf (Input IN, inout SurfaceOutputStandard o) {
			fixed4 c = tex2D (_MainTex, IN.uv_MainTex) * _Color;
			o.Albedo = c.rgb * IN.visibility;
			o.Metallic = _Metallic;
			o.Smoothness = _Glossiness;
			o.Alpha = c.a;
		}
		ENDCG
	}
	FallBack "Diffuse"
}

Change all the materials of the objects so that they use the new shader and assign them the texture of the grid coordinates.


Urban with mesh texture.

Access cell data


To sample the texture of the grid coordinates in the vertex program, we again need tex2Dloda four-component texture coordinate vector. The first two coordinates are the position of the XZ world. The other two are equal to zero as before.

		void vert (inout appdata_full v, out Input data) {
			UNITY_INITIALIZE_OUTPUT(Input, data);
			float3 pos = mul(unity_ObjectToWorld, v.vertex);
			float4 gridUV = float4(pos.xz, 0, 0);
			data.visibility = 1;
		}

As in the Terrain shader , we stretch the UV coordinates so that the texture has the correct aspect ratio corresponding to the grid of hexagons.

			float4 gridUV = float4(pos.xz, 0, 0);
			gridUV.x *= 1 / (4 * 8.66025404);
			gridUV.y *= 1 / (2 * 15.0);

We can find out in which part of the 2 × 2 cells we are by taking the value of the UV coordinates rounded down. This forms the basis for the coordinates of the cells.

			float4 gridUV = float4(pos.xz, 0, 0);
			gridUV.x *= 1 / (4 * 8.66025404);
			gridUV.y *= 1 / (2 * 15.0);
			float2 cellDataCoordinates = floor(gridUV.xy);

To find the coordinates of the cell in which we are, we add the displacements stored in the texture.

			float2 cellDataCoordinates =
				floor(gridUV.xy) + tex2Dlod(_GridCoordinates, gridUV).rg;

Since part of the grid is 2 × 2 in size, and the offsets are halved, we need to double the result to get the final coordinates.

			float2 cellDataCoordinates =
				floor(gridUV.xy) + tex2Dlod(_GridCoordinates, gridUV).rg;
			cellDataCoordinates *= 2;

Now we have the XZ coordinates of the cell grid that we need to convert to the UV coordinates of these cells. This can be done by simply moving to the centers of the pixels and then dividing them into texture sizes. So let's add a function for this to the HexCellData include file that will also handle sampling.

float4 GetCellData (float2 cellDataCoordinates) {
	float2 uv = cellDataCoordinates + 0.5;
	uv.x *= _HexCellData_TexelSize.x;
	uv.y *= _HexCellData_TexelSize.y;
	return tex2Dlod(_HexCellData, float4(uv, 0, 0));
}

Now we can use this in the vertex shader program the Feature .

			cellDataCoordinates *= 2;
			data.visibility = GetCellData(cellDataCoordinates).x;
			data.visibility = lerp(0.25, 1, data.visibility);


Objects with visibility.

Finally, visibility affects the entire map, with the exception of units that are always visible. Since we determine the visibility of objects for each vertex, then for the object crossing the cell boundary, the visibility of the cells that it closes will be mixed. But the objects are so small that they constantly remain inside their cell, even taking into account distortion of positions. However, some may be part of the vertices in another cell. Therefore, our approach is cheap, but imperfect. This is most noticeable in the case of walls, the visibility of which varies between the visibilities of neighboring cells.


Walls with changing visibility.

Since wall segments are generated procedurally, we can add cell data to their mesh and use the approach that we used for the relief. Unfortunately, the towers are prefabs, so we will still have inconsistencies. In general terms, the existing approach looks good enough for the simple geometry we use. In the future, we will consider more detailed models and walls, therefore, we will improve the method of mixing their visibility.

unitypackage

Part 21: map research


  • We display everything during editing.
  • We track the investigated cells.
  • We hide what is still unknown.
  • We force units to avoid unexplored areas.

In the previous part, we added the fog of war, which we will now refine to implement map research.


We are ready to explore the world.

Display the entire map in edit mode


The meaning of the study is that until the cells are not seen are considered unknown, and therefore invisible. They should not be obscured, but not displayed at all. Therefore, before adding research support, we will enable visibility in edit mode.

Visibility Switching


We can control whether the shaders use visibility using the keyword, as was done with the overlay on the grid. Let's use the HEX_MAP_EDIT_MODE keyword to indicate the state of the editing mode. Since several shaders should know about this keyword, we will define it globally using static methods Shader.EnableKeyWordand Shader.DisableKeyword. We will call the appropriate method in HexGameUI.SetEditModewhen changing the editing mode.

	public void SetEditMode (bool toggle) {
		enabled = !toggle;
		grid.ShowUI(!toggle);
		grid.ClearPath();
		if (toggle) {
			Shader.EnableKeyword("HEX_MAP_EDIT_MODE");
		}
		else {
			Shader.DisableKeyword("HEX_MAP_EDIT_MODE");
		}
	}

Edit mode shaders


When HEX_MAP_EDIT_MODE is defined, shaders will ignore visibility. This boils down to the fact that cell visibility will always be considered equal to 1. Let's add a function to filter the data of cells depending on the keyword at the beginning of the HexCellData include-file .

sampler2D _HexCellData;
float4 _HexCellData_TexelSize;
float4 FilterCellData (float4 data) {
	#if defined(HEX_MAP_EDIT_MODE)
		data.x = 1;
	#endif
	return data;
}

We pass through this function the result of both functions GetCellDatabefore returning it.

float4 GetCellData (appdata_full v, int index) {
	…
	return FilterCellData(data);
}
float4 GetCellData (float2 cellDataCoordinates) {
	…
	return FilterCellData(tex2Dlod(_HexCellData, float4(uv, 0, 0)));
}

For everything to work, all relevant shaders must receive the multi_compile directive to create options in case the HEX_MAP_EDIT_MODE keyword is defined. Add the appropriate line to the shaders Estuary , Feature , River , Road , Terrain , Water and Water Shore , between the target directive and the first include directive.

		#pragma multi_compile _ HEX_MAP_EDIT_MODE

Now, when switching to map editing mode, the fog of war will disappear.

unitypackage

Cell research


By default, cells should be considered unexplored. They become explored when a squad sees them. After that, they continue to remain investigated if a detachment can see them.

Tracking Study Status


To add support for monitoring the status of studies, we add to the HexCellgeneral property IsExplored.

	public bool IsExplored { get; set; }

The state of the study is determined by the cell itself. Therefore, this property should be set only HexCell. To add this restriction, we will set the setter private.

	public bool IsExplored { get; private set; }

The first time that the cell’s visibility becomes greater than zero, the cell begins to be considered investigated, and therefore IsExploreda value should be assigned true. In fact, it will be enough for us to simply mark the cell as examined when visibility increases to 1. This must be done before the call RefreshVisibility.

	public void IncreaseVisibility () {
		visibility += 1;
		if (visibility == 1) {
			IsExplored = true;
			ShaderData.RefreshVisibility(this);
		}
	}

Transferring research state to shaders


As in the case with the visibility of cells, we transfer their research state to the shaders through the shader data. In the end, it's just another type of visibility. HexCellShaderData.RefreshVisibilitystores the visibility state in the data channel R. Let's keep the state of the study in channel G data.

	public void RefreshVisibility (HexCell cell) {
		int index = cell.Index;
		cellTextureData[index].r = cell.IsVisible ? (byte)255 : (byte)0;
		cellTextureData[index].g = cell.IsExplored ? (byte)255 : (byte)0;
		enabled = true;
	}

Black unexplored relief


Now we can use shaders to visualize the state of cell research. To make sure that everything works as it should, we just make the unexplored terrain black. But first, to make the editing mode work, change it FilterCellDataso that it filters out the research data.

float4 FilterCellData (float4 data) {
	#if defined(HEX_MAP_EDIT_MODE)
		data.xy = 1;
	#endif
	return data;
}

The Terrain shader passes the visibility data of all three possible cells to the fragment program. In the case of the research state, we combine them in the vertex program and transfer the only value to the fragment program. Add the visibilityfourth component to the input so that we have a place for this.

		struct Input {
			float4 color : COLOR;
			float3 worldPos;
			float3 terrain;
			float4 visibility;
		};

Now, in the vertex program, when we change the visibility index, we must explicitly access data.visibility.xyz.

		void vert (inout appdata_full v, out Input data) {
			…
			data.visibility.xyz = lerp(0.25, 1, data.visibility.xyz);
		}

After that, we combine the states of the study and write the result in data.visibility.w. This is similar to combining visibility in other shaders, but using component Y of these cells.

			data.visibility.xyz = lerp(0.25, 1, data.visibility.xyz);
			data.visibility.w =
				cell0.y * v.color.x + cell1.y * v.color.y + cell2.y * v.color.z;

Research status is now available in the fragment program through IN.visibility.w. Consider it in the calculation of albedo.

		void surf (Input IN, inout SurfaceOutputStandard o) {
			…
			float explored = IN.visibility.w;
			o.Albedo = c.rgb * grid * _Color * explored;
			o.Metallic = _Metallic;
			o.Smoothness = _Glossiness;
			o.Alpha = c.a;
		}


The unexplored topography is now black.

The relief of unexplored cells now has a black color. But this has not yet affected objects, roads and water. However, this is enough to make sure that the study works.

Saving and loading research status


Now that we have added research support, we need to make sure that research status is taken into account when saving and loading maps. Therefore, we need to increase the version of map files to 3. To make these changes more convenient, let's add a SaveLoadMenuconstant for this .

	const int mapFileVersion = 3;

We will use this constant when writing the file version to Saveand when checking file support in Load.

	void Save (string path) {
		using (
			BinaryWriter writer =
			new BinaryWriter(File.Open(path, FileMode.Create))
		) {
			writer.Write(mapFileVersion);
			hexGrid.Save(writer);
		}
	}
	void Load (string path) {
		if (!File.Exists(path)) {
			Debug.LogError("File does not exist " + path);
			return;
		}
		using (BinaryReader reader = new BinaryReader(File.OpenRead(path))) {
			int header = reader.ReadInt32();
			if (header <= mapFileVersion) {
				hexGrid.Load(reader, header);
				HexMapCamera.ValidatePosition();
			}
			else {
				Debug.LogWarning("Unknown map format " + header);
			}
		}
	}

As a final step, HexCell.Savewe record the status of the study.

	public void Save (BinaryWriter writer) {
		…
		writer.Write(IsExplored);
	}

And we will read it at the end Load. After that, we will call RefreshVisibilityin case the state of the study differs from the previous one.

	public void Load (BinaryReader reader) {
		…
		IsExplored = reader.ReadBoolean();
		ShaderData.RefreshVisibility(this);
	}

To maintain backward compatibility with old save files, we need to skip reading the save state if the file version is less than 3. In this case, by default, the cells will have the state “unexplored”. To do this, we need to add Loadheader data as a parameter .

	public void Load (BinaryReader reader, int header) {
		…
		IsExplored = header >= 3 ? reader.ReadBoolean() : false;
		ShaderData.RefreshVisibility(this);
	}

Now HexGrid.Loadit will have to pass in the HexCell.Loadheader data.

	public void Load (BinaryReader reader, int header) {
		…
		for (int i = 0; i < cells.Length; i++) {
			cells[i].Load(reader, header);
		}
		…
	}

Now, when saving and loading maps, the state of exploration of the cells will be taken into account.

unitypackage

Hide unknown cells


At the current stage, unexplored cells are visually indicated by a black relief. But in reality, we want these cells to be invisible because they are unknown. We can make the opaque geometry transparent so that it is not visible. However, the Unity surface shader framework was developed without this possibility in mind. Instead of using true transparency, we will change the shaders to match the background, which will also make them invisible.

Making the relief really black


Although the studied relief is black, we can still recognize it because it still has specular lighting. To get rid of the lighting, we need to make it perfectly matte black. In order not to affect other surface properties, it is easiest to change the specular color to black. This is possible if you use a surface shader that works with specular, but now we use the standard metallic. So let's start by switching the Terrain shader to specular.

Replace the color property _Metallic on property _Specular . By default, its color value should be equal to (0.2, 0.2, 0.2). So we guarantee that it will match the appearance of the metallic version.

	Properties {
		_Color ("Color", Color) = (1,1,1,1)
		_MainTex ("Terrain Texture Array", 2DArray) = "white" {}
		_GridTex ("Grid Texture", 2D) = "white" {}
		_Glossiness ("Smoothness", Range(0,1)) = 0.5
//		_Metallic ("Metallic", Range(0,1)) = 0.0
		_Specular ("Specular", Color) = (0.2, 0.2, 0.2)
	}

Also change the corresponding shader variables. The color of specular surface shaders is defined as fixed3, so let's use it.

		half _Glossiness;
//		half _Metallic;
		fixed3 _Specular;
		fixed4 _Color;

Change the pragma surface surf from Standard to StandardSpecular . This will force Unity to generate shaders using specular.

		#pragma surface surf StandardSpecular fullforwardshadows vertex:vert

Now the function surfneeds the second parameter to be of type SurfaceOutputStandardSpecular. In addition, now you need to assign the value not o.Metallic, but o.Specular.

		void surf (Input IN, inout SurfaceOutputStandardSpecular o) {
			…
			float explored = IN.visibility.w;
			o.Albedo = c.rgb * grid * _Color * explored;
//			o.Metallic = _Metallic;
			o.Specular = _Specular;
			o.Smoothness = _Glossiness;
			o.Alpha = c.a;
		}

Now we can obscure the highlights by considering the exploredspecular color.

			o.Specular = _Specular * explored;


Unexplored terrain without reflected lighting.

As you can see in the picture, now the unexplored relief looks dull black. However, when viewed at a tangent angle, the surfaces turn into a mirror, because of which the relief begins to reflect the environment, that is, the skybox.

Why do surfaces become mirrors?
This is called the Fresnel effect. See the Rendering tutorial series for more information .


Unexplored areas still reflect the environment.

To get rid of these reflections, we will consider the unexplored relief completely shaded. This is accomplished by assigning a value to exploredthe occlusion parameter, which we use as a reflection mask.

			float explored = IN.visibility.w;
			o.Albedo = c.rgb * grid * _Color * explored;
			o.Specular = _Specular * explored;
			o.Smoothness = _Glossiness;
			o.Occlusion = explored;
			o.Alpha = c.a;


Unexplored without reflections.

Matching Background


Now that the unexplored terrain ignores all the lighting, you need to make it match the background. Since our camera always looks from above, the background is always gray. To tell the Terrain shader which color to use, add the _BackgroundColor property , which defaults to black.

	Properties {
		…
		_BackgroundColor ("Background Color", Color) = (0,0,0)
	}
	…
		half _Glossiness;
		fixed3 _Specular;
		fixed4 _Color;
		half3 _BackgroundColor;

To use this color, we will add it as emissive light. This is o.Emissionaccomplished by assigning a background color value multiplied by one minus explored.

			o.Occlusion = explored;
			o.Emission = _BackgroundColor * (1 -  explored);

Since we use the default skybox, the visible background color is actually not the same. In general, a slightly reddish gray would be the best color. When setting up the relief material, you can use the code 68615BFF for Hex Color .


Relief material with gray background color.

In general, this works, although if you know where to look, you will notice very weak silhouettes. So that the player could not see them, you can assign a uniform background color of 68615BFF to the camera instead of skybox.


Camera with a uniform background color.

Why not remove the skybox?
This can be done, but do not forget that it is used for environmental lighting maps. If you switch to a uniform color, then the lighting of the map will change.

Now we can not find the difference between the background and unexplored cells. A high unexplored topography can still obscure a low explored topography at low camera angles. In addition, unexplored parts still cast shadows on the explored. But these minimal clues can be neglected.


Unexplored cells are no longer visible.

What if you do not use a uniform background color?
You can create your own shader, which will actually make the relief transparent, but continue writing to the depth buffer. This may require tricks with the shader queue. If you use the texture of the screen space, then instead of the background color, you can simply sample this texture. If you use a texture in the space of the world, then you will need to perform calculations to determine which UV coordinates of the texture to use based on the viewing angle and the position of the fragment in the world.

Hide relief objects


Now we have only the mesh of the relief hidden. The rest of the state of the study has not yet affected.


So far, only the relief is hidden.

Let's change the Feature shader , which is an opaque shader like Terrain . Turn it into a specular shader and add the background color to it. Let's start with the properties.

	Properties {
		_Color ("Color", Color) = (1,1,1,1)
		_MainTex ("Albedo (RGB)", 2D) = "white" {}
		_Glossiness ("Smoothness", Range(0,1)) = 0.5
//		_Metallic ("Metallic", Range(0,1)) = 0.0
		_Specular ("Specular", Color) = (0.2, 0.2, 0.2)
		_BackgroundColor ("Background Color", Color) = (0,0,0)
		[NoScaleOffset] _GridCoordinates ("Grid Coordinates", 2D) = "white" {}
	}

Further pragma surface and variables, as before.

		#pragma surface surf StandardSpecular fullforwardshadows vertex:vert
		…
		half _Glossiness;
//		half _Metallic;
		fixed3 _Specular;
		fixed4 _Color;
		half3 _BackgroundColor;

visibilityone more component is also required. Since Feature combines visibility for each vertex, it only needed one float value. Now we need two.

		struct Input {
			float2 uv_MainTex;
			float2 visibility;
		};

Change it vertso that it explicitly uses for the visibility data data.visibility.x, and then assign the data.visibility.yvalue of the study data.

		void vert (inout appdata_full v, out Input data) {
			…
			float4 cellData = GetCellData(cellDataCoordinates);
			data.visibility.x = cellData.x;
			data.visibility.x = lerp(0.25, 1, data.visibility.x);
			data.visibility.y = cellData.y;
		}

Change it surfso that it uses the new data, like Terrain .

		void surf (Input IN, inout SurfaceOutputStandardSpecular o) {
			fixed4 c = tex2D (_MainTex, IN.uv_MainTex) * _Color;
			float explored = IN.visibility.y;
			o.Albedo = c.rgb * (IN.visibility.x * explored);
//			o.Metallic = _Metallic;
			o.Specular = _Specular * explored;
			o.Smoothness = _Glossiness;
			o.Occlusion = explored;
			o.Emission = _BackgroundColor * (1 -  explored);
			o.Alpha = c.a;
		}


Hidden relief objects.

Hide water


Next up are the Water and Water Shore shaders . Let's start by converting them to specular shaders. However, they don’t need a background color because they are transparent shaders.

After the conversion, add visibilityone more component and change it accordingly vert. Both shaders combine data from three cells.

		struct Input {
			…
			float2 visibility;
		};
		…
		void vert (inout appdata_full v, out Input data) {
			…
			data.visibility.x =
				cell0.x * v.color.x + cell1.x * v.color.y + cell2.x * v.color.z;
			data.visibility.x = lerp(0.25, 1, data.visibility.x);
			data.visibility.y =
				cell0.y * v.color.x + cell1.y * v.color.y + cell2.y * v.color.z;
		}

Water and Water Shore perform surfdifferent operations, but set their surface properties in the same way. Since they are transparent, we will take into account explorein the alpha channel, and we will not set emission.

		void surf (Input IN, inout SurfaceOutputStandardSpecular o) {
			…
			float explored = IN.visibility.y;
			o.Albedo = c.rgb * IN.visibility.x;
			o.Specular = _Specular * explored;
			o.Smoothness = _Glossiness;
			o.Occlusion = explored;
			o.Alpha = c.a * explored;
		}


Hidden water.

We hide estuaries, rivers and roads


We still have the shaders for Estuary , River, and Road . All three are transparent and combine the data of two cells. Switch them all to specular, and then add them to the visibilityresearch data.

		struct Input {
			…
			float2 visibility;
		};
		…
		void vert (inout appdata_full v, out Input data) {
			…
			data.visibility.x = cell0.x * v.color.x + cell1.x * v.color.y;
			data.visibility.x = lerp(0.25, 1, data.visibility.x);
			data.visibility.y = cell0.y * v.color.x + cell1.y * v.color.y;
		}

Change the function of the Estuary and Riversurf shaders so that it uses the new data. Both need to make the same changes.

		void surf (Input IN, inout SurfaceOutputStandardSpecular o) {
			…
			float explored = IN.visibility.y;
			fixed4 c = saturate(_Color + water);
			o.Albedo = c.rgb * IN.visibility.x;
			o.Specular = _Specular * explored;
			o.Smoothness = _Glossiness;
			o.Occlusion = explored;
			o.Alpha = c.a * explored;
		}

The Shader Road is a little different because it uses an extra blending metric.

		void surf (Input IN, inout SurfaceOutputStandardSpecular o) {
			float4 noise = tex2D(_MainTex, IN.worldPos.xz * 0.025);
			fixed4 c = _Color * ((noise.y * 0.75 + 0.25) * IN.visibility.x);
			float blend = IN.uv_MainTex.x;
			blend *= noise.x + 0.5;
			blend = smoothstep(0.4, 0.7, blend);
			float explored = IN.visibility.y;
			o.Albedo = c.rgb;
			o.Specular = _Specular * explored;
			o.Smoothness = _Glossiness;
			o.Occlusion = explored;
			o.Alpha = blend * explored;
		}


Everything is hidden.

unitypackage

Avoiding Unexplored Cells


Although everything unknown is visually hidden, while the state of the study is not taken into account when searching for a path. As a result, units can be ordered to move through and through unexplored cells, magically determining which way to move. We need to force units to avoid unexplored cells.


Navigate unexplored cells.

Squads determine the cost of moving


Before tackling unexplored cells, let's redo the code to transfer the cost of moving from HexGridto HexUnit. This will simplify support for units with different movement rules.

Add to the HexUnitgeneral method GetMoveCostto determine the cost of moving. He needs to know which cells are moving between them, as well as the direction. We copy the corresponding code for the costs of moving from HexGrid.Searchto this method and change the variable names.

	public int GetMoveCost (
		HexCell fromCell, HexCell toCell, HexDirection direction)
	{
		HexEdgeType edgeType = fromCell.GetEdgeType(toCell);
		if (edgeType == HexEdgeType.Cliff) {
			continue;
		}
		int moveCost;
		if (fromCell.HasRoadThroughEdge(direction)) {
			moveCost = 1;
		}
		else if (fromCell.Walled != toCell.Walled) {
			continue;
		}
		else {
			moveCost = edgeType == HexEdgeType.Flat ? 5 : 10;
			moveCost +=
				toCell.UrbanLevel + toCell.FarmLevel + toCell.PlantLevel;
		}
	}

The method should return the cost of moving. I used the old code to skip invalid moves continue, but this approach will not work here. If movement is not possible, then we will return the negative costs of moving.

	public int GetMoveCost (
		HexCell fromCell, HexCell toCell, HexDirection direction)
	{
		HexEdgeType edgeType = fromCell.GetEdgeType(toCell);
		if (edgeType == HexEdgeType.Cliff) {
			return -1;
		}
		int moveCost;
		if (fromCell.HasRoadThroughEdge(direction)) {
			moveCost = 1;
		}
		else if (fromCell.Walled != toCell.Walled) {
			return -1;
		}
		else {
			moveCost = edgeType == HexEdgeType.Flat ? 5 : 10;
			moveCost +=
				toCell.UrbanLevel + toCell.FarmLevel + toCell.PlantLevel;
		}
		return moveCost;
	}

Now we need to know when finding the path, not only speed, but also the selected unit. Change accordingly HexGameUI.DoPathFinding.

	void DoPathfinding () {
		if (UpdateCurrentCell()) {
			if (currentCell && selectedUnit.IsValidDestination(currentCell)) {
				grid.FindPath(selectedUnit.Location, currentCell, selectedUnit);
			}
			else {
				grid.ClearPath();
			}
		}
	}

Since we still need access to squad speed, we’ll add to the HexUnitproperty Speed. While it will return a constant value of 24.

	public int Speed {
		get {
			return 24;
		}
	}

In HexGridchange, FindPathand Searchso that they can work with our new approach.

	public void FindPath (HexCell fromCell, HexCell toCell, HexUnit unit) {
		ClearPath();
		currentPathFrom = fromCell;
		currentPathTo = toCell;
		currentPathExists = Search(fromCell, toCell, unit);
		ShowPath(unit.Speed);
	}
	bool Search (HexCell fromCell, HexCell toCell, HexUnit unit) {
		int speed = unit.Speed;
		…
	}

Now we will remove from the Searchold code that determined whether it is possible to move to the next cell and what are the costs of moving. Instead, we will call HexUnit.IsValidDestinationand HexUnit.GetMoveCost. We will skip the cell if the cost of moving is negative.

			for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) {
				HexCell neighbor = current.GetNeighbor(d);
				if (
					neighbor == null ||
					neighbor.SearchPhase > searchFrontierPhase
				) {
					continue;
				}
//				if (neighbor.IsUnderwater || neighbor.Unit) {
//					continue;
//				}
//				HexEdgeType edgeType = current.GetEdgeType(neighbor);
//				if (edgeType == HexEdgeType.Cliff) {
//					continue;
//				}
//				int moveCost;
//				if (current.HasRoadThroughEdge(d)) {
//					moveCost = 1;
//				}
//				else if (current.Walled != neighbor.Walled) {
//					continue;
//				}
//				else {
//					moveCost = edgeType == HexEdgeType.Flat ? 5 : 10;
//					moveCost += neighbor.UrbanLevel + neighbor.FarmLevel +
//						neighbor.PlantLevel;
//				}
				if (!unit.IsValidDestination(neighbor)) {
					continue;
				}
				int moveCost = unit.GetMoveCost(current, neighbor, d);
				if (moveCost < 0) {
					continue;
				}
				int distance = current.Distance + moveCost;
				int turn = (distance - 1) / speed;
				if (turn > currentTurn) {
					distance = turn * speed + moveCost;
				}
				…
			}

Bypass unexplored areas


To avoid unexplored cells, it is enough for us to make sure that we HexUnit.IsValidDestinationcheck whether the cell is examined.

	public bool IsValidDestination (HexCell cell) {
		return cell.IsExplored && !cell.IsUnderwater && !cell.Unit;
	}


More units will not be able to get to unexplored cells.

Since unexplored cells are no longer valid endpoints, squads will avoid them when moving to the endpoint. That is, unexplored areas act as barriers that lengthen the path or even make it impossible. We will have to bring the units closer to an unknown terrain in order to first explore the area.

What if a shorter path appears during the move?
In our case, the path is determined only once, and during movement it is impossible to deviate from it. We can change this and look for a new path at every step, but this will make the detachment’s movement unpredictable and chaotic. It is better to adhere to the chosen path and not to be wiser.

On the other hand, if we are strictly limited to movement in one turn, then for long journeys we will have to calculate a new path on each turn. In this case, the units can choose a shorter path in the next turn, if it suddenly appeared.

unitypackage

Part 22: Enhanced Visibility


  • Smoothly change the visibility.
  • Use the height of the cell to determine the scope.
  • Hide the edge of the map.

By adding support for map exploration, we will improve the calculations and transitions of the scope.


To see further, climb higher.

Visibility Transitions


The cell is either visible or invisible, because it is either in the scope of the detachment or not. Even if it looks like it takes a unit some time to move between cells, its scope jumps from cell to cell instantly. As a result, the visibility of the surrounding cells changes dramatically. Squad movement seems smooth, but changes in visibility are sudden.

Ideally, visibility should also change smoothly. Once in the field of visibility, the cells should be illuminated gradually, and leaving it, gradually darken. Or maybe you prefer instant transitions? Let's add to the HexCellShaderDataproperty that switches instant transitions. By default, transitions will be smooth.

	public bool ImmediateMode { get; set; }

Transition Cell Tracking


Even when displaying smooth transitions, the true visibility data still remains binary, that is, the effect is only visual. This means that visibility transitions must be dealt with HexCellShaderData. We will give it a list of cells in which the transition is performed. Make sure that at each initialization it is empty.

using System.Collections.Generic;
using UnityEngine;
public class HexCellShaderData : MonoBehaviour {
	Texture2D cellTexture;
	Color32[] cellTextureData;
	List transitioningCells = new List();
	public bool ImmediateMode { get; set; }
	public void Initialize (int x, int z) {
		…
		transitioningCells.Clear();
		enabled = true;
	}
	…
}

At the moment, we are setting cell data RefreshVisibilitydirectly. This is still correct for the instant transition mode, but when it is disabled, we must add a cell to the list of transition cells.

	public void RefreshVisibility (HexCell cell) {
		int index = cell.Index;
		if (ImmediateMode) {
			cellTextureData[index].r = cell.IsVisible ? (byte)255 : (byte)0;
			cellTextureData[index].g = cell.IsExplored ? (byte)255 : (byte)0;
		}
		else {
			transitioningCells.Add(cell);
		}
		enabled = true;
	}

Visibility doesn't seem to work anymore, because for now, we're not doing anything with the cells in the list.

Loop through cells in a loop


Instead of instantly setting the corresponding values ​​to 255 or 0, we will increase / decrease these values ​​gradually. The smoothness of the transition depends on the rate of change. It should not be very fast and not very slow. A good compromise between beautiful transitions and the convenience of the game is to change within one second. Let's set a constant for this to make it easier to change.

	const float transitionSpeed = 255f;

Now in LateUpdatewe can define the delta applied to the values. To do this, multiply the time delta by speed. It must be an integer because we don’t know how big it can be. A sharp drop in frame rate can make the delta more than 255.

In addition, we need to update while there are transition cells. Therefore, the code should be included while there is something in the list.

	void LateUpdate () {
		int delta = (int)(Time.deltaTime * transitionSpeed);
		cellTexture.SetPixels32(cellTextureData);
		cellTexture.Apply();
		enabled = transitioningCells.Count > 0;
	}

Also theoretically possible very high frame rates. In combination with a low transition speed, this can give us a delta of 0. For the change to take place, we force the delta minimum to be 1.

		int delta = (int)(Time.deltaTime * transitionSpeed);
		if (delta == 0) {
			delta = 1;
		}

Having received the delta, we can loop around all the transition cells and update their data. Suppose we have a method for this UpdateCellData, the parameters of which are the corresponding cell and delta.

		int delta = (int)(Time.deltaTime * transitionSpeed);
		if (delta == 0) {
			delta = 1;
		}
		for (int i = 0; i < transitioningCells.Count; i++) {
			UpdateCellData(transitioningCells[i], delta);
		}

At some point, the cell transition should complete. Assume that the method returns information about whether the transition is still ongoing. When it stops going on, we can remove the cell from the list. After that, we must decrement the iterator so as not to skip the cells.

		for (int i = 0; i < transitioningCells.Count; i++) {
			if (!UpdateCellData(transitioningCells[i], delta)) {
				transitioningCells.RemoveAt(i--);
			}
		}

The order in which the transition cells are processed is not important. Therefore, we do not have to delete the cell at the current index, which would force RemoveAtall cells to move after it. Instead, we move the last cell to the current index, and then delete the last.

			if (!UpdateCellData(transitioningCells[i], delta)) {
				transitioningCells[i--] =
					transitioningCells[transitioningCells.Count - 1];
				transitioningCells.RemoveAt(transitioningCells.Count - 1);
			}

Now we have to create a method UpdateCellData. To do his job, he will need an index and cell data, so let's start by getting them. It should also determine whether to continue updating the cell. By default, we will assume that it is not necessary. After completion of the work, it is necessary to apply the changed data and return the status “the update is continuing”.

	bool UpdateCellData (HexCell cell, int delta) {
		int index = cell.Index;
		Color32 data = cellTextureData[index];
		bool stillUpdating = false;
		cellTextureData[index] = data;
		return stillUpdating;
	}

Updating Cell Data


At this stage, we have a cell that is in the process of transition or has already completed it. First, let's check the status of the cell probe. If the cell is examined, but its G value is not yet equal to 255, then it is in the process of transition, so we will monitor this.

		bool stillUpdating = false;
		if (cell.IsExplored && data.g < 255) {
			stillUpdating = true;
		}
		cellTextureData[index] = data;

To perform the transition, we will add a delta to the G value of the cell. Arithmetic operations do not work with bytes, they are first converted to integer. Therefore, the sum will have the integer format, which must be converted to byte.

		if (cell.IsExplored && data.g < 255) {
			stillUpdating = true;
			int t = data.g + delta;
			data.g = (byte)t;
		}

But before the conversion, you need to make sure that the value does not exceed 255.

			int t = data.g + delta;
			data.g = t >= 255 ? (byte)255 : (byte)t;

Next, we need to do the same for visibility, which uses the value of R.

		if (cell.IsExplored && data.g < 255) {
			…
		}
		if (cell.IsVisible && data.r < 255) {
			stillUpdating = true;
			int t = data.r + delta;
			data.r = t >= 255 ? (byte)255 : (byte)t;
		}

Since the cell can become invisible again, we need to check whether it is necessary to decrease the value of R. This happens when the cell is invisible, but R is greater than zero.

		if (cell.IsVisible) {
			if (data.r < 255) {
				stillUpdating = true;
				int t = data.r + delta;
				data.r = t >= 255 ? (byte)255 : (byte)t;
			}
		}
		else if (data.r > 0) {
			stillUpdating = true;
			int t = data.r - delta;
			data.r = t < 0 ? (byte)0 : (byte)t;
		}

Now it’s UpdateCellDataready and visibility transitions are performed correctly.


Visibility Transitions.

Protection against duplicate transition elements


Transitions work, but duplicate items may appear in the list. This happens if the cell’s visibility state changes while it’s still in transition. For example, when the cell is visible during the movement of the squad only for a short time.

As a result of the appearance of duplicated elements, the cell transition is updated several times per frame, which leads to faster transitions and extra work. We can prevent this by checking before adding a cell whether it is already in the list. However, a list search on every callRefreshVisibilitycostly, especially when multiple cell transitions are performed. Instead, let's use another channel that has not yet been used to indicate whether the cell is in the process of transition, for example, value B. When adding a cell to the list, we will assign it the value 255, and add only those cells whose value is not equal to 255.

	public void RefreshVisibility (HexCell cell) {
		int index = cell.Index;
		if (ImmediateMode) {
			cellTextureData[index].r = cell.IsVisible ? (byte)255 : (byte)0;
			cellTextureData[index].g = cell.IsExplored ? (byte)255 : (byte)0;
		}
		else if (cellTextureData[index].b != 255) {
			cellTextureData[index].b = 255;
			transitioningCells.Add(cell);
		}
		enabled = true;
	}

For this to work, we need to reset the value of B after the completion of the cell transition.

	bool UpdateCellData (HexCell cell, int delta) {
		…
		if (!stillUpdating) {
			data.b = 0;
		}
		cellTextureData[index] = data;
		return stillUpdating;
	}


Transitions without duplicates.

Instantly loading visibility


Visibility changes are now always gradual, even when loading a map. This is illogical, because the map describes the state in which the cells are already visible, so the transition is inappropriate here. In addition, performing transitions for the many visible cells of a large map can slow down the game after loading. Therefore, before loading cells and squads, let's switch HexGrid.Loadto the instant transition mode.

	public void Load (BinaryReader reader, int header) {
		…
		cellShaderData.ImmediateMode = true;
		for (int i = 0; i < cells.Length; i++) {
			cells[i].Load(reader, header);
		}
		…
	}

So we redefine the initial setting of the instant transition mode, whatever it may be. Perhaps it is already turned off, or made a configuration option, so we will remember the initial mode and will switch to it after completion of work.

	public void Load (BinaryReader reader, int header) {
		…
		bool originalImmediateMode = cellShaderData.ImmediateMode;
		cellShaderData.ImmediateMode = true;
		…
		cellShaderData.ImmediateMode = originalImmediateMode;
	}

unitypackage

Height-dependent scope


So far we have used a constant scope of three for all units, but in reality it is more complicated. In the general case, we cannot see the object for two reasons: either some obstacle prevents us from seeing it, or the object is too small or far. In our game, we only implement the scope limitation.

We cannot see what is on the opposite side of the Earth, because the planet obscures us. We can only see to the horizon. Since the planet can approximately be considered a sphere, the higher the point of view, the more surface we can see, that is, the horizon depends on the height.


The horizon depends on the height of the viewpoint.

The limited visibility of our units mimics the horizon effect created by the curvature of the Earth. The range of their review depends on the size of the planet and the scale of the map. At least that is the logical explanation. But the main reason for reducing the scope is the gameplay, this is a limitation called the fog of war. However, understanding the physics underlying the field of view, we can conclude that a high point of view should have strategic value, because it moves away the horizon and allows you to look at lower obstacles. But so far we have not implemented it.

Height for review


To take the height into account when determining the scope, we need to know the height. This will be the usual height or level of water, depending on whether the land cell or water. Let's add this to the HexCellproperty.

	public int ViewElevation {
		get {
			return elevation >= waterLevel ? elevation : waterLevel;
		}
	}

But if the height affects the scope, then with a change in the viewing height of the cell, the visibility situation may also change. Since the cell has blocked or is now blocking the scope of several units, it is not so easy to determine what needs to be changed. The cell itself will not be able to solve this problem, so let it report a change in the situation HexCellShaderData. Suppose you HexCellShaderDatahave a method for this ViewElevationChanged. We will call it upon assignment HexCell.Elevation, if necessary.

	public int Elevation {
		get {
			return elevation;
		}
		set {
			if (elevation == value) {
				return;
			}
			int originalViewElevation = ViewElevation;
			elevation = value;
			if (ViewElevation != originalViewElevation) {
				ShaderData.ViewElevationChanged();
			}
			…
		}
	}

The same goes for WaterLevel.

	public int WaterLevel {
		get {
			return waterLevel;
		}
		set {
			if (waterLevel == value) {
				return;
			}
			int originalViewElevation = ViewElevation;
			waterLevel = value;
			if (ViewElevation != originalViewElevation) {
				ShaderData.ViewElevationChanged();
			}
			ValidateRivers();
			Refresh();
		}
	}

Reset visibility


Now we need to create a method HexCellShaderData.ViewElevationChanged. Determining how a general visibility situation changes is a complex task, especially when changing multiple cells at the same time. Therefore, we will not come up with any tricks, but simply plan to reset the visibility of all cells. Add a boolean field to keep track of whether to do this. Inside the method, we will simply set it to true and include the component. Regardless of the number of cells that have simultaneously changed, this will lead to a single reset.

	bool needsVisibilityReset;
	…
	public void ViewElevationChanged () {
		needsVisibilityReset = true;
		enabled = true;
	}

To reset the visibility values ​​of all cells, you must have access to them, which you do HexCellShaderDatanot have. So let's delegate this responsibility HexGrid. To do this, you need to add to the HexCellShaderDataproperty, which will allow you to refer to the grid. Then we can use it in LateUpdateto request a reset.

	public HexGrid Grid { get; set; }
	…
	void LateUpdate () {
		if (needsVisibilityReset) {
			needsVisibilityReset = false;
			Grid.ResetVisibility();
		}
		…
	}

Let's move on to HexGrid: set the link to the grid HexGrid.Awakeafter creating the shader data.

	void Awake () {
		HexMetrics.noiseSource = noiseSource;
		HexMetrics.InitializeHashGrid(seed);
		HexUnit.unitPrefab = unitPrefab;
		cellShaderData = gameObject.AddComponent();
		cellShaderData.Grid = this;
		CreateMap(cellCountX, cellCountZ);
	}

HexGridshould also get a method ResetVisibilityto discard all cells. Just make it go around all the cells in the loop and delegate the reset to itself.

	public void ResetVisibility () {
		for (int i = 0; i < cells.Length; i++) {
			cells[i].ResetVisibility();
		}
	}

Now we need to add to the HexCellmethod ResetVisibilty. It will simply zero out the visibility and trigger the visibility update. This must be done when the cell visibility is greater than zero.

	public void ResetVisibility () {
		if (visibility > 0) {
			visibility = 0;
			ShaderData.RefreshVisibility(this);
		}
	}

After resetting all visibility data, HexGrid.ResetVisibilityhe must again apply visibility to all the squads, for which he needs to know the scope of each squad. Suppose it can be obtained using the property VisionRange.

	public void ResetVisibility () {
		for (int i = 0; i < cells.Length; i++) {
			cells[i].ResetVisibility();
		}
		for (int i = 0; i < units.Count; i++) {
			HexUnit unit = units[i];
			IncreaseVisibility(unit.Location, unit.VisionRange);
		}
	}

For this to work, we will refactor the renaming HexUnit.visionRangeto HexUnit.VisionRangeand turn it into a property. While it will receive a constant value of 3, but in the future it will change.

	public int VisionRange {
		get {
			return 3;
		}
	}

Due to this, the visibility data will be reset and remain correct after changing the cell viewing height. But it is likely that we will change the rules for determining the scope and run recompilation in Play mode. So that the scope changes independently, let's run a reset HexGrid.OnEnablewhen recompilation is detected.

	void OnEnable () {
		if (!HexMetrics.noiseSource) {
			…
			ResetVisibility();
		}
	}

Now you can change the scope code and see the results, while remaining in Play mode.

Expanding the horizon


The calculation of the scope is determined HexGrid.GetVisibleCells. So that the height affects the scope, we can simply use the viewing height by fromCelltemporarily redefining the transmitted area. So we can easily check if this works.

	List GetVisibleCells (HexCell fromCell, int range) {
		…
		range = fromCell.ViewElevation;
		fromCell.SearchPhase = searchFrontierPhase;
		fromCell.Distance = 0;
		searchFrontier.Enqueue(fromCell);
		…
	}


Use height as a scope.

Obstacles to Visibility


Applying a viewing height as a scope only works correctly when all other cells are at zero height. But if all the cells have the same height as the point of view, then the field of view should be zero. In addition, cells with high heights should block the visibility of the low cells behind them. So far, none of this has been implemented.


The scope does not interfere.

The most correct way to determine the scope would be to check by the emission of rays, but it would quickly become costly and still produce strange results. We need a quick solution that creates good enough results that do not have to be perfect. In addition, it is important that the rules for determining the scope are simple, intuitive and predictable for players.

Our solution will be as follows - when determining the visibility of a cell, we will add the viewing height of the neighboring cell to the covered distance. In fact, this reduces the scope when we look at these cells, and if they are skipped, this will not allow us to reach the cells behind them.

				int distance = current.Distance + 1;
				if (distance + neighbor.ViewElevation > range) {
					continue;
				}


High cells block the view.

Shouldn't we see tall cells in the distance?
As in the case of a mountain range, we can see their slopes adjacent to the cells that we see. But we cannot see the mountains from above, therefore we cannot see the cells themselves.

Do not look around the corners


Now it seems that high cells block the view to low, but sometimes the scope penetrates through them, although it seems that this should not be. This happens because the search algorithm still finds a path to these cells, bypassing the blocking cells. As a result, it looks as if our area of ​​visibility can go around obstacles. To avoid this, we need to make sure that only the shortest paths are taken into account when determining the cell visibility. This can be done by dropping paths that become longer than necessary.

		HexCoordinates fromCoordinates = fromCell.coordinates;
		while (searchFrontier.Count > 0) {
			…
			for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) {
				…
				int distance = current.Distance + 1;
				if (distance + neighbor.ViewElevation > range ||
					distance > fromCoordinates.DistanceTo(neighbor.coordinates)
				) {
					continue;
				}
				…
			}
		}


We use only the shortest paths.

So we fixed most of the obviously erroneous cases. For nearby cells, this works well, because there are only shortest paths to them. Farther cells have more options for paths; therefore, over long distances, an envelope of visibility can still occur. This will not be a problem if the visibility areas remain small and the differences in adjacent heights are not too large.

And finally, instead of replacing the transmitted field of view, we add to it the height of the view. The squad’s own field of view indicates its height, flight altitude, or reconnaissance capabilities.

		range += fromCell.ViewElevation;


View with full field of view at a low point of view.

That is, the final rules of visibility apply to vision when moving along the shortest path to the field of view, taking into account the difference in cell height relative to the viewpoint. When a cell is out of scope, it blocks all paths through it. As a result, high observation points, from which nothing prevents the view, become strategically valuable.

What about obstructing the visibility of objects?
I decided that the relief objects will not affect the visibility, but it is possible. for example, make dense forests or walls add height to a cell. In this case, the player will be more difficult to evaluate the rules for determining the scope.

unitypackage

Cells that cannot be explored


The last problem with visibility concerns the edges of the map. The relief abruptly and without transitions ends, because the cells on the edge have no neighbors.


Marked edge of the map.

Ideally, the visual display of unexplored areas and edges of the map should be the same. We can achieve this by adding special cases when triangulating edges, when they have no neighbors, but this will require additional logic, and we will have to work with missing cells. Therefore, such a solution is nontrivial. An alternative approach is to force the boundary cells of the map to be unexplored, even if they are in the scope of the squad. This approach is much simpler, so let's use it. It also allows you to mark as unexplored and other cells, making it easier to achieve the creation of uneven edges of the map. In addition, hidden cells at the edges allow you to create roads and rivers that enter and leave the map of the river and road, because their end points will be out of scope.

We mark cells as investigated


To indicate that a cell can be examined, add to the HexCellproperty Explorable.

	public bool Explorable { get; set; }

Now a cell can be visible if it is an investigated one, so IsVisiblewe’ll change the property to take this into account.

	public bool IsVisible {
		get {
			return visibility > 0 && Explorable;
		}
	}

The same applies to IsExplored. However, for this we investigated the standard property. We need to convert it to an explicit property in order to be able to change the logic of its getter.

	public bool IsExplored {
		get {
			return explored && Explorable;
		}
		private set {
			explored = value;
		}
	}
	…
	bool explored;

Hide the edge of the map


You can hide the edge of a rectangular map in the method HexGrid.CreateCell. Cells that are not on the edge are investigated, all the rest are unexplored.

	void CreateCell (int x, int z, int i) {
		…
		HexCell cell = cells[i] = Instantiate(cellPrefab);
		cell.transform.localPosition = position;
		cell.coordinates = HexCoordinates.FromOffsetCoordinates(x, z);
		cell.Index = i;
		cell.ShaderData = cellShaderData;
		cell.Explorable =
			x > 0 && z > 0 && x < cellCountX - 1 && z < cellCountZ - 1;
		…
	}

Now the cards are darkened around the edges, hiding behind them huge unexplored spaces. As a result, the size of the studied area of ​​maps decreases in each dimension by two.


Unexplored edge of the map.

Is it possible to make the research state editable?
Yes, it is possible and will give us maximum versatility. At the same time, you also have to add this information when saving data.

Unexplored cells impede visibility


Finally, if the cell cannot be examined, then it should interfere with visibility. Change HexGrid.GetVisibleCellsto take this into account.

				if (
					neighbor == null ||
					neighbor.SearchPhase > searchFrontierPhase ||
					!neighbor.Explorable
				) {
					continue;
				}

unitypackage

Part 23: generating land


  • Fill new maps with generated landscapes.
  • We raise land over water, we flood some.
  • We control the amount of created land, its height and unevenness.
  • We add support for various configuration options to create variable maps.
  • We make it so that the same map can be generated again.

This part of the tutorial will be the beginning of a series on procedural map generation.

This part was created in Unity 2017.1.0.


One of the many generated maps.

Card generation


Although we can create any map, it takes a lot of time. It would be convenient if the application could help the designer by generating cards for him, which he can then modify to his taste. You can take another step and completely get rid of creating the design manually, completely transferring the responsibility of generating the finished map to the application. Due to this, the game can be played every time with a new card and each game session will be different. For all this to be possible, we must create a map generation algorithm.

The type of generation algorithm you need depends on the type of card you need. There is no one right approach, you always have to look for a compromise between credibility and playability.

For a card to be believable, it must seem quite possible and real to the player. This does not mean that the map should look like a part of our planet. It may be a different planet or a completely different reality. But if it should indicate the relief of the Earth, then it must at least partially resemble it.

Playability is related to how the cards correspond to the gameplay. Sometimes it conflicts with believability. For example, although mountain ranges can look beautiful, at the same time they greatly limit the movement and view of units. If this is undesirable, then you have to do without mountains, which will reduce credibility and limit the expressiveness of the game. Or we can save the mountains, but reduce their impact on gameplay, which also can reduce credibility.

In addition, feasibility must be considered. For example, you can create a very realistic earth-like planet by simulating tectonic plates, erosion, rains, volcanic eruptions, the effects of meteorites and the moon, and so on. But the development of such a system will require a lot of time. In addition, it can take a long time to generate such a planet, and players will not want to wait a few minutes before starting a new game. That is, simulation is a powerful tool, but it has a price.

Games often use trade-offs between credibility, playability, and feasibility. Sometimes such compromises are invisible and seem completely normal, and sometimes they look random, inconsistent or chaotic, depending on the decisions made during the development process. This applies not only to card generation, but when developing a procedural card generator, you need to pay special attention to this. You can spend a lot of time creating an algorithm that generates beautiful cards that turn out to be useless for the game you are creating.

In this tutorial series, we will create a land-like relief. It should look interesting, with great variability and the absence of large homogeneous areas. The relief scale will be large, maps will cover one or more continents, regions of the oceans, or even an entire planet. We need control over geography, including land masses, climate, the number of regions and terrain bumps. In this part, we will lay the foundation for the creation of sushi.

Getting started in edit mode


We will focus on the map, not on the gameplay, so it will be more convenient to launch the application in edit mode. Thanks to this, we can immediately see the cards. Therefore, we will change by HexMapEditor.Awakesetting the editing mode to true and turning on the shader keyword of this mode.

	void Awake () {
		terrainMaterial.DisableKeyword("GRID_ON");
		Shader.EnableKeyword("HEX_MAP_EDIT_MODE");
		SetEditMode(true);
	}

Card generator


Since quite a lot of code is needed to generate procedural maps, we will not add it directly to HexGrid. Instead, we will create a new component HexMapGenerator, and HexGridwill not know about it. This will simplify the transition to another algorithm if we need it.

The generator needs a link to the grid, so we’ll add a general field to it. In addition, we add a general method GenerateMapthat will deal with the work of the algorithm. We will give it the dimensions of the map as parameters, and then force it to be used to create a new empty map.

using System.Collections.Generic;
using UnityEngine;
public class HexMapGenerator : MonoBehaviour {
	public HexGrid grid;
	public void GenerateMap (int x, int z) {
		grid.CreateMap(x, z);
	}
}

Add an object with a component to the scene HexMapGeneratorand connect it to the grid.


Map generator object.

Change the menu of a new map


We will change it NewMapMenuso that it can generate cards, not just create empty ones. We will control its functionality through a Boolean field generateMaps, which by default has a value true. Let's create a general method for setting this field, as we did to switch options HexMapEditor. Add the appropriate switch to the menu and connect it to the method.

	bool generateMaps = true;
	public void ToggleMapGeneration (bool toggle) {
		generateMaps = toggle;
	}


Menu of a new card with a switch.

Give the menu a link to the map generator. Then we will force it to call the GenerateMapgenerator method if necessary , and not just execute the CreateMapgrid.

	public HexMapGenerator mapGenerator;
	…
	void CreateMap (int x, int z) {
		if (generateMaps) {
			mapGenerator.GenerateMap(x, z);
		}
		else {
			hexGrid.CreateMap(x, z);
		}
		HexMapCamera.ValidatePosition();
		Close();
	}


Connection to the generator.

Cell access


In order for the generator to work, it needs access to the cells. We HexGridalready have common methods GetCellthat require or position vector, or hexagon coordinates. The generator does not need to work with either one or the other, so we add two convenient methods HexGrid.GetCellthat will work with the coordinates of the offset or index of the cell.

	public HexCell GetCell (int xOffset, int zOffset) {
		return cells[xOffset + zOffset * cellCountX];
	}
	public HexCell GetCell (int cellIndex) {
		return cells[cellIndex];
	}

Now it HexMapGeneratorcan receive cells directly. For example, after creating a new map, he can use grass coordinates to set grass as the relief of the middle column of cells.

	public void GenerateMap (int x, int z) {
		grid.CreateMap(x, z);
		for (int i = 0; i < z; i++) {
			grid.GetCell(x / 2, i).TerrainTypeIndex = 1;
		}
	}


Column of grass on a small map.

unitypackage

Making sushi


When generating a map, we start completely without land. One can imagine that the whole world is flooded with one huge ocean. A land is created when part of the ocean floor is pushed up so much that it rises above the water. We need to decide how much land should be created this way, where it will appear and what shape it will have.

Raise the relief


Let's start small - raise one piece of land above the water. We create for this a method RaiseTerrainwith a parameter to control the size of the plot. Call this method in GenerateMap, replacing the previous test code. Let's start with a small piece of land consisting of seven cells.

	public void GenerateMap (int x, int z) {
		grid.CreateMap(x, z);
//		for (int i = 0; i < z; i++) {
//			grid.GetCell(x / 2, i).TerrainTypeIndex = 1;
//		}
		RaiseTerrain(7);
	}
	void RaiseTerrain (int chunkSize) {}

So far, we use the “grass” type of relief to denote the elevated land, and the original “sand” relief refers to the ocean. Make us RaiseTerraintake a random cell and change the type of its relief until we get the right amount of land.

To obtain a random cell, we add a method GetRandomCellthat determines a random cell index and obtains the corresponding cell from the grid.

	void RaiseTerrain (int chunkSize) {
		for (int i = 0; i < chunkSize; i++) {
			GetRandomCell().TerrainTypeIndex = 1;
		}
	}
	HexCell GetRandomCell () {
		return grid.GetCell(Random.Range(0, grid.cellCountX * grid.cellCountZ));
	}


Seven random sushi cells.

Since in the end we may need a lot of random cells or loop through all the cells several times, let's keep track of the number of cells in the cell itself HexMapGenerator.

	int cellCount;
	public void GenerateMap (int x, int z) {
		cellCount = x * z;
		…
	}
	…
	HexCell GetRandomCell () {
		return grid.GetCell(Random.Range(0, cellCount));
	}

Creation of one site


So far, we are turning seven random cells into land, and they can be anywhere. Most likely they do not form a single land area. In addition, we can select the same cells several times, so we get less land. To solve both problems, without restrictions, we will select only the first cell. After that, we should select only those cells that are next to the ones selected earlier. These restrictions are similar to the limitations of the path search, so we use the same approach here.

We add HexMapGeneratorour own property and the counter of the phase of the search border, as it was in HexGrid.

	HexCellPriorityQueue searchFrontier;
	int searchFrontierPhase;

Check that the priority queue exists before we need it.

	public void GenerateMap (int x, int z) {
		cellCount = x * z;
		grid.CreateMap(x, z);
		if (searchFrontier == null) {
			searchFrontier = new HexCellPriorityQueue();
		}
		RaiseTerrain(7);
	}

After creating a new map, the search boundary for all cells is zero. But if we are going to search for cells in the process of map generation, we will increase their search border in this process. If we perform many search operations, they may be ahead of the phase of the search boundary recorded HexGrid. This may interfere with the search for unit paths. To avoid this, at the end of the map generation process, we will reset the search phase of all cells to zero.

		RaiseTerrain(7);
		for (int i = 0; i < cellCount; i++) {
			grid.GetCell(i).SearchPhase = 0;
		}

Now I RaiseTerrainhave to look for the appropriate cells, and not select them randomly. This process is very similar to the search method in HexGrid. However, we will not visit cells more than once, so it will be enough for us to increase the phase of the search border by 1 instead of 2. Then we initialize the border with the first cell, which is randomly selected. As usual, in addition to setting its search phase, we assign its distance and heuristic to zero.

	void RaiseTerrain (int chunkSize) {
//		for (int i = 0; i < chunkSize; i++) {
//			GetRandomCell().TerrainTypeIndex = 1;
//		}
		searchFrontierPhase += 1;
		HexCell firstCell = GetRandomCell();
		firstCell.SearchPhase = searchFrontierPhase;
		firstCell.Distance = 0;
		firstCell.SearchHeuristic = 0;
		searchFrontier.Enqueue(firstCell);
	}

After that, the search loop will be mostly familiar to us. In addition, to continue the search until the border is empty, we need to stop when the fragment reaches the desired size, so we will track it. At each iteration, we will extract the next cell from the queue, set the type of its relief, increase the size, and then bypass the neighbors of this cell. All neighbors are simply added to the border if they have not been added there yet. We do not need to make any changes or comparisons. After completion, you need to clear the border.

		searchFrontier.Enqueue(firstCell);
		int size = 0;
		while (size < chunkSize && searchFrontier.Count > 0) {
			HexCell current = searchFrontier.Dequeue();
			current.TerrainTypeIndex = 1;
			size += 1;
			for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) {
				HexCell neighbor = current.GetNeighbor(d);
				if (neighbor && neighbor.SearchPhase < searchFrontierPhase) {
					neighbor.SearchPhase = searchFrontierPhase;
					neighbor.Distance = 0;
					neighbor.SearchHeuristic = 0;
					searchFrontier.Enqueue(neighbor);
				}
			}
		}
		searchFrontier.Clear();


A line of cells.

We got a single plot of the right size. It will be smaller only if there is not a sufficient number of cells. Because of the way the border is filled, the plot always consists of a line running northwest. It changes direction only when it reaches the edge of the map.

We connect cells


Land areas rarely resemble lines, and if they do, they are not always oriented in the same way. To change the shape of the site, we need to change the priorities of the cells. The first random cell can be used as the center of the plot. Then the distance to all other cells will be relative to this point. So we will give higher priority to cells that are closer to the center, so the site will not grow as a line, but around the center.

		searchFrontier.Enqueue(firstCell);
		HexCoordinates center = firstCell.coordinates;
		int size = 0;
		while (size < chunkSize && searchFrontier.Count > 0) {
			HexCell current = searchFrontier.Dequeue();
			current.TerrainTypeIndex = 1;
			size += 1;
			for (HexDirection d = HexDirection.NE; d <= HexDirection.NW; d++) {
				HexCell neighbor = current.GetNeighbor(d);
				if (neighbor && neighbor.SearchPhase < searchFrontierPhase) {
					neighbor.SearchPhase = searchFrontierPhase;
					neighbor.Distance = neighbor.coordinates.DistanceTo(center);
					neighbor.SearchHeuristic = 0;
					searchFrontier.Enqueue(neighbor);
				}
			}
		}


The accumulation of cells.

And in fact, now our seven cells are beautifully packed in a compact hexagonal area if the central cell does not appear on the edge of the map. Let's try now to use a plot size of 30.

		RaiseTerrain(30);


Sushi mass in 30 cells.

We again got the same shape, although there weren’t enough cells to get the right hexagon. Since the radius of the plot is larger, it is more likely to be close to the edge of the map, which will force it to take a different shape.

Sushi randomization


We don’t want all areas to look the same, so we’ll slightly change the cell priorities. Each time we add a neighboring cell to the border, if the next number is Random.valueless than a certain threshold value, then the heuristic of this cell becomes not 0, but 1. Let's use the value 0.5 as a threshold, that is, it will most likely affect half the cells.

					neighbor.Distance = neighbor.coordinates.DistanceTo(center);
					neighbor.SearchHeuristic = Random.value < 0.5f ? 1: 0;
					searchFrontier.Enqueue(neighbor);


Distorted area.

By increasing the search heuristic of the cell, we made it visit later than expected. At the same time, other cells located one step further from the center will be visited earlier, unless they also increase the heuristic. This means that if we increase the heuristic of all cells by one value, then this will not affect the map. That is, threshold 1 will not have an effect, like threshold 0. And threshold 0.8 will be equivalent to 0.2. That is, the probability of 0.5 makes the search process the most "trembling."

The appropriate amount of oscillation depends on the desired type of terrain, so let's make it customizable. Add a generic float field jitterProbabilitywith the attribute to the generatorRangelimited in the range 0–0.5. Let's give it a default value equal to the average of this interval, i.e. 0.25. This will allow us to configure the generator in the Unity inspector window.

	[Range(0f, 0.5f)]
	public float jitterProbability = 0.25f;


Probability of fluctuations.

Can you make it customizable in the game UI?
This is possible, and in most games this is done. In the tutorial, I will not add this parameter to the game UI, but nothing prevents you from doing this. However, as a result, the generator will have quite a few configuration options, so consider this when creating the UI. You can wait until you know all the options. At this point, you can even choose other restrictions, different terminology and limit the options available to players.

Now, to make a decision about when the heuristic should be equal to 1, we use probability instead of a constant value.

					neighbor.SearchHeuristic =
						Random.value < jitterProbability ? 1: 0;

We use heuristic values ​​0 and 1. Although larger values ​​can be used, this will greatly worsen the deformation of the sections, most likely turning them into a bunch of stripes.

Raise some land


We will not be limited to the generation of one piece of land. For example, we place a call RaiseTerraininside a loop to get five sections.

		for (int i = 0; i < 5; i++) {
			RaiseTerrain(30);
		}


Five plots of land.

Although now we are generating five plots of 30 cells each, but not necessarily get exactly 150 cells of land. Since each site is created separately, they do not know about each other, so they can intersect. This is normal because it can create more interesting landscapes than just a set of isolated sections.

To increase the variability of land, we can also change the size of each plot. Add two integer fields to control the minimum and maximum sizes of the plots. Assign them a sufficiently large interval, for example, 20-200. I will make the standard minimum equal to 30, and the standard maximum - 100.

	[Range(20, 200)]
	public int chunkSizeMin = 30;
	[Range(20, 200)]
	public int chunkSizeMax = 100;


Sizing interval.

We use these fields to randomly determine the size of the area when called RaiseTerrain.

			RaiseTerrain(Random.Range(chunkSizeMin, chunkSizeMax + 1));


Five randomly sized sections on the middle map.

Create enough sushi


While we can not particularly control the amount of land generated. Although we can add the configuration option for the number of plots, the plots themselves are random in size and may overlap slightly or strongly. Therefore, the number of sites does not guarantee the receipt on the map of the required amount of land. Let's add an option to directly control the percentage of land expressed as an integer. Since 100% land or water is not very interesting, we limit it to the interval 5–95, with a value of 50 by default.

	[Range(5, 95)]
	public int landPercentage = 50;


Percentage of sushi.

To guarantee the creation of the right amount of land, we just need to continue to raise areas of the terrain until we get a sufficient amount. To do this, we need to control the process, which will complicate the generation of land. Therefore, let's replace the existing cycle of raising sites by calling a new method CreateLand. The first thing this method does is calculate the number of cells that should become land. This amount will be our total sum of sushi cells.

	public void GenerateMap (int x, int z) {
		…
//		for (int i = 0; i < 5; i++) {
//			RaiseTerrain(Random.Range(chunkSizeMin, chunkSizeMax + 1));
//		}
		CreateLand();
		for (int i = 0; i < cellCount; i++) {
			grid.GetCell(i).SearchPhase = 0;
		}
	}
	void CreateLand () {
		int landBudget = Mathf.RoundToInt(cellCount * landPercentage * 0.01f);
	}

CreateLandwill cause RaiseTerrainuntil we have spent the whole amount of cells. In order not to exceed the amount, we change RaiseTerrainso that it receives the amount as an additional parameter. After finishing work, he must return the remaining amount.

//	void RaiseTerrain (int chunkSize) {
	int RaiseTerrain (int chunkSize, int budget) {
		…
		return budget;
	}

The amount should decrease each time the cell is removed from the border and converted into land. If after this the whole amount is spent, then we must stop the search and complete the site. In addition, this should be done only when the current cell is not yet land.

		while (size < chunkSize && searchFrontier.Count > 0) {
			HexCell current = searchFrontier.Dequeue();
			if (current.TerrainTypeIndex == 0) {
				current.TerrainTypeIndex = 1;
				if (--budget == 0) {
					break;
				}
			}
			size += 1;
			…
		}

Now it CreateLandcan raise land until it spends the entire amount of cells.

	void CreateLand () {
		int landBudget = Mathf.RoundToInt(cellCount * landPercentage * 0.01f);
		while (landBudget > 0) {
			landBudget = RaiseTerrain(
				Random.Range(chunkSizeMin, chunkSizeMax + 1), landBudget
			);
		}
	}


Exactly half the map became land.

unitypackage

Take into account the height


Land is not just a flat plate, limited by the coastline. She has a changing height, containing hills, mountains, valleys, lakes, and so on. Large differences in height exist due to the interaction of slowly moving tectonic plates. Although we will not simulate it, our land areas should in some way resemble such plates. Sites do not move, but may intersect. And we can take advantage of this.

Push the land up


Each plot represents a portion of land pushed out from the bottom of the ocean. Therefore, let's constantly increase the height of the current cell in RaiseTerrainand see what happens.

			HexCell current = searchFrontier.Dequeue();
			current.Elevation += 1;
			if (current.TerrainTypeIndex == 0) {
				…
			}


Land with heights.

We got the heights, but it's hard to see. You can make them more legible if you use your own type of terrain for each level of height, like geographic layering. We will only do this so that the heights are more noticeable, so you can simply use the height level as an elevation index.

What happens if the height exceeds the number of terrain types?
The shader will use the latest texture from the texture array. In our case, the last type of relief is snow, so we get a line of snow.

Instead of updating the terrain type of the cell with each change in height, let's create a separate method SetTerrainTypeto set all terrain types only once.

	void SetTerrainType () {
		for (int i = 0; i < cellCount; i++) {
			HexCell cell = grid.GetCell(i);
			cell.TerrainTypeIndex = cell.Elevation;
		}
	}

We will call this method after creating sushi.

	public void GenerateMap (int x, int z) {
		…
		CreateLand();
		SetTerrainType();
		…
	}

Now he RaiseTerraincan not deal with the type of relief, and focus on heights. To do this, you need to change its logic. If the new height of the current cell is 1, then it has just become drier, so the sum of the cells has decreased, which can lead to the completion of the growth of the site.

			HexCell current = searchFrontier.Dequeue();
			current.Elevation += 1;
			if (current.Elevation == 1 && --budget == 0) {
				break;
			}
//			if (current.TerrainTypeIndex == 0) {
//				current.TerrainTypeIndex = 1;
//				if (--budget == 0) {
//					break;
//				}
//			}


Stratification of the layers.

Add water


Let's explicitly indicate which cells are water or land, setting the water level for all cells to 1. Do this in GenerateMapbefore creating land.

	public void GenerateMap (int x, int z) {
		cellCount = x * z;
		grid.CreateMap(x, z);
		if (searchFrontier == null) {
			searchFrontier = new HexCellPriorityQueue();
		}
		for (int i = 0; i < cellCount; i++) {
			grid.GetCell(i).WaterLevel = 1;
		}
		CreateLand();
		…
	}

Now for the designation of land layers we can use all types of terrain. All submarine cells will remain sand, as will the lowest land cells. This can be done by subtracting the water level from the height and using the value as an index of the relief type.

	void SetTerrainType () {
		for (int i = 0; i < cellCount; i++) {
			HexCell cell = grid.GetCell(i);
			if (!cell.IsUnderwater) {
				cell.TerrainTypeIndex = cell.Elevation - cell.WaterLevel;
			}
		}
	}


Land and water.

Raise the water level


We are not limited to one water level. Let's make it customizable using a common field with an interval of 1–5 and a default value of 3. Use this level when initializing the cells.

	[Range(1, 5)]
	public int waterLevel = 3;
	…
	public void GenerateMap (int x, int z) {
		…
		for (int i = 0; i < cellCount; i++) {
			grid.GetCell(i).WaterLevel = waterLevel;
		}
		…
	}



Water level 3.

When the water level is 3, we get less land than we expected. This is because it RaiseTerrainstill believes that the water level is 1. Let's fix it.

			HexCell current = searchFrontier.Dequeue();
			current.Elevation += 1;
			if (current.Elevation == waterLevel && --budget == 0) {
				break;
			}

Using higher water levels leads to that. that the cells do not become land immediately. When the water level is 2, the first section will still remain under water. The bottom of the ocean has risen, but still remains under water. A land is formed only at the intersection of at least two sections. The higher the water level, the more sites must cross to create land. Therefore, with rising water levels, land becomes more chaotic. In addition, when more plots are needed, it is more likely that they will intersect on already existing land, which is why mountains will be more common and flat land less often, as in the case of using smaller plots.





Water levels are 2–5, sushi is always 50%.

unitypackage

Vertical movement


So far we have raised the plots up one level at a time, but we don’t have to limit ourselves to this.

High sites


Although each section increases the height of its cells by one level, clippings may occur. This happens when the edges of two sections touch. This can create isolated cliffs, but long cliff lines will be rare. We can increase the frequency of their appearance by increasing the height of the plot by more than one step. But this needs to be done only for a certain proportion of sites. If all areas rise high, it will be very difficult to move along the terrain. So let's make this parameter customizable using a probability field with a default value of 0.25.

	[Range(0f, 1f)]
	public float highRiseProbability = 0.25f;


The likelihood of a strong rise in the cells.

Although we can use any increase in height for high areas, this quickly gets out of hand. The height difference 2 already creates cliffs, so this is enough. Since you can skip a height equal to the water level, we need to change the way we determine if a cell has become land. If it was below the water level, and now it is at the same level or higher, then we created a new land cell.

		int rise = Random.value < highRiseProbability ? 2 : 1;
		int size = 0;
		while (size < chunkSize && searchFrontier.Count > 0) {
			HexCell current = searchFrontier.Dequeue();
			int originalElevation = current.Elevation;
			current.Elevation = originalElevation + rise;
			if (
				originalElevation < waterLevel &&
				current.Elevation >= waterLevel && --budget == 0
			) {
				break;
			}
			size += 1;
			…
		}





The probabilities of a strong increase in height are 0.25, 0.50, 0.75 and 1.

Lower the land


The land does not always rise, sometimes it falls. When land falls low enough, water fills it and it is lost. So far we are not doing this. Since we only push the areas up, the land usually looks like a set of rather round areas mixed together. If we sometimes lower the area down, we get more varying forms.


Big map without sunken sushi.

We can control the frequency of land subsidence using another probability field. Since lowering can destroy land, the probability of lowering should always be lower than the probability of raising. Otherwise, it may take a very long time to get the right percentage of land. Therefore, let's use a maximum lowering probability of 0.4 with a default value of 0.2.

	[Range(0f, 0.4f)]
	public float sinkProbability = 0.2f;


Probability of lowering.

Lowering the site is similar to raising, with some differences. Therefore, we duplicate the method RaiseTerrainand change its name to SinkTerrain. Instead of determining the magnitude of the rise, we need a lowering value that can use the same logic. At the same time, comparisons to check whether we have passed through the surface of the water need to be turned over. In addition, when lowering the relief, we are not limited to the sum of the cells. Instead, each lost sushi cell returns the amount spent on it, so we increase it and continue to work.

	int SinkTerrain (int chunkSize, int budget) {
		…
		int sink = Random.value < highRiseProbability ? 2 : 1;
		int size = 0;
		while (size < chunkSize && searchFrontier.Count > 0) {
			HexCell current = searchFrontier.Dequeue();
			int originalElevation = current.Elevation;
			current.Elevation = originalElevation - sink;
			if (
				originalElevation >= waterLevel &&
				current.Elevation < waterLevel
//				&& --budget == 0
			) {
//				break;
				budget += 1;
			}
			size += 1;
			…
		}
		searchFrontier.Clear();
		return budget;
	}

Now, at each iteration inside, CreateLandwe must either lower or raise the land, depending on the probability of lowering.

	void CreateLand () {
		int landBudget = Mathf.RoundToInt(cellCount * landPercentage * 0.01f);
		while (landBudget > 0) {
			int chunkSize = Random.Range(chunkSizeMin, chunkSizeMax - 1);
			if (Random.value < sinkProbability) {
				landBudget = SinkTerrain(chunkSize, landBudget);
			}
			else {
				landBudget = RaiseTerrain(chunkSize, landBudget);
			}
		}
	}





The probability of dropping is 0.1, 0.2, 0.3, and 0.4.

Limit height


At the current stage, we can potentially overlap many sections, sometimes with several increases in height, some of which can go down and then rise again. At the same time, we can create very high, and sometimes very low heights, especially when a high percentage of land is needed.


Huge heights at 90% land.

To limit the height, let's add a custom minimum and maximum. A reasonable minimum will be somewhere between −4 and 0, and an acceptable maximum may be in the range of 6–10. Let the default values ​​be −2 and 8. When manually editing the map, they will be outside the acceptable limit, so you can change the slider of the editor’s UI, or leave it as it is.

	[Range(-4, 0)]
	public int elevationMinimum = -2;
	[Range(6, 10)]
	public int elevationMaximum = 8;


Minimum and maximum heights.

Now RaiseTerrainwe must make sure that the height does not exceed the permissible maximum. This can be done by checking if the current cells are too high. If so, then we skip them without changing the height and adding their neighbors. This will lead to the fact that land areas will avoid areas that have reached a maximum height, and grow around them.

			HexCell current = searchFrontier.Dequeue();
			int originalElevation = current.Elevation;
			int newElevation = originalElevation + rise;
			if (newElevation > elevationMaximum) {
				continue;
			}
			current.Elevation = newElevation;
			if (
				originalElevation < waterLevel &&
				newElevation >= waterLevel && --budget == 0
			) {
				break;
			}
			size += 1;

Do the same in SinkTerrain, but for a minimum height.

			HexCell current = searchFrontier.Dequeue();
			int originalElevation = current.Elevation;
			int newElevation = current.Elevation - sink;
			if (newElevation < elevationMinimum) {
				continue;
			}
			current.Elevation = newElevation;
			if (
				originalElevation >= waterLevel &&
				newElevation < waterLevel
			) {
				budget += 1;
			}
			size += 1;


Limited height with 90% land.

Negative Altitude Preservation


At this point, the save and load code cannot handle negative heights because we store the height as byte. A negative number is converted when saved to a large positive. Therefore, when saving and loading the generated map, very high ones may appear in place of the original underwater cells.

We can add support for negative heights by storing it as an integer, not a byte. However, we still do not need to support multiple levels of height. In addition, we can offset the stored value by adding 127. This will allow us to correctly store heights in the range −127–128 within one byte. Change HexCell.Saveaccordingly.

	public void Save (BinaryWriter writer) {
		writer.Write((byte)terrainTypeIndex);
		writer.Write((byte)(elevation + 127));
		…
	}

Since we changed the way we save map data, we increase it SaveLoadMenu.mapFileVersionto 4.

	const int mapFileVersion = 4;

And finally, change it HexCell.Loadso that it subtracts 127 from the heights loaded from version 4 files.

	public void Load (BinaryReader reader, int header) {
		terrainTypeIndex = reader.ReadByte();
		ShaderData.RefreshTerrain(this);
		elevation = reader.ReadByte();
		if (header >= 4) {
			elevation -= 127;
		}
		…
	}

unitypackage

Recreating the same map


Now we can create a wide variety of maps. When generating each new result will be random. We can control using the configuration options only the characteristics of the card, but not the most accurate form. But sometimes we need to recreate the exact same map again. For example, to share a beautiful map with a friend, or start again after manually editing it. It’s also useful in the game development process, so let's add this feature.

Using Seed


To make the map generation process unpredictable, we use Random.Rangeand Random.value. To get the same pseudo-random sequence of numbers again, you need to use the same seed value. We have already taken a similar approach before, in HexMetrics.InitializeHashGrid. It first saves the current state of the number generator initialized with a specific seed value, and then restores its original state. We can use the same approach for HexMapGenerator.GenerateMap. We can again remember the old state and restore it after completion, so as not to interfere with anything else that uses Random.

	public void GenerateMap (int x, int z) {
		Random.State originalRandomState = Random.state;
		…
		Random.state = originalRandomState;
	}

Next, we need to make available the seed used to generate the last card. This is done using a common integer field.

	public int seed;


Display seed.

Now we need the seed value to initialize Random. To create random cards you need to use a random seed. The simplest approach is to use an arbitrary seed value to generate Random.Range. So that it does not affect the initial random state, we need to do this after saving it.

	public void GenerateMap (int x, int z) {
		Random.State originalRandomState = Random.state;
		seed = Random.Range(0, int.MaxValue);
		Random.InitState(seed);
		…
	}

Since after completion we restore a random state, if we immediately generate another card, as a result we get the same seed value. In addition, we do not know how the initial random state was initialized. Therefore, although it can serve as an arbitrary starting point, we need something more to randomize it with each call.

There are various ways to initialize random number generators. In this case, you can simply combine several arbitrary values ​​that vary over a wide range, that is, the probability of re-generating the same card will be low. For example, we use the lower 32 bits of the system time, expressed in cycles, plus the current runtime of the application. Combine these values ​​using the bitwise exclusive OR operation so that the result is not very large.

		seed = Random.Range(0, int.MaxValue);
		seed ^= (int)System.DateTime.Now.Ticks;
		seed ^= (int)Time.unscaledTime;
		Random.InitState(seed);

The resulting number may be negative, which for a public value seed does not look very nice. We can make it strictly positive by using bitwise masking with a maximum integer value that will reset the sign bit.

		seed ^= (int)Time.unscaledTime;
		seed &= int.MaxValue;
		Random.InitState(seed);

Reusable Seed


We still generate random cards, but now we can see what seed value was used for each of them. To recreate the same map again, we must order the generator to use the same seed value again, rather than creating a new one. We will do this by adding a switch using a Boolean field.

	public bool useFixedSeed;


Option to use a constant seed.

If a constant seed is selected, then we simply skip generating the new seed in GenerateMap. If we do not manually change the seed field, the result will be the same map again.

		Random.State originalRandomState = Random.state;
		if (!useFixedSeed) {
			seed = Random.Range(0, int.MaxValue);
			seed ^= (int)System.DateTime.Now.Ticks;
			seed ^= (int)Time.time;
			seed &= int.MaxValue;
		}
		Random.InitState(seed);

Now we can copy the seed value of the map we like and save it somewhere, in order to generate it again in the future. Do not forget that we will get the same card only if we use exactly the same generator parameters, that is, the same card size, as well as all other configuration options. Even a small change in these probabilities can create a completely different map. Therefore, in addition to seed, we need to remember all the settings.



Large cards with seed values ​​0 and 929396788, standard parameters.

unitypackage

Also popular now: