added basic terrain coloring

This commit is contained in:
grimsace
2026-04-22 13:22:39 -05:00
parent 57b20fe697
commit fc399095bd
2 changed files with 242 additions and 70 deletions
+104 -40
View File
@@ -20,12 +20,14 @@ enum LeftTab {
enum PreviewFocus { enum PreviewFocus {
Heightmap, Heightmap,
Bumpmap, Bumpmap,
TerrainColor,
} }
#[derive(Clone)] #[derive(Clone)]
struct TexturePair { struct TextureSet {
heightmap: TextureHandle, heightmap: TextureHandle,
bumpmap: TextureHandle, bumpmap: TextureHandle,
terrain_color: TextureHandle,
} }
pub struct TerrainApp { pub struct TerrainApp {
@@ -33,7 +35,7 @@ pub struct TerrainApp {
generation_tab: LeftTab, generation_tab: LeftTab,
preview_focus: PreviewFocus, preview_focus: PreviewFocus,
hovered_preview: Option<PreviewFocus>, hovered_preview: Option<PreviewFocus>,
textures: Option<TexturePair>, textures: Option<TextureSet>,
generated: Option<GeneratedImages>, generated: Option<GeneratedImages>,
generation_receiver: Option<Receiver<Result<GeneratedImages, String>>>, generation_receiver: Option<Receiver<Result<GeneratedImages, String>>>,
generation_in_progress: bool, generation_in_progress: bool,
@@ -47,7 +49,7 @@ impl TerrainApp {
Self { Self {
params: GenerationParams::default(), params: GenerationParams::default(),
generation_tab: LeftTab::Generation, generation_tab: LeftTab::Generation,
preview_focus: PreviewFocus::Bumpmap, preview_focus: PreviewFocus::TerrainColor,
hovered_preview: None, hovered_preview: None,
textures: None, textures: None,
generated: None, generated: None,
@@ -88,7 +90,7 @@ impl TerrainApp {
match receiver.try_recv() { match receiver.try_recv() {
Ok(Ok(images)) => { Ok(Ok(images)) => {
self.status_message = format!( self.status_message = format!(
"Generated {}x{} heightmap and bumpmap.", "Generated {}x{} heightmap, bumpmap, and terrain color.",
images.width, images.height images.width, images.height
); );
self.install_textures(ctx, &images); self.install_textures(ctx, &images);
@@ -142,10 +144,16 @@ impl TerrainApp {
fn install_textures(&mut self, ctx: &Context, images: &GeneratedImages) { fn install_textures(&mut self, ctx: &Context, images: &GeneratedImages) {
let heightmap = gray_to_color_image(&images.heightmap_image); let heightmap = gray_to_color_image(&images.heightmap_image);
let bumpmap = gray_to_color_image(&images.bumpmap_image); let bumpmap = gray_to_color_image(&images.bumpmap_image);
let terrain_color = rgba_to_color_image(&images.terrain_color_image);
self.textures = Some(TexturePair { self.textures = Some(TextureSet {
heightmap: ctx.load_texture("heightmap_preview", heightmap, TextureOptions::default()), heightmap: ctx.load_texture("heightmap_preview", heightmap, TextureOptions::default()),
bumpmap: ctx.load_texture("bumpmap_preview", bumpmap, TextureOptions::default()), bumpmap: ctx.load_texture("bumpmap_preview", bumpmap, TextureOptions::default()),
terrain_color: ctx.load_texture(
"terrain_color_preview",
terrain_color,
TextureOptions::default(),
),
}); });
} }
@@ -160,13 +168,11 @@ impl TerrainApp {
return; return;
}; };
let image = match preview { let image = preview_image_data(generated, preview);
PreviewFocus::Heightmap => generated.heightmap_image.clone(),
PreviewFocus::Bumpmap => generated.bumpmap_image.clone(),
};
let success_message = match preview { let success_message = match preview {
PreviewFocus::Heightmap => String::from("Heightmap copied to clipboard."), PreviewFocus::Heightmap => String::from("Heightmap copied to clipboard."),
PreviewFocus::Bumpmap => String::from("Bumpmap copied to clipboard."), PreviewFocus::Bumpmap => String::from("Bumpmap copied to clipboard."),
PreviewFocus::TerrainColor => String::from("Terrain color copied to clipboard."),
}; };
let (tx, rx) = mpsc::channel(); let (tx, rx) = mpsc::channel();
@@ -175,10 +181,11 @@ impl TerrainApp {
self.status_message = match preview { self.status_message = match preview {
PreviewFocus::Heightmap => String::from("Copying heightmap to clipboard..."), PreviewFocus::Heightmap => String::from("Copying heightmap to clipboard..."),
PreviewFocus::Bumpmap => String::from("Copying bumpmap to clipboard..."), PreviewFocus::Bumpmap => String::from("Copying bumpmap to clipboard..."),
PreviewFocus::TerrainColor => String::from("Copying terrain color to clipboard..."),
}; };
thread::spawn(move || { thread::spawn(move || {
let result = copy_gray_image_to_clipboard(&image).map(|_| success_message); let result = copy_image_to_clipboard(&image).map(|_| success_message);
let _ = tx.send(result); let _ = tx.send(result);
}); });
} }
@@ -342,6 +349,19 @@ impl TerrainApp {
0.1, 0.1,
); );
labeled_int_slider(ui, "Octaves", &mut self.params.erosion_octaves, 1..=8); labeled_int_slider(ui, "Octaves", &mut self.params.erosion_octaves, 1..=8);
labeled_int_slider(
ui,
"Snowline Height",
&mut self.params.snowline_height,
0..=255,
);
labeled_float_slider(
ui,
"Rock Slope Angle",
&mut self.params.rock_slope_angle_degrees,
0.0..=89.0,
1.0,
);
} }
fn ui_right_panel(&mut self, ctx: &Context) { fn ui_right_panel(&mut self, ctx: &Context) {
@@ -359,6 +379,7 @@ impl TerrainApp {
}; };
let heightmap_texture = textures.heightmap.clone(); let heightmap_texture = textures.heightmap.clone();
let bumpmap_texture = textures.bumpmap.clone(); let bumpmap_texture = textures.bumpmap.clone();
let terrain_color_texture = textures.terrain_color.clone();
let available = ui.available_size(); let available = ui.available_size();
let bottom_strip_height = (available.y * 0.24).clamp(120.0, 220.0); let bottom_strip_height = (available.y * 0.24).clamp(120.0, 220.0);
@@ -367,6 +388,7 @@ impl TerrainApp {
let main_texture = match preview_focus { let main_texture = match preview_focus {
PreviewFocus::Heightmap => &heightmap_texture, PreviewFocus::Heightmap => &heightmap_texture,
PreviewFocus::Bumpmap => &bumpmap_texture, PreviewFocus::Bumpmap => &bumpmap_texture,
PreviewFocus::TerrainColor => &terrain_color_texture,
}; };
let main_response = let main_response =
@@ -381,22 +403,20 @@ impl TerrainApp {
} }
ui.add_space(8.0); ui.add_space(8.0);
let thumb_width = (available.x * 0.28).clamp(120.0, 240.0); let thumb_width = (available.x * 0.26).clamp(120.0, 220.0);
let panel_width = (thumb_width + 32.0).min(available.x); let panel_width = (thumb_width + 32.0).min(available.x);
let secondary_texture = match preview_focus { let secondary_previews = secondary_previews(preview_focus);
PreviewFocus::Heightmap => &bumpmap_texture,
PreviewFocus::Bumpmap => &heightmap_texture,
};
let secondary_focus = match preview_focus {
PreviewFocus::Heightmap => PreviewFocus::Bumpmap,
PreviewFocus::Bumpmap => PreviewFocus::Heightmap,
};
let secondary_label = match secondary_focus {
PreviewFocus::Heightmap => "Heightmap",
PreviewFocus::Bumpmap => "Bumpmap",
};
ui.horizontal_centered(|ui| { ui.horizontal_centered(|ui| {
ui.horizontal(|ui| {
for secondary_focus in secondary_previews {
let secondary_texture = match secondary_focus {
PreviewFocus::Heightmap => &heightmap_texture,
PreviewFocus::Bumpmap => &bumpmap_texture,
PreviewFocus::TerrainColor => &terrain_color_texture,
};
let secondary_label = preview_label(secondary_focus);
Frame::group(ui.style()).show(ui, |ui| { Frame::group(ui.style()).show(ui, |ui| {
ui.set_min_width(panel_width); ui.set_min_width(panel_width);
ui.set_max_width(panel_width); ui.set_max_width(panel_width);
@@ -420,6 +440,8 @@ impl TerrainApp {
} }
}); });
}); });
}
});
}); });
}); });
@@ -473,23 +495,48 @@ fn gray_to_color_image(image: &image::GrayImage) -> ColorImage {
ColorImage::new(size, pixels) ColorImage::new(size, pixels)
} }
fn labeled_int_slider( fn rgba_to_color_image(image: &image::RgbaImage) -> ColorImage {
let size = [image.width() as usize, image.height() as usize];
let mut pixels = Vec::with_capacity(size[0] * size[1]);
for pixel in image.pixels() {
let [r, g, b, a] = pixel.0;
pixels.push(Color32::from_rgba_unmultiplied(r, g, b, a));
}
ColorImage::new(size, pixels)
}
fn preview_image_data(images: &GeneratedImages, preview: PreviewFocus) -> ImageData<'static> {
match preview {
PreviewFocus::Heightmap => gray_image_to_clipboard_data(&images.heightmap_image),
PreviewFocus::Bumpmap => gray_image_to_clipboard_data(&images.bumpmap_image),
PreviewFocus::TerrainColor => rgba_image_to_clipboard_data(&images.terrain_color_image),
}
}
fn preview_label(preview: PreviewFocus) -> &'static str {
match preview {
PreviewFocus::Heightmap => "Heightmap",
PreviewFocus::Bumpmap => "Bumpmap",
PreviewFocus::TerrainColor => "Terrain Color",
}
}
fn secondary_previews(preview: PreviewFocus) -> [PreviewFocus; 2] {
match preview {
PreviewFocus::Heightmap => [PreviewFocus::TerrainColor, PreviewFocus::Bumpmap],
PreviewFocus::Bumpmap => [PreviewFocus::TerrainColor, PreviewFocus::Heightmap],
PreviewFocus::TerrainColor => [PreviewFocus::Heightmap, PreviewFocus::Bumpmap],
}
}
fn labeled_int_slider<Num: egui::emath::Numeric>(
ui: &mut Ui, ui: &mut Ui,
label: &str, label: &str,
value: &mut usize, value: &mut Num,
range: std::ops::RangeInclusive<usize>, range: std::ops::RangeInclusive<Num>,
) { ) {
ui.label(label); ui.label(label);
let mut slider_value = *value as u32; ui.add(Slider::new(value, range));
if ui
.add(Slider::new(
&mut slider_value,
*range.start() as u32..=*range.end() as u32,
))
.changed()
{
*value = slider_value as usize;
}
} }
fn labeled_float_slider( fn labeled_float_slider(
@@ -534,19 +581,36 @@ fn render_clickable_image(
response response
} }
fn copy_gray_image_to_clipboard(image: &image::GrayImage) -> Result<(), String> { fn gray_image_to_clipboard_data(image: &image::GrayImage) -> ImageData<'static> {
let mut rgba = Vec::with_capacity((image.width() * image.height() * 4) as usize); let mut rgba = Vec::with_capacity((image.width() * image.height() * 4) as usize);
for pixel in image.pixels() { for pixel in image.pixels() {
let gray = pixel.0[0]; let gray = pixel.0[0];
rgba.extend_from_slice(&[gray, gray, gray, 255]); rgba.extend_from_slice(&[gray, gray, gray, 255]);
} }
let data = ImageData { ImageData {
width: image.width() as usize, width: image.width() as usize,
height: image.height() as usize, height: image.height() as usize,
bytes: rgba.into(), bytes: rgba.into(),
}; }
}
fn rgba_image_to_clipboard_data(image: &image::RgbaImage) -> ImageData<'static> {
let mut rgba = Vec::with_capacity((image.width() * image.height() * 4) as usize);
for pixel in image.pixels() {
rgba.extend_from_slice(&pixel.0);
}
ImageData {
width: image.width() as usize,
height: image.height() as usize,
bytes: rgba.into(),
}
}
fn copy_image_to_clipboard(image: &ImageData<'static>) -> Result<(), String> {
let mut clipboard = Clipboard::new().map_err(|error| error.to_string())?; let mut clipboard = Clipboard::new().map_err(|error| error.to_string())?;
clipboard.set_image(data).map_err(|error| error.to_string()) clipboard
.set_image(image.clone())
.map_err(|error| error.to_string())
} }
+116 -8
View File
@@ -1,4 +1,4 @@
use image::{GrayImage, Luma}; use image::{GrayImage, Luma, Rgba, RgbaImage};
use rayon::prelude::*; use rayon::prelude::*;
const TAU: f32 = 6.283_185_5; const TAU: f32 = 6.283_185_5;
@@ -21,6 +21,8 @@ pub struct GenerationParams {
pub erosion_gully_weight: f32, pub erosion_gully_weight: f32,
pub erosion_detail: f32, pub erosion_detail: f32,
pub erosion_octaves: usize, pub erosion_octaves: usize,
pub snowline_height: u8,
pub rock_slope_angle_degrees: f32,
} }
impl Default for GenerationParams { impl Default for GenerationParams {
@@ -35,6 +37,8 @@ impl Default for GenerationParams {
erosion_gully_weight: 0.5, erosion_gully_weight: 0.5,
erosion_detail: 1.5, erosion_detail: 1.5,
erosion_octaves: 5, erosion_octaves: 5,
snowline_height: 220,
rock_slope_angle_degrees: 15.0,
} }
} }
} }
@@ -45,6 +49,13 @@ pub struct GeneratedImages {
pub height: usize, pub height: usize,
pub heightmap_image: GrayImage, pub heightmap_image: GrayImage,
pub bumpmap_image: GrayImage, pub bumpmap_image: GrayImage,
pub terrain_color_image: RgbaImage,
#[allow(dead_code)]
pub grass_mask_image: GrayImage,
#[allow(dead_code)]
pub rock_mask_image: GrayImage,
#[allow(dead_code)]
pub snow_mask_image: GrayImage,
} }
#[derive(Copy, Clone, Debug, Default)] #[derive(Copy, Clone, Debug, Default)]
@@ -192,7 +203,14 @@ fn noised(p: Vec2, random_seed: u64) -> (f32, Vec2) {
(val, Vec2::new(deriv_x, deriv_y)) (val, Vec2::new(deriv_x, deriv_y))
} }
fn fractal_noise(p: Vec2, freq: f32, octaves: usize, lacunarity: f32, gain: f32, random_seed: u64) -> (f32, Vec2) { fn fractal_noise(
p: Vec2,
freq: f32,
octaves: usize,
lacunarity: f32,
gain: f32,
random_seed: u64,
) -> (f32, Vec2) {
let mut val = 0.0; let mut val = 0.0;
let mut deriv = Vec2::default(); let mut deriv = Vec2::default();
let mut nf = freq; let mut nf = freq;
@@ -207,7 +225,14 @@ fn fractal_noise(p: Vec2, freq: f32, octaves: usize, lacunarity: f32, gain: f32,
(val, deriv) (val, deriv)
} }
fn phacelle_noise(p: Vec2, norm_dir: Vec2, freq: f32, offset: f32, normalization: f32, random_seed: u64) -> (Vec2, Vec2) { fn phacelle_noise(
p: Vec2,
norm_dir: Vec2,
freq: f32,
offset: f32,
normalization: f32,
random_seed: u64,
) -> (Vec2, Vec2) {
let side_dir = Vec2::new(-norm_dir.y, norm_dir.x) * (freq * TAU); let side_dir = Vec2::new(-norm_dir.y, norm_dir.x) * (freq * TAU);
let offset_tau = offset * TAU; let offset_tau = offset * TAU;
@@ -241,7 +266,10 @@ fn phacelle_noise(p: Vec2, norm_dir: Vec2, freq: f32, offset: f32, normalization
} }
let interpolated = phase_dir * (1.0 / weight_sum); let interpolated = phase_dir * (1.0 / weight_sum);
let magnitude = interpolated.dot(interpolated).sqrt().max(1.0 - normalization); let magnitude = interpolated
.dot(interpolated)
.sqrt()
.max(1.0 - normalization);
(interpolated * (1.0 / magnitude), side_dir) (interpolated * (1.0 / magnitude), side_dir)
} }
@@ -274,10 +302,8 @@ pub fn generate(params: &GenerationParams) -> GeneratedImages {
.enumerate() .enumerate()
.for_each(|(y, row)| { .for_each(|(y, row)| {
for (x, value) in row.iter_mut().enumerate() { for (x, value) in row.iter_mut().enumerate() {
let p = Vec2::new( let p = Vec2::new(x as f32 / width as f32, y as f32 / height as f32)
x as f32 / width as f32, * params.generation_scale;
y as f32 / height as f32,
) * params.generation_scale;
let height_freq = 3.0; let height_freq = 3.0;
let height_amp = 0.125; let height_amp = 0.125;
@@ -369,15 +395,33 @@ pub fn generate(params: &GenerationParams) -> GeneratedImages {
let bumpmap = apply_shading(&heightmap, width, height, 0.5); let bumpmap = apply_shading(&heightmap, width, height, 0.5);
let heightmap_image = grayscale_image_from_values(&heightmap_normalized, width, height); let heightmap_image = grayscale_image_from_values(&heightmap_normalized, width, height);
let bumpmap_image = grayscale_image_from_values(&bumpmap, width, height); let bumpmap_image = grayscale_image_from_values(&bumpmap, width, height);
let terrain_layers = build_terrain_layers(
&heightmap_normalized,
width,
height,
params.snowline_height,
params.rock_slope_angle_degrees,
);
GeneratedImages { GeneratedImages {
width, width,
height, height,
heightmap_image, heightmap_image,
bumpmap_image, bumpmap_image,
terrain_color_image: terrain_layers.terrain_color_image,
grass_mask_image: terrain_layers.grass_mask_image,
rock_mask_image: terrain_layers.rock_mask_image,
snow_mask_image: terrain_layers.snow_mask_image,
} }
} }
struct TerrainLayers {
terrain_color_image: RgbaImage,
grass_mask_image: GrayImage,
rock_mask_image: GrayImage,
snow_mask_image: GrayImage,
}
fn normalize_values(values: &[f32]) -> Vec<f32> { fn normalize_values(values: &[f32]) -> Vec<f32> {
let mut min_val = f32::INFINITY; let mut min_val = f32::INFINITY;
let mut max_val = f32::NEG_INFINITY; let mut max_val = f32::NEG_INFINITY;
@@ -407,6 +451,70 @@ fn grayscale_image_from_values(values: &[f32], width: usize, height: usize) -> G
image image
} }
fn build_terrain_layers(
heightmap: &[f32],
width: usize,
height: usize,
snowline_height: u8,
rock_slope_angle_degrees: f32,
) -> TerrainLayers {
let mut terrain_color_image = RgbaImage::new(width as u32, height as u32);
let mut grass_mask_image = GrayImage::new(width as u32, height as u32);
let mut rock_mask_image = GrayImage::new(width as u32, height as u32);
let mut snow_mask_image = GrayImage::new(width as u32, height as u32);
let grass_color = Rgba([92, 140, 66, 255]);
let rock_color = Rgba([122, 112, 98, 255]);
let snow_color = Rgba([245, 248, 252, 255]);
let slope_threshold_radians = rock_slope_angle_degrees.clamp(0.0, 89.9).to_radians();
for y in 0..height {
for x in 0..width {
let idx = y * width + x;
let height_value = heightmap[idx].clamp(0.0, 1.0);
let left = sample_height(heightmap, width, height, x.saturating_sub(1), y);
let right = sample_height(heightmap, width, height, (x + 1).min(width - 1), y);
let up = sample_height(heightmap, width, height, x, y.saturating_sub(1));
let down = sample_height(heightmap, width, height, x, (y + 1).min(height - 1));
let dx = (right - left) * 0.5 * width as f32;
let dy = (down - up) * 0.5 * height as f32;
let slope_angle = dx.hypot(dy).atan();
let is_snow = if snowline_height == 0 {
true
} else if snowline_height == u8::MAX {
false
} else {
(height_value * 255.0).round() > snowline_height as f32
};
let is_rock = slope_angle >= slope_threshold_radians;
grass_mask_image.put_pixel(x as u32, y as u32, Luma([255]));
rock_mask_image.put_pixel(x as u32, y as u32, Luma([if is_rock { 255 } else { 0 }]));
snow_mask_image.put_pixel(x as u32, y as u32, Luma([if is_snow { 255 } else { 0 }]));
let mut color = grass_color;
if is_rock {
color = rock_color;
}
if is_snow {
color = snow_color;
}
terrain_color_image.put_pixel(x as u32, y as u32, color);
}
}
TerrainLayers {
terrain_color_image,
grass_mask_image,
rock_mask_image,
snow_mask_image,
}
}
fn apply_shading(heightmap: &[f32], width: usize, height: usize, strength: f32) -> Vec<f32> { fn apply_shading(heightmap: &[f32], width: usize, height: usize, strength: f32) -> Vec<f32> {
let mut output = vec![0.0; width * height]; let mut output = vec![0.0; width * height];