Making a game with Go and Pixel: #2 Procedural Content Generation (PCG)
This issue is about procedurally generating planet textures solely by using simplex noise. PCG is commonly used to create all kinds of content for games. This can increase replayability because of less repetition and also reduce the need for manual asset creation. Obviously it's not a general replacement for that!
2 Noise
git checkout chapter2
What is noise and why do we need it? There are different types of noise: just random noise or sophisticated noise such as simplex noise. Let's have a look at random noise.
As you can see it doesn't look very appealing. It certainly does not remind me of a planet texture. The problem is that it has no visible pattern(s), it is just random (well pseudo random but let's ignore this right now). Compare that to the output of the simplex noise function, where the inputs are just the x and y coordinates of the image.
Here it feels more like a pattern. The visual effect is less chaotic but it still does not resemble a planet surface structure. Simplex noise however allows us to change the frequency of the resulting values. We can do that by zooming in or out. Let's multiply the x and y coordinates by 0.1 each before passing it to simplex.
Zooming in has produced a less hectic pattern as before and if we try this again with a multiplier of 0.01 it will be even calmer.
Now what? We have multiple noise patterns but none of them really look like anything. The good part is we can combine multiple layers of noise to one. This is similar to mixing sound signals. In fact our noises are just signals too. We will use the following function to create layered noise:
func layerNoise(layers int, x, y, persistence, freq, low, high float64) (result float64) {
ampSum := 0.0
amp := 1.0
for i := 0; i < layers; i++ {
result += noise.Eval2(x*freq, y*freq) * amp
ampSum += amp
amp *= persistence
freq *= 2
}
result /= ampSum
result = result*(high-low)/2 + (high+low)/2
return
}
First of all let's credit the original author. I adapted this function from here. If you want to know more about simplex noise the linked article is worth a read. For the actual generation of noise we use the github.com/ojrac/opensimplex-go
package.
As you can see the function generates one result value for a specific x and y input. The result is transformed into the given range between low
and high
. The layer
parameter specifies how many layers of noise are used to sample this value. Each layer has different settings for frequency and amplitude of the noise. A lower frequency means a calmer pattern or less abrupt changes over the x and y dimension. Reducing the amplitude for higher frequencies achieves a lesser weight of the more hectic layers in the result. Dividing the resulting value by the total of all amplitudes averages the layers' values.
Trying this function with layers = 8
, persistence = 0.5
and freq = 0.02
yields the following noise.
NOTE: Procedural generation is a lot about trying things and tweaking parameters. This function could be good to create a water or stone texture with totally different parameters.
Now this looks similar to the moon surface. We can really use this to create some planets. Let's go ahead and inspect our planet generator function.
func genPlanet(radius float64) (canvas *pixelgl.Canvas) {
noise = opensimplex.NewWithSeed(time.Now().UnixNano()) // (1)
size := int(radius*2 + 1)
canvas = genGradientDisc(radius, 0.98, colornames.White) // (2)
pixels := canvas.Pixels() // (3)
freq := radius / (1000 * (radius / 40) * (radius / 40)) // (4)
for y := 0; y < size; y++ {
//nn := layerNoise(16, 0, float64(y), 0.5, freq, 0.25, 1) // (X)
for x := 0; x < size; x++ {
index := y*size*4 + x*4 // (5)
r, g, b, a := float64(pixels[index]), float64(pixels[index+1]), // (6)
float64(pixels[index+2]), float64(pixels[index+3])
if a > 0 {
nnn := layerNoise(16, float64(x), float64(y), 0.5, freq, 0, 1) // (7)
// n := (nnn + nn) / 2 // (Y)
n := nnn // (Z)
pixels[index] = brighten(uint8(r*n), 1.5) // (8)
pixels[index+1] = brighten(uint8(g*n), 1.5)
pixels[index+2] = brighten(uint8(b*n), 1.5)
pixels[index+3] = 255 // Make the planet opaque
}
}
}
canvas.SetPixels(pixels) // (9)
return
}
Line (1) shows how you create a new simplex noise generator with a seed. Note that the simplex noise function is deterministic. The same inputs generate the same outputs if the seed is equal. Because we want differently looking planets we have to initialize the seed every time a planet is generated.
In line (2) we initialize canvas
with a white disc. The genGradientDisc()
function generates a filled circle with a gradient color. If you create a disc with density = 1
it will just be a completely opaque circle. We specify density = 0.98
so the edge of the circle is a little softened. The canvas holds now a pristine planet that we can deform :)
Because we have to inspect and change most pixels of the canvas one by one we extract them in line (3) for editing and later in line (9) put them back onto the canvas at once. This is the most performant way to do it albeit a little cumbersome. We have to access the red, green, blue and alpha values each on its own (see line (8) and the following).
In line (4) we calculate a frequency for the following noise generation that yields nice results independent of the planet size. I really cannot explain much about this function other than that I tuned it by trial and error until I liked the result.
We will skip lines (X), (Y) and (Z) for now and come back to it later. In Line (5) we calculate the index for the r
value in the pixels slice. They layout follows this structure: [r,g,b,a, r,g,b,a, ..]
. As you can see it is a very raw structure. The colors are not encapsulated in types but their color values are stored directly. In line (6) all color values for the pixel at x, y
are retrieved.
Then in line (7) the noise value is generated for the current pixel via the layerNoise()
function. In line (8) and following the red, green, blue and alpha values are set for the pixel. Also each of the r, g, b components is brightened by a factor of 1.5. I did this because the final planets are very small and need to be bright enough to be clearly visible. The next image shows a sample output of the described function.
While I really like the result when looking at a large planet it doesn't look so great on small planets. I think the contrast is too extreme for small sizes. Now I could have dampened the contrast but I discovered another option. I added another layer of noise to the planet which generated horizontal stripes. This is shown in line (X) in the above function. You can see that the x parameter is always 0. This way we generate a different value for different y values only. By uncommenting line (X) and (Y) and deleting line (Z) the final function is reached. In the resulting image below you can see a light stripe pattern merged with the previous moon pattern.
I quite like the result. Keep in mind that every generated planet will look differently because of the seed randomization. It is also worth noting that too much detail will rather hurt the result because our planets are so small on the screen.
NOTE: Don't overdo things. If something is very small, you don't need a ton of details. If you see something only for a brief moment, you don't need a ton of details. Don't put too much energy into one little detail before tackling all the other challenges of your project.
Afterthought
Chapter 2 has a better scope than chapter 1. This is the amount of content I will be aiming at in the future. Talking about the future.. the next tutorial will be about shaders with Pixel. However it may take a while until I finish it. I needed much longer for this tutorial than planned. My time currently is very scarce so please forgive me if it takes longer than expected! As always feedback is greatly appreciated.