feat: wire vertical exaggeration through the shell #13
+67
-4
@@ -3,7 +3,9 @@ use std::path::Path;
|
|||||||
use image::RgbImage;
|
use image::RgbImage;
|
||||||
|
|
||||||
use crate::path::{CameraPath, build_demo_path};
|
use crate::path::{CameraPath, build_demo_path};
|
||||||
use crate::render::{render_perspective, render_top_down};
|
use crate::render::{
|
||||||
|
render_perspective_with_quality, render_top_down_with_quality, RenderQualityPreset,
|
||||||
|
};
|
||||||
use crate::scene::{Scene, Vec3};
|
use crate::scene::{Scene, Vec3};
|
||||||
use crate::scene_file::{self, SceneFileError};
|
use crate::scene_file::{self, SceneFileError};
|
||||||
use crate::script::{Command, parse_script};
|
use crate::script::{Command, parse_script};
|
||||||
@@ -49,6 +51,32 @@ impl RendererMode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum RenderQuality {
|
||||||
|
Preview,
|
||||||
|
Balanced,
|
||||||
|
Final,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RenderQuality {
|
||||||
|
/// Human-readable label used by the UI shell and CLI.
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
RenderQuality::Preview => "Preview",
|
||||||
|
RenderQuality::Balanced => "Balanced",
|
||||||
|
RenderQuality::Final => "Final",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn preset(self) -> RenderQualityPreset {
|
||||||
|
match self {
|
||||||
|
RenderQuality::Preview => RenderQualityPreset::Preview,
|
||||||
|
RenderQuality::Balanced => RenderQualityPreset::Balanced,
|
||||||
|
RenderQuality::Final => RenderQualityPreset::Final,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A pure, derived summary of a parsed script, suitable for display in the UI.
|
/// A pure, derived summary of a parsed script, suitable for display in the UI.
|
||||||
///
|
///
|
||||||
/// All counts are zero and `error` carries the parser message when the script
|
/// All counts are zero and `error` carries the parser message when the script
|
||||||
@@ -194,10 +222,25 @@ impl AppData {
|
|||||||
river_level,
|
river_level,
|
||||||
lake_level,
|
lake_level,
|
||||||
drainage,
|
drainage,
|
||||||
|
lake_center_x,
|
||||||
|
lake_center_z,
|
||||||
|
lake_radius,
|
||||||
|
river_center_x,
|
||||||
|
river_width,
|
||||||
|
river_bend,
|
||||||
} => {
|
} => {
|
||||||
self.scene.hydrology.river_level = river_level;
|
self.scene.hydrology.river_level = river_level;
|
||||||
self.scene.hydrology.lake_level = lake_level;
|
self.scene.hydrology.lake_level = lake_level;
|
||||||
self.scene.hydrology.drainage = drainage.max(0.0);
|
self.scene.hydrology.drainage = drainage.max(0.0);
|
||||||
|
self.scene.hydrology.lake_center_x = lake_center_x.clamp(0.0, 1.0);
|
||||||
|
self.scene.hydrology.lake_center_z = lake_center_z.clamp(0.0, 1.0);
|
||||||
|
self.scene.hydrology.lake_radius = lake_radius.max(0.0);
|
||||||
|
self.scene.hydrology.river_center_x = river_center_x.clamp(0.0, 1.0);
|
||||||
|
self.scene.hydrology.river_width = river_width.max(0.0);
|
||||||
|
self.scene.hydrology.river_bend = river_bend.clamp(0.0, 0.5);
|
||||||
|
}
|
||||||
|
AppAction::ResetHydrology => {
|
||||||
|
self.scene.hydrology = crate::scene::Hydrology::default();
|
||||||
}
|
}
|
||||||
AppAction::SetPalette(palette) => self.scene.palette = palette,
|
AppAction::SetPalette(palette) => self.scene.palette = palette,
|
||||||
AppAction::SetPreviewSize { width, height } => {
|
AppAction::SetPreviewSize { width, height } => {
|
||||||
@@ -280,8 +323,8 @@ impl AppData {
|
|||||||
import_label: "Import terrain".to_string(),
|
import_label: "Import terrain".to_string(),
|
||||||
script_label: "Scripts / paths".to_string(),
|
script_label: "Scripts / paths".to_string(),
|
||||||
path_label: "Path tools".to_string(),
|
path_label: "Path tools".to_string(),
|
||||||
scene_controls_label: "Scene / camera / palette".to_string(),
|
scene_controls_label: "Scene / camera / color map".to_string(),
|
||||||
palette_label: "Palette / color map".to_string(),
|
palette_label: "Color map".to_string(),
|
||||||
hydrology_label: "Hydrology".to_string(),
|
hydrology_label: "Hydrology".to_string(),
|
||||||
legacy_dialogs_label: "Legacy dialogs".to_string(),
|
legacy_dialogs_label: "Legacy dialogs".to_string(),
|
||||||
scene_file_path: self.loaded_scene_path.clone(),
|
scene_file_path: self.loaded_scene_path.clone(),
|
||||||
@@ -373,12 +416,31 @@ mod tests {
|
|||||||
assert_eq!(app.scene.water_level, 2.5);
|
assert_eq!(app.scene.water_level, 2.5);
|
||||||
assert_eq!(app.scene.tree_line, 5.5);
|
assert_eq!(app.scene.tree_line, 5.5);
|
||||||
assert_eq!(app.scene.snow_line, 8.5);
|
assert_eq!(app.scene.snow_line, 8.5);
|
||||||
|
assert_eq!(app.scene.palette.water_level, 2.5);
|
||||||
|
assert_eq!(app.scene.palette.tree_line, 5.5);
|
||||||
|
assert_eq!(app.scene.palette.snow_line, 8.5);
|
||||||
assert_eq!(app.scene.haze, 0.75);
|
assert_eq!(app.scene.haze, 0.75);
|
||||||
assert_eq!(app.scene.camera, original_camera);
|
assert_eq!(app.scene.camera, original_camera);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reducer_updates_camera_vectors_and_mode() {
|
fn reducer_syncs_palette_thresholds_back_into_scene_state() {
|
||||||
|
let mut app = AppData::default();
|
||||||
|
let mut palette = app.scene.palette;
|
||||||
|
palette.water_level = 0.25;
|
||||||
|
palette.tree_line = 2.75;
|
||||||
|
palette.snow_line = 6.5;
|
||||||
|
palette.water = [1, 2, 3];
|
||||||
|
|
||||||
|
app.apply(AppAction::SetPalette(palette));
|
||||||
|
|
||||||
|
assert_eq!(app.scene.water_level, 0.25);
|
||||||
|
assert_eq!(app.scene.tree_line, 2.75);
|
||||||
|
assert_eq!(app.scene.snow_line, 6.5);
|
||||||
|
assert_eq!(app.scene.palette, palette);
|
||||||
|
assert_eq!(app.scene.palette.water, [1, 2, 3]);
|
||||||
|
}
|
||||||
|
|
||||||
let mut app = AppData::default();
|
let mut app = AppData::default();
|
||||||
let position = Vec3::new(1.0, 2.0, 3.0);
|
let position = Vec3::new(1.0, 2.0, 3.0);
|
||||||
let target = Vec3::new(4.0, 5.0, 6.0);
|
let target = Vec3::new(4.0, 5.0, 6.0);
|
||||||
@@ -426,6 +488,7 @@ mod tests {
|
|||||||
assert_eq!(shell.import_label, "Import terrain");
|
assert_eq!(shell.import_label, "Import terrain");
|
||||||
assert_eq!(shell.script_label, "Scripts / paths");
|
assert_eq!(shell.script_label, "Scripts / paths");
|
||||||
assert_eq!(shell.path_label, "Path tools");
|
assert_eq!(shell.path_label, "Path tools");
|
||||||
|
assert_eq!(shell.palette_label, "Color map");
|
||||||
assert!(shell.scene_file_path.is_some());
|
assert!(shell.scene_file_path.is_some());
|
||||||
assert!(shell.import_path.is_some());
|
assert!(shell.import_path.is_some());
|
||||||
assert!(shell.path_target.is_none());
|
assert!(shell.path_target.is_none());
|
||||||
|
|||||||
@@ -195,4 +195,13 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(scene_color(&scene, scene.snow_line + 1.0), SNOW_COLOR);
|
assert_eq!(scene_color(&scene, scene.snow_line + 1.0), SNOW_COLOR);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scene_color_uses_palette_band_colors_for_the_default_scene() {
|
||||||
|
let scene = Scene::default();
|
||||||
|
assert_eq!(scene.palette.water, WATER_COLOR);
|
||||||
|
assert_eq!(scene.palette.lowland, LOWLAND_COLOR);
|
||||||
|
assert_eq!(scene.palette.highland, HIGHLAND_COLOR);
|
||||||
|
assert_eq!(scene.palette.snow, SNOW_COLOR);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+67
-1
@@ -70,6 +70,56 @@ pub fn render_top_down(grid: &HeightGrid, scene: &Scene) -> RgbImage {
|
|||||||
img
|
img
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn blur_pass(image: &RgbImage) -> RgbImage {
|
||||||
|
let mut blurred = RgbImage::new(image.width(), image.height());
|
||||||
|
for y in 0..image.height() {
|
||||||
|
for x in 0..image.width() {
|
||||||
|
let mut accum = [0u32; 3];
|
||||||
|
let mut count = 0u32;
|
||||||
|
for dy in -1i32..=1 {
|
||||||
|
for dx in -1i32..=1 {
|
||||||
|
let nx = x as i32 + dx;
|
||||||
|
let ny = y as i32 + dy;
|
||||||
|
if nx < 0 || ny < 0 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (nx, ny) = (nx as u32, ny as u32);
|
||||||
|
if nx >= image.width() || ny >= image.height() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let px = image.get_pixel(nx, ny).0;
|
||||||
|
accum[0] += u32::from(px[0]);
|
||||||
|
accum[1] += u32::from(px[1]);
|
||||||
|
accum[2] += u32::from(px[2]);
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let avg = [
|
||||||
|
(accum[0] / count.max(1)) as u8,
|
||||||
|
(accum[1] / count.max(1)) as u8,
|
||||||
|
(accum[2] / count.max(1)) as u8,
|
||||||
|
];
|
||||||
|
blurred.put_pixel(x, y, Rgb(avg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
blurred
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_top_down_quality(mut image: RgbImage, quality: RenderQualityPreset) -> RgbImage {
|
||||||
|
for _ in 0..quality.profile().top_down_blur_passes {
|
||||||
|
image = blur_pass(&image);
|
||||||
|
}
|
||||||
|
image
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_top_down_with_quality(
|
||||||
|
grid: &HeightGrid,
|
||||||
|
scene: &Scene,
|
||||||
|
quality: RenderQualityPreset,
|
||||||
|
) -> RgbImage {
|
||||||
|
apply_top_down_quality(render_top_down(grid, scene), quality)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn render_top_down_to_path(
|
pub fn render_top_down_to_path(
|
||||||
grid: &HeightGrid,
|
grid: &HeightGrid,
|
||||||
scene: &Scene,
|
scene: &Scene,
|
||||||
@@ -227,6 +277,22 @@ pub fn demo_camera_for(grid: &HeightGrid) -> Camera {
|
|||||||
/// Coordinate convention: world X and Z map onto the grid's `(x, y)` indices,
|
/// Coordinate convention: world X and Z map onto the grid's `(x, y)` indices,
|
||||||
/// world Y is elevation, up = +Y.
|
/// world Y is elevation, up = +Y.
|
||||||
pub fn render_perspective(grid: &HeightGrid, scene: &Scene, width: u32, height: u32) -> RgbImage {
|
pub fn render_perspective(grid: &HeightGrid, scene: &Scene, width: u32, height: u32) -> RgbImage {
|
||||||
|
render_perspective_with_quality(
|
||||||
|
grid,
|
||||||
|
scene,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
RenderQualityPreset::Preview,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_perspective_with_quality(
|
||||||
|
grid: &HeightGrid,
|
||||||
|
scene: &Scene,
|
||||||
|
width: u32,
|
||||||
|
height: u32,
|
||||||
|
quality: RenderQualityPreset,
|
||||||
|
) -> RgbImage {
|
||||||
let mut img = RgbImage::new(width.max(1), height.max(1));
|
let mut img = RgbImage::new(width.max(1), height.max(1));
|
||||||
|
|
||||||
let cam = &scene.camera;
|
let cam = &scene.camera;
|
||||||
@@ -245,7 +311,7 @@ pub fn render_perspective(grid: &HeightGrid, scene: &Scene, width: u32, height:
|
|||||||
let grid_w = grid.width() as f32;
|
let grid_w = grid.width() as f32;
|
||||||
let grid_h = grid.height() as f32;
|
let grid_h = grid.height() as f32;
|
||||||
let max_dist = cam.far_range.max(cam.near_range + 0.1);
|
let max_dist = cam.far_range.max(cam.near_range + 0.1);
|
||||||
let step = 0.5_f32;
|
let step = quality.profile().perspective_step;
|
||||||
|
|
||||||
let haze_strength = scene.haze.clamp(0.0, 1.0);
|
let haze_strength = scene.haze.clamp(0.0, 1.0);
|
||||||
|
|
||||||
|
|||||||
@@ -103,12 +103,58 @@ pub struct Hydrology {
|
|||||||
pub river_level: f32,
|
pub river_level: f32,
|
||||||
pub lake_level: f32,
|
pub lake_level: f32,
|
||||||
pub drainage: f32,
|
pub drainage: f32,
|
||||||
|
/// Normalized center point for the generated lake overlay.
|
||||||
|
pub lake_center_x: f32,
|
||||||
|
/// Normalized center point for the generated lake overlay.
|
||||||
|
pub lake_center_z: f32,
|
||||||
|
/// Normalized radius for the generated lake overlay.
|
||||||
|
pub lake_radius: f32,
|
||||||
|
/// Normalized centerline for the generated river overlay.
|
||||||
|
pub river_center_x: f32,
|
||||||
|
/// Normalized half-width for the generated river overlay.
|
||||||
|
pub river_width: f32,
|
||||||
|
/// Horizontal meander amplitude for the generated river overlay.
|
||||||
|
pub river_bend: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn smoothstep(edge0: f32, edge1: f32, value: f32) -> f32 {
|
||||||
|
if edge0 >= edge1 {
|
||||||
|
return if value < edge0 { 0.0 } else { 1.0 };
|
||||||
|
}
|
||||||
|
let t = ((value - edge0) / (edge1 - edge0)).clamp(0.0, 1.0);
|
||||||
|
t * t * (3.0 - 2.0 * t)
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Hydrology {
|
impl Hydrology {
|
||||||
pub fn effective_water_level(self, base_water_level: f32) -> f32 {
|
pub fn effective_water_level(self, base_water_level: f32) -> f32 {
|
||||||
(base_water_level.max(self.river_level).max(self.lake_level) - self.drainage).max(0.0)
|
(base_water_level.max(self.river_level).max(self.lake_level) - self.drainage).max(0.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Coverage of the generated lake primitive at normalized coordinates.
|
||||||
|
pub fn lake_coverage(self, x: f32, z: f32) -> f32 {
|
||||||
|
let radius = self.lake_radius.max(0.0);
|
||||||
|
if radius <= 0.0 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let dx = x - self.lake_center_x;
|
||||||
|
let dz = z - self.lake_center_z;
|
||||||
|
let dist = (dx * dx + dz * dz).sqrt();
|
||||||
|
let falloff = (radius * 0.2).max(0.01);
|
||||||
|
1.0 - smoothstep(radius - falloff, radius + falloff, dist)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Coverage of the generated river primitive at normalized coordinates.
|
||||||
|
pub fn river_coverage(self, x: f32, z: f32) -> f32 {
|
||||||
|
let half_width = self.river_width.max(0.0);
|
||||||
|
if half_width <= 0.0 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
let meander = self.river_bend * (z * std::f32::consts::TAU * 1.25).sin();
|
||||||
|
let center_x = (self.river_center_x + meander).clamp(0.0, 1.0);
|
||||||
|
let dist = (x - center_x).abs();
|
||||||
|
let falloff = (half_width * 0.35).max(0.01);
|
||||||
|
1.0 - smoothstep(half_width - falloff, half_width + falloff, dist)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Hydrology {
|
impl Default for Hydrology {
|
||||||
@@ -117,6 +163,12 @@ impl Default for Hydrology {
|
|||||||
river_level: 0.5,
|
river_level: 0.5,
|
||||||
lake_level: 0.75,
|
lake_level: 0.75,
|
||||||
drainage: 0.0,
|
drainage: 0.0,
|
||||||
|
lake_center_x: 0.38,
|
||||||
|
lake_center_z: 0.62,
|
||||||
|
lake_radius: 0.16,
|
||||||
|
river_center_x: 0.58,
|
||||||
|
river_width: 0.08,
|
||||||
|
river_bend: 0.08,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,6 +152,9 @@ mod tests {
|
|||||||
drainage: 0.2,
|
drainage: 0.2,
|
||||||
},
|
},
|
||||||
palette: Palette {
|
palette: Palette {
|
||||||
|
water_level: 0.5,
|
||||||
|
tree_line: 3.0,
|
||||||
|
snow_line: 8.0,
|
||||||
water: [12, 34, 56],
|
water: [12, 34, 56],
|
||||||
lowland: [78, 90, 12],
|
lowland: [78, 90, 12],
|
||||||
highland: [123, 111, 99],
|
highland: [123, 111, 99],
|
||||||
|
|||||||
@@ -141,6 +141,11 @@ pub fn run_script(script: &Script, base_dir: &Path) -> Result<ExecReport, Script
|
|||||||
scene.water_level = thresholds.water;
|
scene.water_level = thresholds.water;
|
||||||
scene.tree_line = thresholds.tree;
|
scene.tree_line = thresholds.tree;
|
||||||
scene.snow_line = thresholds.snow;
|
scene.snow_line = thresholds.snow;
|
||||||
|
scene.palette.sync_thresholds_from_scene(
|
||||||
|
thresholds.water,
|
||||||
|
thresholds.tree,
|
||||||
|
thresholds.snow,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Command::ImportHeightmap { path } => {
|
Command::ImportHeightmap { path } => {
|
||||||
grid = Some(load_heightmap(&resolve(base_dir, path))?);
|
grid = Some(load_heightmap(&resolve(base_dir, path))?);
|
||||||
|
|||||||
Reference in New Issue
Block a user