#Little Planet Procedural

This is a handbook meant to explain the concepts and execution behind the "Little Planet Procedural" project, my directed study midterm project.

The github repo is /alan-luo/planetprocedural/.

As some background, the project is meant as a case study of how code can create diverse and infinite art through basic techniques of procedural generation. It is inspired by the likes of No Man's Sky.

Each time the page is refreshed, a new planet is generated. There are night scenes and day scenes.

sample image

sample image2

The project is written entirely with raw HTMl5 Canvas.

Concepts

Noise

There is, for the most part, only one concept used throughout the entire project. To see this, let's first consider the following problem. Say we want to generate some hills. How should we go about doing so? We want something that looks kind of like this:

some hills

We want there to be some randomness, but we also want an organic nature to the terrain. In other words, we want local similarity, but global randomness. Generating random points is no good, since it gives us too much local variation.

random points

We could mediate this by stretching it all the way out. We call this interpolation.

lerping

Technically, with a more complex interpolation that smoothes out the ridges, this method could achieve feasible terrain. In fact, there are an infinite number of ways to generate realistic terrain. However, we will only look at one, called Perlin Noise. The details of Perlin Noise are complex and will not be explored. However, the result is seen in the first image, which is generated using Perlin Noise.

More Dimensions

It turns out we can extend this reasoning to higher dimensions. What if we want to generate three-dimensional terrain? We'd want to achieve the same local similarity and global variance on a two-dimensional input. Rather than representing this as a three-dimensional terrain, we can treat map the third dimension onto the continuum from black to white. This gives us cloud-like patterns.

2D perlin noise

This might look familiar. It's used in a lot of movies and games to create special effects. In fact, Ken Perlin, the creator of Perlin Noise, won an academy award for his work!

We refer to the "dimension" of Perlin noise as the dimension of the input. So, 2D terrain is 1D noise, generated by a dimension of input and a dimension of output. 3D terrain is 2D noise.

The cool thing is that the third dimension does not have to be physical. We could lift a plane of 2D noise through 3D space to give the impression that the terrain is changing, but we are really just using 3D noise with time as a variable.

With this intuition, we can formally define noise.

To manipulate noise, we treat it like we treat sine funcitons. A noise function f of a vector x can take the form af(bx)+c. This will make the amplitude vary from 0 to a, stretch by a factor of b, and offset by c.

modified noise

In this case, the image on the right is the same noise on the left, but stretched out.

Now that these basic concepts are down, let's get rolling!

Mountains and Hills

hills and mountains

The hills and mountains are both generated by one-dimensional Perlin Noise. There are two things to notice. Firstly, the mountains have much more chaotic noise than the hills. Secondly, both have "layers" of color.

Octaves

The variation is generated through what we call octaves. Like any function, Perlin Noise follows the concept of constructive interference. Suppose we have a sine wave. See how smooth it is?

a sine wave

Now suppose we want to introduce some variation to it. One thing we could do is superimpose a smaller sine wave on top - one that has both a smaller wavelength and amplitude.

a messy sine wave

We can do this again and again to get more and more "noisy" (haha) functions. (If you add them infinitely you get something somewhat bizarre - but that's a different story.)

a messier sine wave

We call these "octaves" because the first harmonic of a signal, or twice the fundamental frequency, or the wave sin(2x) corresponding to the wave sin(x), corresponds to the musical sound one octave up. For example, the note A4 on a piano is 440Hz, while A5 is 880Hz, A3 is 220Hz, and so on.

We can similarly create octaves for Perlin Noise. Implementation-wise, I write one function for drawing and filling in noise:

function make1DNoise(axis, amplitude, scale, params) {
    var newNoise = [];

    for(var i=0; i<CANVAS_WIDTH; i++) {
        newNoise.push({x: i, y:axis+amplitude*
        params.noiseFunction(scale*i, params.zaxis)});  
    }

    newNoise.push(LOWER_LEFT); newNoise.push(LOWER_RIGHT);
    ctx.fillStyle = params.fillColor;
    fillPath(newNoise);
}

(fillPath is not a default canvas function - it's a function I wrote to make and fill a path from an array of points)

There are a few things to notice here. First of all, I pass in axis, amplitude, and scale as arguments, corresponding very similarly to the constants that manipulate a sine function. Secondly, I pass in a params variable that holds a number of useful things. For instance, I can step through the other dimension of the noise even if I am only using 1D noise, a feature that will become useful soon. More importantly, I reference this thing called noiseFunction. What's that about?

It turns out that in scripting language, included, everything is pretty much the same. Objects are functions and vice versa. So, I can pass in a function as an argument of a function, and save myself some time. I essentially wrote two noise functions: one for mountains, and one for hills.

function mountainNoise(x, z) {
    return simplex.noise2D(z, x)+0.5*simplex.noise2D(0, 2*x
    +0.25*simplex.noise2D(0, 4*x)+0.125*simplex.noise2D(0, 8*x);
}
function hillNoise(x, z) {
    return simplex.noise2D(z, x);
}

(Simplex Noise is just a variant of Perlin Noise)

I pass these functions in as parameters of my previous function, and thus can use the same function for drawing mountains and hills. Notice that the mountains have four octaves and the hills one, corresponding to their very different noise shapes.

An Observation

You might also notice that there are bands of color on the hills and on the mountains.

color bands

To see how these work, let's first make the following observation. Let's say we have a plane intersecting a field of 2D noise. In otherworse, let's take a slice out of the 2D noise.

color bands

If we do this, we're essentially fixing one variable of the input. In other words, we only have one remaining variable. So we've essentially reduced the dimension to 1. If we then project this sliced noise, we will see that it is 1D.

color bands

Since 2D noise follows our rule of local similarity, by taking a series of adjacent slices out of 2D noise, we can thus construct a series of similar but not identical noise functions. So, the bands of color on the mountains and hills are generated by setting slightly different fixed y-values as inputs for a 2D noise function.

for(var i=0; i<3; i++) {
    make1DNoise(430, 200, 0.005, {noiseFunction:mountainNoise,
    fillColor:toHslString(mountainshade), zaxis:0.07*i}); 
    mountainshade.s-=0.05; mountainshade.l-=0.04;
}

The z-axis parameter of the draw function changes how much the 1D noise is stepped through, or the position of the slicing plane. In this case, we move the plane by 0.07 each step. The last line just slighly varies the color of each new layer drawn - more on this later.

Rivers

rivers

The rivers are pretty much done the same way as the mountains. They are generated starting from the minimum point of the noise. Two sloped lines are drawn from this point. One-octave Perlin Noise extrudes on both sides. This is repeated four times to create four bands of color.

Sky

rivers

With the sky, we need to consider color schemes. Recall that we have two types of scenes: night and day.

Color Theory

First, we need to introduce some basic ideas of color theory. We can arrange the primary colors around a circle equally spaced from each other, then add all the in between colors. We call this the color wheel.

color wheel

One paradigm for choosing aesthetically pleasing color schemes is to choose colors that are as evenly spaced around the wheel as possible in order to maintain a constant relative coloring. For instnace, green, orange, and blue would be a bad choice because blue is farther from orange than it is from green.

We can obtain a number of color schemes by choosing colors this way. Analagous schemes choose one color and other similar colors.

analagous

Complementary schemes choose one color and other similar colors.

complementary

Triad schemes choose three evenly spaced colors.

triad

There are many other schemes, but we won't delve into them here. In our case, we use analagous schemes for night scenes, and complementary schemes in day scenes.

Color in Detail

Most colors in the program are processed as hsb values rather than as rgb. Most computer colors are in the form of three bytes: one value each for red, green, and blue, coorresponding to the range from 0 to 255.

However, I process colors as a hue, a saturation, and a brightness. This is a much easier way to describe what a color "looks like" instead of tinkering with rgb values. Hue describes the angle position on the color wheel, with 0 = 360 degrees referring to red. Saturation describes how much color there is, with 0 being grayscale, and 1 being entirely colored. Brightness describes how much white there is, with 0 being totally black, and 1 being totally white.

I wrote a function that converts an hsb (sometimes known as hsl) color input to a usable string for canvas.

function toHslString(color) {
    return "hsl("+(color.h%1.0)*360+", 
    "+(color.s%1.0)*100+"%, "+(color.l%1.0)*100+"%)";
}

Notice that taking the hue mod 1.0 makes the wheel into a circle.

The details of the colors are mostly tinkered with through experimentation. There are two different schemes - one for day, one for night. I've left the full code here.

if(data.time=="night") {
    baseColor = randomColor({brightness:"dark"});
    mountainColor = {h:baseColor.h+Math.random()*0.2-0.1, 
                         s:baseColor.s+Math.random()*0.2-0.1,
                         l:baseColor.l+Math.random()*0.1+0.15};
    hillColor = {h:mountainColor.h+Math.random()*0.2-0.1, 
                     s:mountainColor.s+Math.random()*0.2-0.1,
                     l:mountainColor.l+Math.random()*0.1+0.15}; 
    riverColor = {h:baseColor.h,
                    s:baseColor.s-Math.random()*0.1-0.05,
                    l:baseColor.l+Math.random()*0.1-0.05};
    skyColor2 = {
        h:baseColor.h,
        s:baseColor.s,
        l:baseColor.l-0.2
    };

    cloudColor={h:baseColor.h, s:0.4, l:0.2};
    treeColor={h:baseColor.h+0.5, s:0.4, l:0.2};
    leafColor={h:treeColor.h+0.5, s:0.8, l:0.6};
    planetColor={h:hillColor.h, s:0.4, l:0.4};

} else if(data.time=="day") {
    baseColor = randomColor({brightness:"medium"});
    mountainColor = {h:baseColor.h+0.4+Math.random()*0.1, 
                         s:0.2+Math.random()*0.2,
                         l:baseColor.l+Math.random()*0.1-0.05};
    hillColor = {h:mountainColor.h+Math.random()*0.2-0.1, 
                     s:0.4+Math.random()*0.2,
                     l:baseColor.l+Math.random()*0.1};
    riverColor = {h:baseColor.h,
                    s:baseColor.s-Math.random()*0.1-0.05,
                    l:baseColor.l+Math.random()*0.1+0.2
                    };

    skyColor2 = {
        h:baseColor.h,
        s:baseColor.s,
        l:baseColor.l-0.2
    };

    cloudColor={h:baseColor.h, s:0.3, l:0.9};
    treeColor={h:baseColor.h-0.25, s:0.6, l:0.4};
    leafColor={h:treeColor.h+0.5, s:0.8, l:0.6};
    planetColor={h:treeColor.h+0.5, s:0.8, l:0.6};
}

Triangulation

You'll notice the sky is partitioned into triangular cells. This is done with an algorithm called Delaunay Triangulation. A Delaunay triangulation, informally, is a triangulation of a set of points that makes every triangle as close to equilateral as possible. It's a way of triangulating points to make them look "nice and even."

triangulation

(I took this image from Wikipedia.)

For the sky, I generate a gradient. Then, I generate a bunch of random points, and also add some points on the edges. I throw all of this in to a Delaunay triangulation algorithm to get a set of triangles. For each triangle, I fill the entire triangle with the color at the pixel at the center of the triangle.

var grd=ctx.createLinearGradient(0,CANVAS_HEIGHT,0,0);
grd.addColorStop(0,colors.skyColor);
grd.addColorStop(1,colors.skyColor2);
ctx.fillStyle=grd;
ctx.fill();

var trianglePoints = [];
trianglePoints.push(
    [0, CANVAS_HEIGHT], [0, 0], 
    [CANVAS_WIDTH, CANVAS_HEIGHT], [CANVAS_WIDTH, 0]);

for(var i=0; i<15; i++) { //add some stuff on top and bototm
    trianglePoints.push([Math.random()*CANVAS_WIDTH, 0]);
    trianglePoints.push([Math.random()*CANVAS_WIDTH, CANVAS_HEIGHT]);
}
for(var i=0; i<10; i++) { //add some stuff on the sides
    trianglePoints.push([0, Math.random()*CANVAS_HEIGHT]);
    trianglePoints.push([CANVAS_WIDTH, Math.random()*CANVAS_HEIGHT]);
}
for(var i=0; i<50; i++) { //add some stuff in the middle
    trianglePoints.push([Math.random()*CANVAS_WIDTH,
     Math.random()*CANVAS_HEIGHT]);
}

for( /* do this for each triangle */) {
    var center;
    center.x = (newtriangle[0].x+
                newtriangle[1].x+
                newtriangle[2].x)/3;
    center.y = (newtriangle[0].y+
                newtriangle[1].y+
                newtriangle[2].y)/3;
    var centercolor = ctx.getImageData(center.x, center.y, 1, 1).data;

    var fillcolor = "rgb("+centercolor[0]+", 
                    "+centercolor[1]+", "+centercolor[2]+")";
    ctx.fillStyle = fillcolor;

    fillPath(newtriangle);
}

Clouds

The clouds are also generated using Perlin Noise. We essentially use a threshold function to chop off the noise past a certain point. To see this, imagine we intersect the field of noise with a plane, like before, but this time on the outputs.

clouds1

If we take this and view it from the top, we see our cloud patterns.

clouds2

You'll also notice that the noise in this image seems to be much cleaner than the real clouds. That's because we also introduce an element of variation on the threshold. Also, in order to simulate actual clouds, we stretch out the noise horizontally by 10x. Also, we draw the clouds at 40% opacity.

function makeClouds(threshold, offset, variance) {
    ctx.globalAlpha = 0.4;
    ctx.beginPath();
    for(var i=0; i<CANVAS_WIDTH; i++) {
        for(var j=0; j<CANVAS_HEIGHT; j++) {
            var noiseValue = simplex.noise2D(i*0.001+offset, 
                                             j*0.01+offset);
            if(noiseValue>params.threshold
                         +Math.random()*params.variance) {
                drawPixel({x:i, y:j}, colors.cloudColor);
            }
        }
    }
    ctx.globalAlpha = 1.0;
}

Celestial Objects

There are a number of celestial objects in the scenes. At day, a sun is generated. At night, a planet and stars are generated.

Stars

We generate a bunch of stars at random points in the sky. Most stars are just little cirlces, but each star has a random chance of being a "big star." The big stars are meant to simulate twinkling, and are modelled as an intersection of two thin rectangles, with a gradient fill.

//5% chance to make a big star
if(Math.random()<0.05){ 
    ctx.beginPath();

    var starwidth = Math.random()*7+3;
    ctx.rect(starx-1, stary-starwidth, 2, 2*starwidth);
    ctx.rect(starx-starwidth, stary-1, 2*starwidth, 2);

    var grd=ctx.createRadialGradient(starx, stary, 3, 
                                     starx, stary, starwidth+5);
    grd.addColorStop(0,"white");
    grd.addColorStop(1,"rgba(1, 1, 1, 0.0)");
    ctx.fillStyle=grd;

    ctx.fill();
}

Planet

The planet is pretty much hard-coded in. In future work I may add some variance. For now, I just generate a circle and a bunch of ellipses at an angle to simulate light. I use a method very similar to that I used for the clouds to introduce some texture, as well.

planet

function makePlanet(position, radius, params) {
    ctx.beginPath();
    ctx.arc(position.x, position.y, radius, 0, 2*Math.PI);
    ctx.fillStyle = colors.planetColor;
    ctx.fill();
    ctx.save();
    ctx.clip();

    //in a square around the planet
    var xposmax = position.x+radius;
    var yposmax = position.y+radius;
    ctx.globalCompositeOperation = 'overlay';
    for(var xpos=position.x-radius; xpos<xposmax; xpos++) {
        for(var ypos=position.y-radius; ypos<yposmax; ypos++) {
            if(simplex.noise2D(xpos, ypos)>0.1+Math.random()*0.2) {
                drawPixel({x:xpos, y:ypos}, 'rgba(0, 0, 0, 0.05)');
            }
            if(simplex.noise2D(xpos*0.03, ypos*0.03)>
                                    0.1+Math.random()*0.2) {
                drawPixel({x:xpos, y:ypos}, 'rgba(0, 0, 0, 0.05)');
            }
        }
    }
    ctx.fillStyle="rgba(255, 255, 255, 0.1)";
    ctx.beginPath();
    ctx.ellipse(position.x+60, position.y-60, 60, 
            40, 45 * Math.PI/180, 0, 2 * Math.PI);
    ctx.fill(); ctx.beginPath();
    ctx.ellipse(position.x+40, position.y-40, 80, 
            60, 45 * Math.PI/180, 0, 2 * Math.PI);
    ctx.fill(); ctx.beginPath();
    ctx.ellipse(position.x+20, position.y-20, 100, 
            80, 45 * Math.PI/180, 0, 2 * Math.PI);
    ctx.fill();
    ctx.globalCompositeOperation = 'source-over';

    ctx.restore();
}

Sun

The sun is just a circle with another circle around it.

function makeSun(position, params) {
    ctx.beginPath();
    ctx.arc(position.x, position.y, params.innerradius, 0, 2*Math.PI);
    ctx.globalAlpha = 0.95;
    ctx.fillStyle=toHslString({h:baseColor.h, s:0.3, l:0.8});
    ctx.fill();

    ctx.arc(position.x, position.y, params.outerradius, 0, 2*Math.PI);
    ctx.globalAlpha = 0.5;
    ctx.fillStyle=toHslString({h:baseColor.h, s:0.3, l:0.9});
    ctx.fill();

    ctx.globalAlpha = 1.0;
}

Trees

The trees have two components. The program has two canvas components. The first shows the scene. Whenever a tree is clicked on, the second shows a fractal tree.

Little Trees

We should first consider the goal in making trees. We model the trunk as a rectangle, and the leaves as a blob. So, what exactly is a blob? How can we create one? Essentially, we'd like the blob to look random if you see it from a distance, but still maintain its local circular structure. Sound familiar?

Turns out, all we do is take a loop of Perlin noise. In the past, we've been inputting lines to the noise function. However, nothing stops us from putting in a circle.

fractal tree

Essentially, we make a circle out of a bunch of vertices. Then, for every vertex on the circle, we input its coordinate into a noise function and extrude it by that much.

var pointcount = 30, radius = 10, blobpoints;
for(var j=0; j<pointcount; j++) {//map points onto a circle
    var prepos = {
        x:leafcenter.x+radius*Math.cos(j*(2*Math.PI)/pointcount),
        y:leafcenter.y+radius*Math.sin(j*(2*Math.PI)/pointcount)};
    newradius = radius + 5*simplex.noise2D(prepos.x, prepos.y);

    var newpos = {
        x:leafcenter.x+newradius*Math.cos(j*(2*Math.PI)/pointcount),
        y:leafcenter.y+newradius*Math.sin(j*(2*Math.PI)/pointcount) };
    blobpoints.push(newpos);
}
fillPath(blobpoints);

Big Trees

fractal tree

Clicking on the trees is supposed to simulate "zooming in" on the tree. So, clicking on the same tree twice gives the same "zoomed" tree. This is done through an L-system. We're not going to go into L-systems in depth. However, we can describe one as a type of recursive drawing function. In this case, we write a function where a line segment is drawn (the "trunk" of the tree), followed by two additional segments that sprout off towards either side. However, each call of this function sets off two new calls of the function, resulting in a constant branching.

In order to achieve variation while making the same tree translate to the same L-system, we introduce an angle variation that is seeded by the position of the tree.

function makeBigTree(newseed) {
    resetCanvas();
    setRandomSeet(newseed);
    branch(50);
}
function branch(len){
  var theta = random()*(Math.PI/3);

  drawLine({x:0, y:0}, {x:0, y:len}, ctx2);
  ctx2.translate(0, len);

  len *= 0.66;
  if (len > 2) {
    ctx2.save();
    ctx2.rotate(theta);
    branch(len);
    ctx2.restore();

    ctx2.save();
    ctx2.rotate(-theta);
    branch(len);
    ctx2.restore();
  }
}

canvas.addEventListener('mousedown', doMouseDown, false);
function doMouseDown(evt) {
    var mousePos = getMousePos(canvas, evt);
    for(var i=0; i<clickboxes.length; i++) {
        if(mousePos.x>clickboxes[i].left 
        && mousePos.x<clickboxes[i].right 
       && mousePos.y>clickboxes[i].top  
       && mousePos.y<clickboxes[i].bottom) {
            clickboxes[i].action(i);
        }
    }
}

We use the same property of functions as objects as before here. clickboxes is an array that stores rectangular bounding boxes of each tree. Each bounding box has a corresponding action, which is a function property. The action is created by the L-system drawing function seeded by the clickbox index.

Conclusion and Future Work

Overall, this has been a fun project. It's showcased the ability for a relatively small set of techniques to generate some really cool looking stuff. There are still many, many techniques that I learned about that did not make it into the final project, including fractals and cellular automata.

If I do additional work on this, I'd like to: