Procedural Gas Giants
Procedural generation is a fun way to generate infinite combinations of just about everything you can think of. It works by generating random numbers in a predictable way and then associating those numbers with a set of rules you define.
Here I will go through how I generated gas giants for my procedural passion project as of June 2023. (it works on the web, check it out!)
Project Setup
For this tutorial, all you will need is any 3D engine of your choice, I will use BabylonJS but anything that can handle shaders and a sphere will do just fine.
BabylonJS works on the web through WebGL and WebGPU which makes it easy to deploy and it is open source!
You can setup the new project with node, typescript and webpack by using the template I used for this tutorial:
We won’t need all the template code, we will just need a sphere with a basic camera and light. In the file src/ts/index.ts, put the following code in place of the existing one:
import { Engine } from "@babylonjs/core/Engines/engine";
import { Scene } from "@babylonjs/core/scene";
import { ArcRotateCamera } from "@babylonjs/core/Cameras/arcRotateCamera";
import { Vector3 } from "@babylonjs/core/Maths/math.vector";
import { MeshBuilder } from "@babylonjs/core/Meshes/meshBuilder";
import { PointLight } from "@babylonjs/core/Lights/pointLight";
import "@babylonjs/core/Materials/standardMaterial";
import "@babylonjs/core/Loading/loadingScreen";
import "../styles/index.scss";
const canvas = document.getElementById("renderer") as HTMLCanvasElement;
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
const engine = new Engine(canvas);
const scene = new Scene(engine);
scene.clearColor.set(0, 0, 0, 1);
const camera = new ArcRotateCamera("camera", -1.5, 1.5, 2, new Vector3(0, 0, 0), scene);
camera.lowerRadiusLimit = 1.5;
camera.wheelDeltaPercentage = 0.01;
camera.attachControl();
const light = new PointLight("light", new Vector3(-5, 1, -5).scaleInPlace(100), scene);
const planet = MeshBuilder.CreateSphere("sphere", { segments: 64, diameter: 1 }, scene);
planet.position = new Vector3(0, 0, 0);
camera.setTarget(planet.position);
function updateScene() {
}
scene.executeWhenReady(() => {
engine.loadingScreen.hideLoadingUI();
scene.registerBeforeRender(() => updateScene());
engine.runRenderLoop(() => scene.render());
});
window.addEventListener("resize", () => {
engine.resize();
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
});
The RNG
First things first, we will need a pseudo number generator (i.e a deterministic sequence of random number tied to a seed). The seed will allow us to not lose the beautiful planets we are about to create.
Any RNG will do, but I will use Squirrel Noise:
npm i -D squirrel-noise
I find Squirrel Noise interesting because you can access any element of the sequence out of order, which can be useful in procedural generation in general (we won’t use this property here but I find this very cool).
You can declare your rng in the following way:
import { eededSquirrelNoise } from "squirrel-noise";
const seed = 42;
const rng = seededSquirrelNoise(seed);
Most RNG out of the box are of the uniform distribution and squirrel noise is no different, it means we have an equal chance to get any number between 0 and 1.
If we stick with the uniform generation, our results will be all over the place, it would be much better if we could set a higher probability for numbers around a certain value that we choose. By doing so we retain most of the variety but we assert a lot more control on it.
We will use the normal distribution to achieve this goal since it has this nice bell curve that we can offset in any way we want to fit our desires.
There is a way to transform 2 uniform random numbers in one normally distributed result. It is called the Box-Mueller transform. Here is a typescript function that achieves just that:
/**
* Returns a random number using the normal distribution of the given mean and std using the Box-Mueller transform
* @param mean The mean value of the distribution
* @param std The standard deviation to the mean value
* @param rand the rng (should return numbers between 0 and 1) defaults to Math.random
* @param step the step to use for the rng if using noise based rng (will also use step + 1 !!!)
* @see https://www.baeldung.com/cs/uniform-to-normal-distribution
*/
export function normalRandom(mean: number, std: number, rand = defaultRand, step?: number): number {
return mean + std * Math.sqrt(-2 * Math.log(rand(step))) * Math.cos(2 * Math.PI * rand(step ? step + 1 : 0));
}
Another function we will need is to get a random number inside a fixed range:
/**
* Returns a random number between min and max (excluded)
* @param min the minimum value
* @param max the maximum value
* @param rand the rng (should return numbers between 0 and 1) defaults to Math.random
* @param step the step to use for the rng if using noise based rng
*/
export function randRange(min = 0, max = 1, rand = defaultRand, step?: number): number {
return rand(step) * (max - min) + min;
}
Alternatively, you can use an npm package I made for this:
https://github.com/BarthPaleologue/extended-random/
npm i -D extended-random
Making a nice color palette
If we mess up the colors of our gas giants, they will be very ugly so we have to be careful. I use 3 different colors, two bright colors and one darker to compensate.
To help with the color selection we will use the HSV (hue, value, saturation) color space instead of RGB. The advantage of HSV is that it allows us to choose directly the hue we want.
For the first bright color, we will randomly pick a hue around the blue region (240 here). We will allow a high variability by setting a standard deviation of 30 from 240.
To get the second bright color, we can choose the complementary color of the first one, it will make an harmonious combination as long as we don’t crank up the saturation too much.
In HSV it is easy to get the complementary color: we just need to take the hue of our first color and add or subtract 180 (it is on the other side of the color disk).
On the other hand, the darker color can be of any hue, it will be very subtle as we will choose a brightness very low.
All in all we have something like this:
const hue1 = normalRandom(240, 30, rng, 70);
const hue2 = normalRandom(0, 180, rng, 72);
const mainColor1 = Color3.FromHSV(hue1 % 360, randRange(0.4, 0.9, rng, 72), randRange(0.7, 0.9, rng, 73));
const mainColor2 = Color3.FromHSV((hue1 + 180) % 360, randRange(0.4, 0.9, rng, 76), randRange(0.7, 0.9, rng, 77));
const darkColor = Color3.FromHSV(hue2 % 360, randRange(0.6, 0.9, rng, 74), randRange(0.0, 0.3, rng, 75));
It’s shader time!
Now that we have our color palette we need to render the cloudy surface of our gas giants.
We will first create a class to extend the ShaderMaterial of BabylonJS to fit our needs, we will call it GasPlanetMaterial.
import surfaceMaterialFragment from "../shaders/fragment.glsl";
import surfaceMaterialVertex from "../shaders/vertex.glsl";
import { normalRandom, randRange } from "extended-random";
import { ShaderMaterial } from "@babylonjs/core/Materials/shaderMaterial";
import { Effect } from "@babylonjs/core/Materials/effect";
import { Scene } from "@babylonjs/core/scene";
import { Color3 } from "@babylonjs/core/Maths/math.color";
import { Mesh } from "@babylonjs/core/Meshes/mesh";
import { seededSquirrelNoise } from "squirrel-noise";
import { PointLight } from "@babylonjs/core/Lights/pointLight";
import { Camera } from "@babylonjs/core/Cameras/camera";
const shaderName = "gazPlanetMaterial";
Effect.ShadersStore[`${shaderName}FragmentShader`] = surfaceMaterialFragment;
Effect.ShadersStore[`${shaderName}VertexShader`] = surfaceMaterialVertex;
export interface GazColorSettings {
mainColor1: Color3;
mainColor2: Color3;
darkColor: Color3;
}
export class GasPlanetMaterial extends ShaderMaterial {
readonly planet: Mesh;
readonly colorSettings: GazColorSettings;
constructor(planet: Mesh, seed: number, scene: Scene) {
super(`${planet.name}SurfaceColor`, scene, shaderName, {
attributes: ["position", "normal"],
uniforms: ["world", "worldViewProjection", "seed", "planetPosition", "starPosition", "mainColor1", "mainColor2", "darkColor", "cameraPosition"]
});
const rng = seededSquirrelNoise(seed);
this.planet = planet;
const hue1 = normalRandom(240, 30, rng, 70);
const hue2 = normalRandom(0, 180, rng, 72);
const mainColor1 = Color3.FromHSV(hue1 % 360, randRange(0.4, 0.9, rng, 72), randRange(0.7, 0.9, rng, 73));
const mainColor2 = Color3.FromHSV((hue1 + 180) % 360, randRange(0.4, 0.9, rng, 76), randRange(0.7, 0.9, rng, 77));
const darkColor = Color3.FromHSV(hue2 % 360, randRange(0.6, 0.9, rng, 74), randRange(0.0, 0.3, rng, 75));
this.colorSettings = {
mainColor1: mainColor1,
mainColor2: mainColor2,
darkColor: darkColor
};
this.setFloat("seed", seed);
this.setColor3("mainColor1", this.colorSettings.mainColor1);
this.setColor3("mainColor2", this.colorSettings.mainColor2);
this.setColor3("darkColor", this.colorSettings.darkColor);
}
public update(camera: Camera, light: PointLight) {
this.setVector3("cameraPosition", camera.globalPosition);
this.setVector3("starPosition", light.getAbsolutePosition());
this.setVector3("planetPosition", this.planet.getAbsolutePosition());
}
}
You can see the core of the code stays the same, we just pass them to the shader using setColor3 and setVector3.
The camera is able to move and so could be the light source and the planet so we pass them to the shader in an update method that will be called every frame.
Your IDE might be a little upset because we try to import shader files that do not exist yet, but fear not we will solve this very soon!
In your index.ts file you can instanciate this material by using:
const material = new GasPlanetMaterial(planet, 25, scene);
planet.material = material;
function updateScene() {
material.update(camera, light);
}
The part where it gets interesting
Now we have to code the shader which, I would argue, is the most interesting part!
Shaders are divided in two parts: one is the vertex shader for displacement (i.e morph the mesh into a different shape) and a fragment shader to color the pixels of the mesh. This is what interests us the most here, we want beautiful colors on a simple sphere.
I will go quickly on the vertex shader here:
precision highp float;
attribute vec3 position;
attribute vec3 normal;
uniform mat4 world;
uniform mat4 worldViewProjection;
varying vec3 vPositionW;
varying vec3 vNormalW;
varying vec3 vSphereNormalW;
varying vec3 vNormal;
varying vec3 vPosition;
varying vec3 vUnitSamplePoint;
void main() {
vec4 outPosition = worldViewProjection * vec4(position, 1.0);
gl_Position = outPosition;
vPositionW = vec3(world * vec4(position, 1.0));
vNormalW = vec3(world * vec4(normal, 0.0));
vPosition = position;
vUnitSamplePoint = normalize(vPosition);
vSphereNormalW = vec3(world * vec4(vUnitSamplePoint, 0.0));
}
The idea is to create varying variables that will be passed to the fragment shader so we can use them for making nice colors.
We will pass the position of every point on the sphere in world space as vPositionW and a normalized version of the position in local space called vUnitSamplePoint that we will use to sample our noise. But wtf is noise anyway?
Noise and why it is awesome
Before detailing the code for the fragment shader, I will talk about about noise in computer graphics.
If you generate random numbers, let it be a seedable rng or a truly random generator and plot the result on an image, you would get something like:
As you would expect, we get a random bunch of pixels.
This is not necessarily what we want. In nature, most phenomenons are continuous and so is the density of clouds in gas giants. Therefore we will need something random that changes in a continuous way: we call this kind of random noise.
To contrast with pure random data, here is perlin noise:
That’s more like it, this is what we will use to guide the generation of clouds in the fragment shader. You don’t need to understand everything about noise for this tutorial, or it would take an eternity for anyone to finish it. You can dowload a shader file containing a noise function that we will be able to use in our project:
https://github.com/BarthPaleologue/procedural-gas-giants/blob/master/src/shaders/noise.glsl
You might notice that we use 4D noise, it is 3D for the positions of points on the planet with the addition of one extra dimension to store the seed of the generation.
If you are more interested about the math behind noise, Inigo Quilez has a bunch of great articles on his website.
Noise is good, but it does not resemble gas giant clouds. The solution? More noise of course!
We will choose where to sample our noise based on more noise!
This technique is called domain warping, and is very uselful to get interseting shapes that look more like gas giants clouds.
Can we make the fragment shader nooow?
Yes! We have everything we need, it’s time to color this big ball of nothing.
To put it simple, for each point on the planet, we will sample a noise three times: one for the domain warping, and two other times to interpolate between our 3 colors.
So first we have our sample point with the seed:
float seedImpact = mod(seed, 1e3);
vec4 seededSamplePoint = vec4(vUnitSamplePoint * 2.0, seedImpact);
If we keep it like this, we will get a uniform distribution of noise on our planet, but gas giants are not uniform!
If you check a gas giant like jupiter online, you might see something like this:
One of the most outstanding features of gas giants are their horizontal cloud bands that stretch around the entire planet. With simple domain warping, we will not get those bands of colors.
To solve this issue, instead of sampling the noise at the position on the planet (+ the noise for domain warping), we sample the noise at the position on the planet, stretched vertically to compress the clouds horizontally. In our fragment shader, we will add something like this:
seededSamplePoint.y *= 2.5;
We can then generate the amount of warping by sampling the noise function at the sample point.
float warpingStrength = 2.0;
float warping = fractalSimplex4(seededSamplePoint, 5, 2.0, 2.0) * warpingStrength;
We will be able to use this to generate two more random values to separate the 3 colors:
float latitude = seededSamplePoint.y;
float colorDecision1 = fractalSimplex4(vec4(latitude + warping, seedImpact, -seedImpact, seedImpact), 3, 2.0, 2.0);
float colorDecision2 = fractalSimplex4(vec4(latitude - warping, seedImpact, -seedImpact, seedImpact), 3, 2.0, 2.0);
We use the lattitude to make the bands go around the planet while the warping gives the variation to break the linear boundary we would get otherwise.
Finally we can use linear interpolation to compute the colors.
If lerp is not defined, you can use this file to add the function to your code:
https://github.com/BarthPaleologue/procedural-gas-giants/blob/master/src/shaders/lerp.glsl
float seedImpact = mod(seed, 1e3);
vec4 seededSamplePoint = vec4(vUnitSamplePoint * 2.0, seedImpact);
seededSamplePoint.y *= 2.5;
float latitude = seededSamplePoint.y;
float warpingStrength = 2.0;
float warping = fractalSimplex4(seededSamplePoint, 5, 2.0, 2.0) * warpingStrength;
float colorDecision1 = fractalSimplex4(vec4(latitude + warping, seedImpact, -seedImpact, seedImpact), 3, 2.0, 2.0);
float colorDecision2 = fractalSimplex4(vec4(latitude - warping, seedImpact, -seedImpact, seedImpact), 3, 2.0, 2.0);
color = lerp(mainColor1, darkColor, smoothstep(0.4, 0.6, colorDecision1));
color = lerp(color, mainColor2, smoothstep(0.2, 0.8, colorDecision2));
The use of smoothstep here is to make the color bands more distinct, without it they would blend together too much and the results would be quite bad.
We just need to add the boilerplate around the bulk of our shader code to make it work. We check the position of the light relative to the planet and use the normal to the surface to calculate shading. If you are more intersted in the details, check out the phong model:
https://en.wikipedia.org/wiki/Phong_reflection_model
We end up with a fragment shader that looks like this:
precision highp float;
varying vec3 vPositionW;
varying vec3 vNormalW;
varying vec3 vUnitSamplePoint;
varying vec3 vSphereNormalW;
varying vec3 vPosition; // position of the vertex in sphere space
uniform vec3 cameraPosition;
uniform vec3 starPosition;
uniform vec3 mainColor1;
uniform vec3 mainColor2;
uniform vec3 darkColor;
uniform float seed;
#pragma glslify: fractalSimplex4 = require(./noise.glsl)
#pragma glslify: lerp = require(./lerp.glsl)
void main() {
vec3 viewRayW = normalize(cameraPosition - vPositionW); // view direction in world space
vec3 sphereNormalW = vSphereNormalW;
vec3 normalW = vNormalW;
vec3 starLightRayW = normalize(starPosition - vPositionW); // light ray direction in world space
float ndl = max(0.0, dot(sphereNormalW, starLightRayW));
vec3 angleW = normalize(viewRayW + starLightRayW);
float specComp = max(0.0, dot(normalW, angleW));
specComp = pow(specComp, 16.0) * 0.5;
vec3 color = vec3(0.0);
float seedImpact = mod(seed, 1e3);
vec4 seededSamplePoint = vec4(vUnitSamplePoint * 2.0, seedImpact);
seededSamplePoint.y *= 2.5;
float latitude = seededSamplePoint.y;
float warpingStrength = 2.0;
float warping = fractalSimplex4(seededSamplePoint, 5, 2.0, 2.0) * warpingStrength;
float colorDecision1 = fractalSimplex4(vec4(latitude + warping, seedImpact, -seedImpact, seedImpact), 3, 2.0, 2.0);
float colorDecision2 = fractalSimplex4(vec4(latitude - warping, seedImpact, -seedImpact, seedImpact), 3, 2.0, 2.0);
color = lerp(mainColor1, darkColor, smoothstep(0.4, 0.6, colorDecision1));
color = lerp(color, mainColor2, smoothstep(0.2, 0.8, colorDecision2));
vec3 screenColor = color.rgb * (ndl + specComp * ndl);
gl_FragColor = vec4(screenColor, 1.0);
}
Now we can run our code with the simple command:
npm run serve
If everything goes well, you should see a gas giant with interesting colors like this one:
If you encounter any issue you can always compare your code to the repository of this tutorial:
https://github.com/BarthPaleologue/procedural-gas-giants/
It could be better?
This is a good start, but we are missing the atmosphere that will make it a lot more believable.
If you are using BabylonJS, you can use the plugin I made to easily add atmospheres in your scene:
You will only need to download 2 files and add 2 lines of code to get this:
This is much better! You can play around with the settings or even randomize them to get what you want.
All the code until here is available at:
https://github.com/BarthPaleologue/procedural-gas-giants/
Optimizations
The shader code is not very fast by any means because we are computing the noise in real time. One thing you can do to accelerate the rendering is to store the noise as a texture and then sample the texture in the shader. You will need to generate a different texture for each seed though.
About the rings
I wanted to keep this as simple as possible and I realize it is quite long already. I might make a follow up to this to explain the rings, in any case the link will be posted here.