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:
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:
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);
}
}
}
}
|