diff --git a/src/app.rs b/src/app.rs index 2eeb479..5feadd3 100644 --- a/src/app.rs +++ b/src/app.rs @@ -20,12 +20,14 @@ enum LeftTab { enum PreviewFocus { Heightmap, Bumpmap, + TerrainColor, } #[derive(Clone)] -struct TexturePair { +struct TextureSet { heightmap: TextureHandle, bumpmap: TextureHandle, + terrain_color: TextureHandle, } pub struct TerrainApp { @@ -33,7 +35,7 @@ pub struct TerrainApp { generation_tab: LeftTab, preview_focus: PreviewFocus, hovered_preview: Option, - textures: Option, + textures: Option, generated: Option, generation_receiver: Option>>, generation_in_progress: bool, @@ -47,7 +49,7 @@ impl TerrainApp { Self { params: GenerationParams::default(), generation_tab: LeftTab::Generation, - preview_focus: PreviewFocus::Bumpmap, + preview_focus: PreviewFocus::TerrainColor, hovered_preview: None, textures: None, generated: None, @@ -88,7 +90,7 @@ impl TerrainApp { match receiver.try_recv() { Ok(Ok(images)) => { self.status_message = format!( - "Generated {}x{} heightmap and bumpmap.", + "Generated {}x{} heightmap, bumpmap, and terrain color.", images.width, images.height ); self.install_textures(ctx, &images); @@ -142,10 +144,16 @@ impl TerrainApp { fn install_textures(&mut self, ctx: &Context, images: &GeneratedImages) { let heightmap = gray_to_color_image(&images.heightmap_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()), 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; }; - let image = match preview { - PreviewFocus::Heightmap => generated.heightmap_image.clone(), - PreviewFocus::Bumpmap => generated.bumpmap_image.clone(), - }; + let image = preview_image_data(generated, preview); let success_message = match preview { PreviewFocus::Heightmap => String::from("Heightmap 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(); @@ -175,10 +181,11 @@ impl TerrainApp { self.status_message = match preview { PreviewFocus::Heightmap => String::from("Copying heightmap to clipboard..."), PreviewFocus::Bumpmap => String::from("Copying bumpmap to clipboard..."), + PreviewFocus::TerrainColor => String::from("Copying terrain color to clipboard..."), }; 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); }); } @@ -342,6 +349,19 @@ impl TerrainApp { 0.1, ); 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) { @@ -359,6 +379,7 @@ impl TerrainApp { }; let heightmap_texture = textures.heightmap.clone(); let bumpmap_texture = textures.bumpmap.clone(); + let terrain_color_texture = textures.terrain_color.clone(); let available = ui.available_size(); 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 { PreviewFocus::Heightmap => &heightmap_texture, PreviewFocus::Bumpmap => &bumpmap_texture, + PreviewFocus::TerrainColor => &terrain_color_texture, }; let main_response = @@ -381,44 +403,44 @@ impl TerrainApp { } 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 secondary_texture = match 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", - }; + let secondary_previews = secondary_previews(preview_focus); ui.horizontal_centered(|ui| { - Frame::group(ui.style()).show(ui, |ui| { - ui.set_min_width(panel_width); - ui.set_max_width(panel_width); - ui.vertical_centered(|ui| { - ui.label(RichText::new(secondary_label).strong()); - ui.add_space(6.0); - let secondary_response = render_clickable_image( - ui, - secondary_texture, - Vec2::new(thumb_width, bottom_strip_height - 32.0), - true, - ); - if secondary_response.hovered() { - pending_hover = Some(secondary_focus); - } - if Self::preview_context_menu(&secondary_response) { - pending_copy = Some(secondary_focus); - } - if secondary_response.clicked() { - pending_focus = Some(secondary_focus); - } - }); + 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| { + ui.set_min_width(panel_width); + ui.set_max_width(panel_width); + ui.vertical_centered(|ui| { + ui.label(RichText::new(secondary_label).strong()); + ui.add_space(6.0); + let secondary_response = render_clickable_image( + ui, + secondary_texture, + Vec2::new(thumb_width, bottom_strip_height - 32.0), + true, + ); + if secondary_response.hovered() { + pending_hover = Some(secondary_focus); + } + if Self::preview_context_menu(&secondary_response) { + pending_copy = Some(secondary_focus); + } + if secondary_response.clicked() { + pending_focus = Some(secondary_focus); + } + }); + }); + } }); }); }); @@ -473,23 +495,48 @@ fn gray_to_color_image(image: &image::GrayImage) -> ColorImage { 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( ui: &mut Ui, label: &str, - value: &mut usize, - range: std::ops::RangeInclusive, + value: &mut Num, + range: std::ops::RangeInclusive, ) { ui.label(label); - let mut slider_value = *value as u32; - if ui - .add(Slider::new( - &mut slider_value, - *range.start() as u32..=*range.end() as u32, - )) - .changed() - { - *value = slider_value as usize; - } + ui.add(Slider::new(value, range)); } fn labeled_float_slider( @@ -534,19 +581,36 @@ fn render_clickable_image( 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); for pixel in image.pixels() { let gray = pixel.0[0]; rgba.extend_from_slice(&[gray, gray, gray, 255]); } - let data = ImageData { + ImageData { width: image.width() as usize, height: image.height() as usize, 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())?; - clipboard.set_image(data).map_err(|error| error.to_string()) + clipboard + .set_image(image.clone()) + .map_err(|error| error.to_string()) } diff --git a/src/generator.rs b/src/generator.rs index c93866f..3b51280 100644 --- a/src/generator.rs +++ b/src/generator.rs @@ -1,4 +1,4 @@ -use image::{GrayImage, Luma}; +use image::{GrayImage, Luma, Rgba, RgbaImage}; use rayon::prelude::*; const TAU: f32 = 6.283_185_5; @@ -21,6 +21,8 @@ pub struct GenerationParams { pub erosion_gully_weight: f32, pub erosion_detail: f32, pub erosion_octaves: usize, + pub snowline_height: u8, + pub rock_slope_angle_degrees: f32, } impl Default for GenerationParams { @@ -35,6 +37,8 @@ impl Default for GenerationParams { erosion_gully_weight: 0.5, erosion_detail: 1.5, erosion_octaves: 5, + snowline_height: 220, + rock_slope_angle_degrees: 15.0, } } } @@ -45,6 +49,13 @@ pub struct GeneratedImages { pub height: usize, pub heightmap_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)] @@ -192,7 +203,14 @@ fn noised(p: Vec2, random_seed: u64) -> (f32, Vec2) { (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 deriv = Vec2::default(); let mut nf = freq; @@ -207,7 +225,14 @@ fn fractal_noise(p: Vec2, freq: f32, octaves: usize, lacunarity: f32, gain: f32, (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 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 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) } @@ -274,10 +302,8 @@ pub fn generate(params: &GenerationParams) -> GeneratedImages { .enumerate() .for_each(|(y, row)| { for (x, value) in row.iter_mut().enumerate() { - let p = Vec2::new( - x as f32 / width as f32, - y as f32 / height as f32, - ) * params.generation_scale; + let p = Vec2::new(x as f32 / width as f32, y as f32 / height as f32) + * params.generation_scale; let height_freq = 3.0; 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 heightmap_image = grayscale_image_from_values(&heightmap_normalized, 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 { width, height, heightmap_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 { let mut min_val = f32::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 } +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 { let mut output = vec![0.0; width * height];