Creating Procedural Planets in Unity — Part 3

Welcome to Part 3 of an ongoing tutorial series about the Poly Universe Project, where I’m sharing what I’ve learned about making procedural planets in Unity. I just glanced at the first tutorial and noticed that is has been almost exactly one year now since I started this series. That’s… not exactly how long I’d intended these tutorials to take! But better late than never I hope. =)

At the end of Part 2, we were able to make simple continents on our planet by extruding polygons, and using vertex shading to color them. Now we’re going to improve on this by giving the planet a transparent ocean with waves along the shoreline, and by adding a simple form of Ambient Occlusion.

(As always, the full working source for this tutorial is available on GitHub)

Simple Ambient Occlusion

Ambient Occlusion is a lighting effect, whereby the corners and recesses of an object appear darker because they are less exposed to light sources. It’s usually extremely expensive to calculate proper ambient occlusion, but since we’re making a simple geometric planet, it’s actually quite simple for us: Every time we extrude a continent, we can expect the base of that continent (the place where it meets the lower terrain) to be slightly darker due to ambient occlusion.

Better still, since we’ve elected not to texture this planet, the texture coordinates of each vertex on our world are still free to be repurposed. In this case, we’re going to use the Y-coordinate of the planet’s UV coordinates to encode an ambient occlusion value. The higher this value is, the darker the resulting vertex will appear.

So first things first: Whenever we extrude a set of polygons, we generate a set of new ‘stitched’ polys which connect the extruded polys back to their un-extruded neighbors. We’re going to add a function that applies an ambient occlusion term to these stitched polys, by writing to their UV coordinates:

public class PolySet : HashSet<Polygon> {...public void ApplyAmbientOcclusionTerm(float AOForOriginalVerts,
float AOForNewVerts) {
foreach (Polygon poly in this) {
for (int i = 0; i < 3; i++) {
float aoTerm = (poly.m_Vertices[i] >
m_StitchedVertexThreshold) ?
AOForNewVerts : AOForOriginalVerts;
Vector2 uv = poly.m_UVs[i];
uv.y = aoTerm;
poly.m_UVs[i] = uv;
}
}
}
...}

This function let’s us set how much ambient occlusion we expect to see on the extruded side of the new polygons, and on the original un-extruded side. If we were making a hill by pushing polys outward, then we’d expect the un-extruded side to be the one with ambient occlusion on it. But we can also reverse the inputs and make the extruded side be the one with ambient occlusion. This will come in handy when we make the ocean floor, because we’ll actually be extruding polygons _inward_ and in that case it’s the extruded side that forms a hard corner and needs ambient occlusion.

With this data in place, we need to make a small update to our mesh generator, just to add the UV data:

Shader "Custom/VertexColoredShader" {    
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
_AmbientOcclusionIntensity("AO Intensity", Range(0,1)) = 0.5
}
SubShader {
Tags{ "RenderType" = "Opaque"}
LOD 200
Blend SrcAlpha OneMinusSrcAlpha
CGPROGRAM
#pragma surface surf Standard fullforwardshadows addshadow
#pragma target 3.0
sampler2D _MainTex; struct Input {
float2 uv_MainTex;
float4 color : Color;
};
half _Glossiness;
half _Metallic;
fixed4 _Color;
half _Alpha;
half _AmbientOcclusionIntensity;
void surf(Input IN, inout SurfaceOutputStandard o) {
fixed4 c = IN.color;
float ambientOcclusionStrength = IN.uv_MainTex.y;
o.Albedo = c * lerp(1.0, (1.0f - _AmbientOcclusionIntensity),
ambientOcclusionStrength);
o.Metallic = _Metallic;
o.Smoothness = _Glossiness;
}
ENDCG
}
FallBack "Diffuse"
}

Proper Insetting-

It turns out we’re only halfway done with making good looking ambient occlusion though! The new stitched polys on the sides of our continents might have correct ambient occlusion, but the horizontal polygons that meet the edge of a continent need to get darker as well. To accomplish this, we need to flesh out how Insetting works. Insetting is functionally the same as Extruding, but instead of pushing polygons outward from the center of the planet, we’re going to move then horizontally ‘inward’ away from their neighbors.

This means that, for every Edge along the border of a PolySet, we need to record what the ‘inward’ direction is — the direction that leads away from the Edge and into the PolySet. We can do that like so, in the Edge constructor:

public class Edge {    
public Polygon m_InnerPoly;
public Polygon m_OuterPoly;
public List<int> m_OuterVerts;
public List<int> m_InnerVerts;
public int m_InwardDirectionVertex;
public Edge(Polygon inner_poly, Polygon outer_poly) {
m_InnerPoly = inner_poly;
m_OuterPoly = outer_poly;
m_OuterVerts = new List<int>(2);
m_InnerVerts = new List<int>(2);
// The inward vertex is quite simply the one remaining vertex
// after we've found the two vertices that form the Edge:
foreach (int vertex in inner_poly.m_Vertices) {
if (outer_poly.m_Vertices.Contains(vertex))
m_InnerVerts.Add(vertex);
else
m_InwardDirectionVertex = vertex;
}
...

}
}

And if we know the direction of each Edge, we can look at any given vertex on that edge, average the inward directions of the Edges that touch that vertex, and have a good idea of which direction that vertex needs to move in order to shrink the PolySet inward. Like so:

public EdgeSet : HashSet<Edge> {  public Dictionary<int, Vector3> GetInwardDirections(List<Vector3> 
vertexPositions) {
var inwardDirections = new Dictionary<int, Vector3>();
var numContributions = new Dictionary<int, int>();

foreach(Edge edge in this) {
Vector3 innerVertexPosition =
vertexPositions[edge.m_InwardDirectionVertex];
Vector3 edgePosA = vertexPositions[edge.m_InnerVerts[0]];
Vector3 edgePosB = vertexPositions[edge.m_InnerVerts[1]];
Vector3 edgeCenter = Vector3.Lerp(edgePosA, edgePosB, 0.5f);
Vector3 innerVector = (innerVertexPosition -
edgeCenter).normalized;
for(int i = 0; i < 2; i++) {
int edgeVertex = edge.m_InnerVerts[i];
if (inwardDirections.ContainsKey(edgeVertex)) {
inwardDirections[edgeVertex] += innerVector;
numContributions[edgeVertex]++;
}
else {
inwardDirections.Add(edgeVertex, innerVector);
numContributions.Add(edgeVertex, 1);
}
}
}
// Now we average the contributions that each vertex received,
// and we can return the result.
foreach(KeyValuePair<int, int> kvp in numContributions) {
int vertexIndex = kvp.Key;
int contributionsToThisVertex = kvp.Value;
inwardDirections[vertexIndex] = (inwardDirections[vertexIndex]
/ contributionsToThisVertex).normalized;
}
return inwardDirections;
}
}

Now, whenever we want to extrude a continent on our world, we first Inset the polys — making a horizontal strip that we can apply ambient occlusion to, and then we Extrude the polys — making another vertical strip that we can apply ambient occlusion to. The combined result is a subtle, but pleasant shading that improves the readability of our world.

But that ambient occlusion term is useful for something else to.

In practice, what we’ve create is more general than an ambient occlusion term. We have a way or marking strips of polys so that one side is the ‘inner edge’ and one side is the ‘outer edge’. We can also use that to make an ocean mesh with waves along the shore line. We start that by making a copy of our world’s polygons, right after we choose which Polys will represent land. All the polys that aren’t land are, naturally, the ocean. =) And if we take the ocean polys and inset them, we create a strip around the land which can be used to render waves. Here’s a simple, simple wave shader that turns the shoreline white periodically using a timer:

Shader "Custom/TransparentVertexColoredShader" {
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
_Alpha("Alpha", Range(0,1)) = 0.5
}
SubShader {
Tags{ "RenderType" = "Opaque"}
LOD 200
Blend SrcAlpha OneMinusSrcAlpha
CGPROGRAM
#pragma surface surf Standard fullforwardshadows addshadow alpha
#pragma target 3.0

sampler2D _MainTex;
struct Input {
float2 uv_MainTex;
float4 color : Color;
};
half _Glossiness;
half _Metallic;
fixed4 _Color;
half _Alpha;
void surf(Input IN, inout SurfaceOutputStandard o) {
fixed4 c = IN.color;
c.a = _Alpha;
float waveStrength = IN.uv_MainTex.y * (sin(_Time.y * 2.0) *
0.5f + 0.5f);
fixed4 waveColor = fixed4(1.0,1.0,1.0,1.0);
o.Albedo = lerp(c, waveColor, waveStrength);
o.Metallic = _Metallic;
o.Smoothness = _Glossiness;
o.Alpha = c.a;
}
ENDCG
}
FallBack "Diffuse"
}

And if we modify our GenerateMesh() function to take a material as a parameter, we can reuse it to generate both Ocean and Ground meshes:

public GameObject GenerateMesh(string name, Material material) {
...
Vector2[] uvs = new Vector2[vertexCount]; for (int i = 0; i < m_Polygons.Count; i++) {
var poly = m_Polygons[i];
...
uvs[i * 3 + 0] = poly.m_UVs[0];
uvs[i * 3 + 1] = poly.m_UVs[1];
uvs[i * 3 + 2] = poly.m_UVs[2];
...
}
terrainMesh.uv = uvs; terrainMesh.SetTriangles(indices, 0);
MeshFilter terrainFilter = meshObject.AddComponent<MeshFilter>();
terrainFilter.mesh = terrainMesh;
return meshObject;
}

The final code for generating both solid ground, and ocean end up looking like so:

public void Start() {
InitAsIcosohedron();
Subdivide(3);
CalculateNeighbors(); Color32 colorOcean = new Color32( 0, 80, 220, 0);
Color32 colorGrass = new Color32( 0, 220, 0, 0);
Color32 colorDirt = new Color32(180, 140, 20, 0);
Color32 colorDeepOcean = new Color32( 0, 40, 110, 0);

// Start by making everything ocean colored:
foreach (Polygon p in m_Polygons)
p.m_Color = colorOcean;
// Now we build a set of Polygons that will become the land.
// We do this by generating randomly sized spheres on the surface
// of the planet, and adding any Polygon that falls inside that
// sphere.
PolySet landPolys = new PolySet();
PolySet sides;
// Grab polygons that are inside random spheres. These will be the
// basis of our planet's continents.
for(int i = 0; i < m_NumberOfContinents; i++) {
float continentSize = Random.Range(m_ContinentSizeMin,
m_ContinentSizeMax);
PolySet newLand = GetPolysInSphere(Random.onUnitSphere,
continentSize, m_Polygons);
landPolys.UnionWith(newLand);
}
// While we're here, let's make a group of oceanPolys. It's pretty
// simple: Any Polygon that isn't in the landPolys set must be in
// the oceanPolys set instead.
var oceanPolys = new PolySet();
foreach (Polygon poly in m_Polygons) {
if (!landPolys.Contains(poly))
oceanPolys.Add(poly);
}
// Let's create the ocean surface as a separate mesh.
// First, let's make a copy of the oceanPolys so we can
// still use them to also make the ocean floor later.
var oceanSurface = new PolySet(oceanPolys);
sides = Inset(oceanSurface, 0.05f);
sides.ApplyColor(colorOcean);
sides.ApplyAmbientOcclusionTerm(1.0f, 0.0f);
if (m_OceanMesh != null)
Destroy(m_OceanMesh);
// Ocean Material should be assigned in the editor. It's just a
// material that uses the TransparentVertexShader that we created
// earlier.
m_OceanMesh = GenerateMesh("Ocean Surface", m_OceanMaterial);

// Back to land for a while! We start by making it green. =)
foreach (Polygon landPoly in landPolys) {
landPoly.m_Color = colorGrass;
}
// The Extrude function will raise the land Polygons up out of the
// water. It also generates a strip of new Polygons to connect the
// newly raised land back down to the water level. We can color
// this vertical strip of land brown like dirt.
sides = Extrude(landPolys, 0.05f);
sides.ApplyColor(colorDirt);
sides.ApplyAmbientOcclusionTerm(1.0f, 0.0f);

// Grab additional polygons to generate hills, but only from the
// set of polygons that are land.
PolySet hillPolys = landPolys.RemoveEdges();
sides = Inset(hillPolys, 0.03f);
sides.ApplyColor(colorGrass);
sides.ApplyAmbientOcclusionTerm(0.0f, 1.0f);
sides = Extrude(hillPolys, 0.05f);
sides.ApplyColor(colorDirt);
// Hills have dark ambient occlusion on the bottom, and light on
// top.
sides.ApplyAmbientOcclusionTerm(1.0f, 0.0f);
// Time to return to the oceans.
sides = Extrude(oceanPolys, -0.02f);
sides.ApplyColor(colorOcean);
sides.ApplyAmbientOcclusionTerm(0.0f, 1.0f);
sides = Inset(oceanPolys, 0.02f);
sides.ApplyColor(colorOcean);
sides.ApplyAmbientOcclusionTerm(1.0f, 0.0f);

var deepOceanPolys = oceanPolys.RemoveEdges();
sides = Extrude(deepOceanPolys, -0.05f);
sides.ApplyColor(colorDeepOcean);
deepOceanPolys.ApplyColor(colorDeepOcean);
// Okay, we're done! Let's generate a ground mesh for this planet. if (m_GroundMesh != null)
Destroy(m_GroundMesh);
m_GroundMesh = GenerateMesh("Ground Mesh", m_GroundMaterial);
}

And the end result is something like this:

Making Progress…

And there you have it! If you haven’t already, you can find a working Unity project for this tutorial right here on GitHub. In the next tutorial I’ll delve more into spawning features on your planet (like rocks and trees) using cellular automata. Until then, happy coding!