Fun With Spherical Simplex Noise

· ☕ 4 min read

There’s plenty of tutorials out there on using Simplex or Perlin noise to make terrain. It’s even included as an example in my library, bracket-noise (a port of Auburn’s excellent FastNoise to Rust).

If you’re in the business of making planets, it can be frustrating that most noise setups don’t wrap around at the edges: you get great terrain, but there’s a seam along the east/west axis if you wrap it around a sphere.

Get Started With a Bracket-Lib Skeleton

Start by making a new Rust project with cargo init <project name>, in whatever directory you like to store your source. Then edit Cargo.toml to include a dependency on bracket-lib:

1
2
[dependencies]
bracket-lib = "0.8.0"

With that in place, you want a pretty minimal main.rs file. You’re going to refer to a worldmap.rs we’ll be building in a second. This is just enough to render the map once, and keep it on screen in a window until you close it:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
use bracket_lib::prelude::*;
mod worldmap;
use worldmap::*;

struct State {
    world : WorldMap,
    run : bool
}

impl GameState for State {
    fn tick(&mut self, ctx: &mut BTerm) {
        if !self.run {
            self.world.render(ctx);
            self.run = true;
        }
    }
}

const WIDTH : i32 = 160;
const HEIGHT : i32 = 100;
const WIDTH_F : f32 = WIDTH as f32;
const HEIGHT_F : f32 = HEIGHT as f32;

fn main() -> BError {
    let context = BTermBuilder::simple(160, 100)
        .unwrap()
        .with_title("Hello Minimal Bracket World")
        .build()?;

    let gs: State = State {
        world : WorldMap::new(WIDTH_F, HEIGHT_F),
        run : false
    };

    main_loop(context, gs)
}

Setting up the noise

You’ll be using bracket-noise, part of the bracket-lib family to generate the noise. Make a new file in your project’s src directory, called worldmap.rs. Start by including the bracket-lib prelude, and a structure in which to store your calculations:

1
2
3
4
5
6
7
use bracket_lib::prelude::*;

pub struct WorldMap {
    noise : FastNoise,
    width: f32,
    height: f32
}

You also want a constructor. This is where you set the noise parameters; I played around until I liked them, I encourage you to do the same:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
impl WorldMap {
    pub fn new(width: f32, height: f32) -> Self {
        let mut rng = RandomNumberGenerator::new();
        let mut noise = FastNoise::seeded(rng.next_u64());
        noise.set_noise_type(NoiseType::SimplexFractal);
        noise.set_fractal_type(FractalType::FBM);
        noise.set_fractal_octaves(10);
        noise.set_fractal_gain(0.5);
        noise.set_fractal_lacunarity(3.0);
        noise.set_frequency(0.01);

        Self{
            noise,
            width,
            height
        }
    }
    ...
}

The real magic comes from sampling Simplex noise as a sphere in three dimensions. Projecting an altitude, and latitude/longitude pair to a set of 3D coordinates is quite well established map. Add sphere_vertex to your implementation:

1
2
3
4
5
6
7
fn sphere_vertex(&self, altitude: f32, lat: f32, lon: f32) -> (f32, f32, f32) {
    (
        altitude * f32::cos(lat) * f32::cos(lon),
        altitude * f32::cos(lat) * f32::sin(lon),
        altitude * f32::sin(lat)
    )
}

Next, make a function called tile_display. It starts by converting x and y coordinates to latitude/longitudes in radians:

1
2
3
4
fn tile_display(&self, x: i32, y:i32) -> (FontCharType, RGB) {
    let lat = (((y as f32 / self.height) * 180.0) - 90.0) * 0.017_453_3;
    let lon = (((x as f32 / self.width) * 360.0) - 180.0) * 0.017_453_3;
    let coords = self.sphere_vertex(100.0, lat, lon);

This is a linear projection, and will distort a bit - it simply scales the current render location to -90…90 on the latitude (y) side, and -180..180 on the longitude (x) side. It then uses the resultant coordinates to calculate a sphere location with a constant altitude. Use lower altitudes for “zooming in”, and higher altitudes to “zoom out”.

Then we calculate the altitude, by sampling the Simplex noise at a given x/y/z 3D coordinate:

1
let altitude = self.noise.get_noise3d(coords.0, coords.1, coords.2);

Next, use the resulant altitude to calculate a display tile. Altitudes less than zero are water, altitudes up to 0.5 are grassland and above that a mountain is rendered:

1
2
3
4
5
6
7
8
9
if altitude < 0.0 {
    ( to_cp437('▒'), RGB::from_f32(0.0, 0.0, 1.0 + altitude) )
} else if altitude < 0.5 {
    let greenness = 0.5 + (altitude / 1.0);
    ( to_cp437('█'), RGB::from_f32(0.0, greenness, 0.0) )
} else {
    let greenness = 0.2 + (altitude / 1.0);
    ( to_cp437('▲'), RGB::from_f32(greenness, greenness, greenness) )
}

That just leaves a render function, which simply needs to render one character per tile for the whole window:

1
2
3
4
5
6
7
8
pub fn render(&self, ctx: &mut BTerm) {
    for y in 0..self.height as i32 {
        for x in 0..self.width as i32 {
            let render = self.tile_display(x, y);
            ctx.set(x, y, render.1, RGB::from_f32(0.0, 0.0, 0.0), render.0);
        }
    }
}

Run the project (with cargo run), and you get something like this:

map

Note the polar distortion, because we’re not using a proper coordinate translator!

If you upload that to https://www.maptoglobe.com/, you have a lovely planet render:

globe

The Completed Project Files

Cargo.toml:

1
2
3
4
5
6
7
8
[package]
name = "planetmap"
version = "0.1.0"
authors = ["Herbert Wolverson <herberticus@gmail.com>"]
edition = "2018"

[dependencies]
bracket-lib = "0.8.0"

main.rs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
use bracket_lib::prelude::*;
mod worldmap;
use worldmap::*;

struct State {
    world : WorldMap,
    run : bool
}

impl GameState for State {
    fn tick(&mut self, ctx: &mut BTerm) {
        if !self.run {
            self.world.render(ctx);
            self.run = true;
        }
    }
}

const WIDTH : i32 = 160;
const HEIGHT : i32 = 100;
const WIDTH_F : f32 = WIDTH as f32;
const HEIGHT_F : f32 = HEIGHT as f32;

fn main() -> BError {
    let context = BTermBuilder::simple(160, 100)
        .unwrap()
        .with_title("Hello Minimal Bracket World")
        .build()?;

    let gs: State = State {
        world : WorldMap::new(WIDTH_F, HEIGHT_F),
        run : false
    };

    main_loop(context, gs)
}

worldmap.rs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
use bracket_lib::prelude::*;

pub struct WorldMap {
    noise : FastNoise,
    width: f32,
    height: f32
}

impl WorldMap {
    pub fn new(width: f32, height: f32) -> Self {
        let mut rng = RandomNumberGenerator::new();
        let mut noise = FastNoise::seeded(rng.next_u64());
        noise.set_noise_type(NoiseType::SimplexFractal);
        noise.set_fractal_type(FractalType::FBM);
        noise.set_fractal_octaves(10);
        noise.set_fractal_gain(0.5);
        noise.set_fractal_lacunarity(3.0);
        noise.set_frequency(0.01);

        Self{
            noise,
            width,
            height
        }
    }

    fn sphere_vertex(&self, altitude: f32, lat: f32, lon: f32) -> (f32, f32, f32) {
        (
            altitude * f32::cos(lat) * f32::cos(lon),
            altitude * f32::cos(lat) * f32::sin(lon),
            altitude * f32::sin(lat)
        )
    }

    fn tile_display(&self, x: i32, y:i32) -> (FontCharType, RGB) {
        let lat = (((y as f32 / self.height) * 180.0) - 90.0) * 0.017_453_3;
        let lon = (((x as f32 / self.width) * 360.0) - 180.0) * 0.017_453_3;
        let coords = self.sphere_vertex(100.0, lat, lon);

        let altitude = self.noise.get_noise3d(coords.0, coords.1, coords.2);
        if altitude < 0.0 {
            ( to_cp437('▒'), RGB::from_f32(0.0, 0.0, 1.0 + altitude) )
        } else if altitude < 0.5 {
            let greenness = 0.5 + (altitude / 1.0);
            ( to_cp437('█'), RGB::from_f32(0.0, greenness, 0.0) )
        } else {
            let greenness = 0.2 + (altitude / 1.0);
            ( to_cp437('▲'), RGB::from_f32(greenness, greenness, greenness) )
        }
    }

    pub fn render(&self, ctx: &mut BTerm) {
        for y in 0..self.height as i32 {
            for x in 0..self.width as i32 {
                let render = self.tile_display(x, y);
                ctx.set(x, y, render.1, RGB::from_f32(0.0, 0.0, 0.0), render.0);
            }
        }
    }
}
Share this post
Support the author with