initial commit for standalone rust program
This commit is contained in:
+6
-165
@@ -1,167 +1,8 @@
|
|||||||
# ---> Python
|
|
||||||
# Byte-compiled / optimized / DLL files
|
|
||||||
__pycache__/
|
|
||||||
*.py[cod]
|
|
||||||
*$py.class
|
|
||||||
|
|
||||||
# C extensions
|
|
||||||
*.so
|
|
||||||
|
|
||||||
# Distribution / packaging
|
|
||||||
.Python
|
|
||||||
build/
|
|
||||||
develop-eggs/
|
|
||||||
dist/
|
|
||||||
downloads/
|
|
||||||
eggs/
|
|
||||||
.eggs/
|
|
||||||
lib/
|
|
||||||
lib64/
|
|
||||||
parts/
|
|
||||||
sdist/
|
|
||||||
var/
|
|
||||||
wheels/
|
|
||||||
share/python-wheels/
|
|
||||||
*.egg-info/
|
|
||||||
.installed.cfg
|
|
||||||
*.egg
|
|
||||||
MANIFEST
|
|
||||||
|
|
||||||
# PyInstaller
|
|
||||||
# Usually these files are written by a python script from a template
|
|
||||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
||||||
*.manifest
|
|
||||||
*.spec
|
|
||||||
|
|
||||||
# Installer logs
|
|
||||||
pip-log.txt
|
|
||||||
pip-delete-this-directory.txt
|
|
||||||
|
|
||||||
# Unit test / coverage reports
|
|
||||||
htmlcov/
|
|
||||||
.tox/
|
|
||||||
.nox/
|
|
||||||
.coverage
|
|
||||||
.coverage.*
|
|
||||||
.cache
|
|
||||||
nosetests.xml
|
|
||||||
coverage.xml
|
|
||||||
*.cover
|
|
||||||
*.py,cover
|
|
||||||
.hypothesis/
|
|
||||||
.pytest_cache/
|
|
||||||
cover/
|
|
||||||
|
|
||||||
# Translations
|
|
||||||
*.mo
|
|
||||||
*.pot
|
|
||||||
|
|
||||||
# Django stuff:
|
|
||||||
*.log
|
|
||||||
local_settings.py
|
|
||||||
db.sqlite3
|
|
||||||
db.sqlite3-journal
|
|
||||||
|
|
||||||
# Flask stuff:
|
|
||||||
instance/
|
|
||||||
.webassets-cache
|
|
||||||
|
|
||||||
# Scrapy stuff:
|
|
||||||
.scrapy
|
|
||||||
|
|
||||||
# Sphinx documentation
|
|
||||||
docs/_build/
|
|
||||||
|
|
||||||
# PyBuilder
|
|
||||||
.pybuilder/
|
|
||||||
target/
|
|
||||||
|
|
||||||
# Jupyter Notebook
|
|
||||||
.ipynb_checkpoints
|
|
||||||
|
|
||||||
# IPython
|
|
||||||
profile_default/
|
|
||||||
ipython_config.py
|
|
||||||
|
|
||||||
# pyenv
|
|
||||||
# For a library or package, you might want to ignore these files since the code is
|
|
||||||
# intended to run in multiple environments; otherwise, check them in:
|
|
||||||
# .python-version
|
|
||||||
|
|
||||||
# pipenv
|
|
||||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
||||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
||||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
||||||
# install all needed dependencies.
|
|
||||||
#Pipfile.lock
|
|
||||||
|
|
||||||
# poetry
|
|
||||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
||||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
||||||
# commonly ignored for libraries.
|
|
||||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
||||||
#poetry.lock
|
|
||||||
|
|
||||||
# pdm
|
|
||||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
||||||
#pdm.lock
|
|
||||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
|
||||||
# in version control.
|
|
||||||
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
|
||||||
.pdm.toml
|
|
||||||
.pdm-python
|
|
||||||
.pdm-build/
|
|
||||||
|
|
||||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
||||||
__pypackages__/
|
|
||||||
|
|
||||||
# Celery stuff
|
|
||||||
celerybeat-schedule
|
|
||||||
celerybeat.pid
|
|
||||||
|
|
||||||
# SageMath parsed files
|
|
||||||
*.sage.py
|
|
||||||
|
|
||||||
# Environments
|
|
||||||
.env
|
|
||||||
.venv
|
|
||||||
env/
|
|
||||||
venv/
|
|
||||||
ENV/
|
|
||||||
env.bak/
|
|
||||||
venv.bak/
|
|
||||||
|
|
||||||
# Spyder project settings
|
|
||||||
.spyderproject
|
|
||||||
.spyproject
|
|
||||||
|
|
||||||
# Rope project settings
|
|
||||||
.ropeproject
|
|
||||||
|
|
||||||
# mkdocs documentation
|
|
||||||
/site
|
|
||||||
|
|
||||||
# mypy
|
|
||||||
.mypy_cache/
|
|
||||||
.dmypy.json
|
|
||||||
dmypy.json
|
|
||||||
|
|
||||||
# Pyre type checker
|
|
||||||
.pyre/
|
|
||||||
|
|
||||||
# pytype static type analyzer
|
|
||||||
.pytype/
|
|
||||||
|
|
||||||
# Cython debug symbols
|
|
||||||
cython_debug/
|
|
||||||
|
|
||||||
#Plans and tests
|
#Plans and tests
|
||||||
plan/
|
/plan/
|
||||||
tests/
|
/tests/
|
||||||
|
|
||||||
# PyCharm
|
# Added by cargo
|
||||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
||||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
/target/
|
||||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
/binaries/
|
||||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
||||||
#.idea/
|
|
||||||
|
|||||||
Generated
+4431
File diff suppressed because it is too large
Load Diff
+11
@@ -0,0 +1,11 @@
|
|||||||
|
[package]
|
||||||
|
name = "advanced_erosion_terrain_heightmap_generation"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
eframe = "0.33"
|
||||||
|
egui = "0.33"
|
||||||
|
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||||
|
rand = "0.9"
|
||||||
|
rayon = "1.10"
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# advanced_erosion_terrain_heightmap_generation
|
# advanced_erosion_terrain_heightmap_generation
|
||||||
|
|
||||||
A krita plugin designed to implement Runevision's (Skovbo Johansen) fast erosion filter on top of noise for realistic looking terrain.
|
A standalone Rust terrain generator using `egui`, inspired by Runevision's fast erosion filter and the reference material in `plan/`.
|
||||||
|
|
||||||
See here:
|
See here:
|
||||||
|
|
||||||
@@ -8,23 +8,23 @@ https://www.shadertoy.com/view/wXcfWn
|
|||||||
https://www.shadertoy.com/view/sf23W1
|
https://www.shadertoy.com/view/sf23W1
|
||||||
https://blog.runevision.com/2026/03/fast-and-gorgeous-erosion-filter.html
|
https://blog.runevision.com/2026/03/fast-and-gorgeous-erosion-filter.html
|
||||||
|
|
||||||
## Plugin layout
|
## Project layout
|
||||||
|
|
||||||
The production Krita plugin lives under `pykrita/advanced_erosion_generator/`.
|
- `src/` contains the standalone Rust app and generator implementation.
|
||||||
The `tests/` directory remains prototype-only reference code and is not imported by the plugin.
|
- `tests/erosion_prototype.py` is the Python reference prototype used to guide the Rust port.
|
||||||
|
- `plan/` contains the inspiration and reference documents for the terrain look.
|
||||||
|
|
||||||
## Installing in Krita
|
## Running
|
||||||
|
|
||||||
Copy `pykrita/advanced_erosion_generator.desktop` and the `pykrita/advanced_erosion_generator/` folder into Krita's `pykrita` directory.
|
```bash
|
||||||
The current plugin implementation is pure Python and does not depend on `numpy` or the test virtual environment.
|
cargo run
|
||||||
|
```
|
||||||
|
|
||||||
## Current UI
|
## Current UI
|
||||||
|
|
||||||
The plugin adds `Tools > Scripts > Generate Advanced Erosion Terrain` and uses the active document resolution as the terrain output size.
|
The app uses an `egui` desktop interface with:
|
||||||
|
|
||||||
Controls:
|
- A left control panel with `Generation` and `Advanced` tabs
|
||||||
|
- Generation controls for image width, image height, generation scale, erosion scale, and random seed
|
||||||
- Generation Scale
|
- Advanced controls for strength, gully weight, detail, and octaves
|
||||||
- Erosion Scale
|
- A right preview area showing both the normalized heightmap and the shaded bumpmap
|
||||||
- Random Seed with a randomize button
|
|
||||||
- Advanced dropdown with Strength, Gully Weight, Detail, and Octaves
|
|
||||||
|
|||||||
+331
@@ -0,0 +1,331 @@
|
|||||||
|
use std::sync::mpsc::{self, Receiver, TryRecvError};
|
||||||
|
use std::thread;
|
||||||
|
|
||||||
|
use eframe::egui::{
|
||||||
|
self, Color32, ColorImage, Context, DragValue, RichText, Sense, Slider, TextureHandle,
|
||||||
|
TextureOptions, Ui, Vec2,
|
||||||
|
};
|
||||||
|
use rand::Rng;
|
||||||
|
|
||||||
|
use crate::generator::{self, GeneratedImages, GenerationParams};
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Eq, PartialEq)]
|
||||||
|
enum LeftTab {
|
||||||
|
Generation,
|
||||||
|
Advanced,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Eq, PartialEq)]
|
||||||
|
enum PreviewFocus {
|
||||||
|
Heightmap,
|
||||||
|
Bumpmap,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TexturePair {
|
||||||
|
heightmap: TextureHandle,
|
||||||
|
bumpmap: TextureHandle,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TerrainApp {
|
||||||
|
params: GenerationParams,
|
||||||
|
generation_tab: LeftTab,
|
||||||
|
preview_focus: PreviewFocus,
|
||||||
|
textures: Option<TexturePair>,
|
||||||
|
generated: Option<GeneratedImages>,
|
||||||
|
generation_receiver: Option<Receiver<Result<GeneratedImages, String>>>,
|
||||||
|
generation_in_progress: bool,
|
||||||
|
status_message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TerrainApp {
|
||||||
|
pub fn new(_cc: &eframe::CreationContext<'_>) -> Self {
|
||||||
|
Self {
|
||||||
|
params: GenerationParams::default(),
|
||||||
|
generation_tab: LeftTab::Generation,
|
||||||
|
preview_focus: PreviewFocus::Heightmap,
|
||||||
|
textures: None,
|
||||||
|
generated: None,
|
||||||
|
generation_receiver: None,
|
||||||
|
generation_in_progress: false,
|
||||||
|
status_message: String::from("Adjust parameters and click Generate."),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_generation(&mut self) {
|
||||||
|
if self.generation_in_progress {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let params = self.params.clone();
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
self.generation_receiver = Some(rx);
|
||||||
|
self.generation_in_progress = true;
|
||||||
|
self.status_message = format!(
|
||||||
|
"Generating {}x{} terrain...",
|
||||||
|
params.image_width, params.image_height
|
||||||
|
);
|
||||||
|
|
||||||
|
thread::spawn(move || {
|
||||||
|
let result = std::panic::catch_unwind(|| generator::generate(¶ms))
|
||||||
|
.map_err(|_| String::from("Generation panicked unexpectedly."));
|
||||||
|
let _ = tx.send(result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn poll_generation(&mut self, ctx: &Context) {
|
||||||
|
let Some(receiver) = &self.generation_receiver else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
match receiver.try_recv() {
|
||||||
|
Ok(Ok(images)) => {
|
||||||
|
self.status_message = format!(
|
||||||
|
"Generated {}x{} heightmap and bumpmap.",
|
||||||
|
images.width, images.height
|
||||||
|
);
|
||||||
|
self.install_textures(ctx, &images);
|
||||||
|
self.generated = Some(images);
|
||||||
|
self.generation_receiver = None;
|
||||||
|
self.generation_in_progress = false;
|
||||||
|
}
|
||||||
|
Ok(Err(error)) => {
|
||||||
|
self.status_message = error;
|
||||||
|
self.generation_receiver = None;
|
||||||
|
self.generation_in_progress = false;
|
||||||
|
}
|
||||||
|
Err(TryRecvError::Empty) => {
|
||||||
|
ctx.request_repaint();
|
||||||
|
}
|
||||||
|
Err(TryRecvError::Disconnected) => {
|
||||||
|
self.status_message = String::from("Generation worker disconnected.");
|
||||||
|
self.generation_receiver = None;
|
||||||
|
self.generation_in_progress = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
self.textures = Some(TexturePair {
|
||||||
|
heightmap: ctx.load_texture("heightmap_preview", heightmap, TextureOptions::default()),
|
||||||
|
bumpmap: ctx.load_texture("bumpmap_preview", bumpmap, TextureOptions::default()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tab_button(ui: &mut Ui, current: &mut LeftTab, tab: LeftTab, label: &str) {
|
||||||
|
let selected = *current == tab;
|
||||||
|
if ui.selectable_label(selected, label).clicked() {
|
||||||
|
*current = tab;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ui_left_panel(&mut self, ctx: &Context) {
|
||||||
|
egui::SidePanel::left("controls_panel")
|
||||||
|
.resizable(true)
|
||||||
|
.default_width(320.0)
|
||||||
|
.min_width(280.0)
|
||||||
|
.show(ctx, |ui| {
|
||||||
|
ui.heading("Terrain Generator");
|
||||||
|
ui.add_space(8.0);
|
||||||
|
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
Self::tab_button(ui, &mut self.generation_tab, LeftTab::Generation, "Generation");
|
||||||
|
Self::tab_button(ui, &mut self.generation_tab, LeftTab::Advanced, "Advanced");
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.separator();
|
||||||
|
|
||||||
|
match self.generation_tab {
|
||||||
|
LeftTab::Generation => self.ui_generation_tab(ui),
|
||||||
|
LeftTab::Advanced => self.ui_advanced_tab(ui),
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.add_space(12.0);
|
||||||
|
let button = ui.add_sized(
|
||||||
|
[ui.available_width(), 56.0],
|
||||||
|
egui::Button::new(RichText::new("Generate").size(20.0)),
|
||||||
|
);
|
||||||
|
if button.clicked() {
|
||||||
|
self.start_generation();
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.generation_in_progress {
|
||||||
|
ui.add_space(8.0);
|
||||||
|
ui.spinner();
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.add_space(8.0);
|
||||||
|
ui.label(&self.status_message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ui_generation_tab(&mut self, ui: &mut Ui) {
|
||||||
|
labeled_int_slider(ui, "Image Width", &mut self.params.image_width, 1..=5000);
|
||||||
|
labeled_int_slider(ui, "Image Height", &mut self.params.image_height, 1..=5000);
|
||||||
|
labeled_float_slider(
|
||||||
|
ui,
|
||||||
|
"Generation Scale",
|
||||||
|
&mut self.params.generation_scale,
|
||||||
|
0.1..=50.0,
|
||||||
|
0.1,
|
||||||
|
);
|
||||||
|
labeled_float_slider(
|
||||||
|
ui,
|
||||||
|
"Erosion Scale",
|
||||||
|
&mut self.params.erosion_scale,
|
||||||
|
0.08..=0.25,
|
||||||
|
0.01,
|
||||||
|
);
|
||||||
|
|
||||||
|
ui.label("Random Seed");
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.add(
|
||||||
|
DragValue::new(&mut self.params.random_seed)
|
||||||
|
.range(0..=u64::MAX)
|
||||||
|
.speed(1.0),
|
||||||
|
);
|
||||||
|
|
||||||
|
if ui.button("Random").clicked() {
|
||||||
|
self.params.random_seed = rand::rng().random::<u64>();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ui_advanced_tab(&mut self, ui: &mut Ui) {
|
||||||
|
labeled_float_slider(
|
||||||
|
ui,
|
||||||
|
"Strength",
|
||||||
|
&mut self.params.erosion_strength,
|
||||||
|
0.01..=0.10,
|
||||||
|
0.01,
|
||||||
|
);
|
||||||
|
labeled_float_slider(
|
||||||
|
ui,
|
||||||
|
"Gully Weight",
|
||||||
|
&mut self.params.erosion_gully_weight,
|
||||||
|
0.0..=1.0,
|
||||||
|
0.01,
|
||||||
|
);
|
||||||
|
labeled_float_slider(
|
||||||
|
ui,
|
||||||
|
"Detail",
|
||||||
|
&mut self.params.erosion_detail,
|
||||||
|
0.7..=3.0,
|
||||||
|
0.1,
|
||||||
|
);
|
||||||
|
labeled_int_slider(ui, "Octaves", &mut self.params.erosion_octaves, 1..=8);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ui_right_panel(&mut self, ctx: &Context) {
|
||||||
|
egui::CentralPanel::default().show(ctx, |ui| {
|
||||||
|
let Some(textures) = &self.textures else {
|
||||||
|
ui.centered_and_justified(|ui| {
|
||||||
|
ui.label("No generated images yet.");
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let available = ui.available_size();
|
||||||
|
let bottom_strip_height = (available.y * 0.24).max(120.0).min(220.0);
|
||||||
|
let main_height = (available.y - bottom_strip_height - 8.0).max(100.0);
|
||||||
|
|
||||||
|
let (main_texture, bottom_texture) = match self.preview_focus {
|
||||||
|
PreviewFocus::Heightmap => (&textures.heightmap, &textures.bumpmap),
|
||||||
|
PreviewFocus::Bumpmap => (&textures.bumpmap, &textures.heightmap),
|
||||||
|
};
|
||||||
|
|
||||||
|
render_clickable_image(ui, main_texture, Vec2::new(available.x, main_height), false);
|
||||||
|
ui.add_space(8.0);
|
||||||
|
|
||||||
|
ui.horizontal_centered(|ui| {
|
||||||
|
let thumb_width = ((available.x - 24.0) * 0.28).max(100.0).min(220.0);
|
||||||
|
let spacer = (available.x - thumb_width * 2.0).max(0.0);
|
||||||
|
|
||||||
|
let main_thumb_clicked = render_clickable_image(
|
||||||
|
ui,
|
||||||
|
match self.preview_focus {
|
||||||
|
PreviewFocus::Heightmap => &textures.heightmap,
|
||||||
|
PreviewFocus::Bumpmap => &textures.bumpmap,
|
||||||
|
},
|
||||||
|
Vec2::new(thumb_width, bottom_strip_height),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
ui.add_space(spacer);
|
||||||
|
|
||||||
|
let secondary_clicked =
|
||||||
|
render_clickable_image(ui, bottom_texture, Vec2::new(thumb_width, bottom_strip_height), true);
|
||||||
|
|
||||||
|
if main_thumb_clicked {
|
||||||
|
self.preview_focus = match self.preview_focus {
|
||||||
|
PreviewFocus::Heightmap => PreviewFocus::Heightmap,
|
||||||
|
PreviewFocus::Bumpmap => PreviewFocus::Bumpmap,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if secondary_clicked {
|
||||||
|
self.preview_focus = match self.preview_focus {
|
||||||
|
PreviewFocus::Heightmap => PreviewFocus::Bumpmap,
|
||||||
|
PreviewFocus::Bumpmap => PreviewFocus::Heightmap,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl eframe::App for TerrainApp {
|
||||||
|
fn update(&mut self, ctx: &Context, _frame: &mut eframe::Frame) {
|
||||||
|
self.poll_generation(ctx);
|
||||||
|
self.ui_left_panel(ctx);
|
||||||
|
self.ui_right_panel(ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gray_to_color_image(image: &image::GrayImage) -> 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 value = pixel.0[0];
|
||||||
|
pixels.push(Color32::from_gray(value));
|
||||||
|
}
|
||||||
|
ColorImage::new(size, pixels)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn labeled_int_slider(ui: &mut Ui, label: &str, value: &mut usize, range: std::ops::RangeInclusive<usize>) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn labeled_float_slider(
|
||||||
|
ui: &mut Ui,
|
||||||
|
label: &str,
|
||||||
|
value: &mut f32,
|
||||||
|
range: std::ops::RangeInclusive<f32>,
|
||||||
|
step: f32,
|
||||||
|
) {
|
||||||
|
ui.label(label);
|
||||||
|
let slider = Slider::new(value, range).step_by(step as f64);
|
||||||
|
ui.add(slider);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_clickable_image(ui: &mut Ui, texture: &TextureHandle, max_size: Vec2, clickable: bool) -> bool {
|
||||||
|
let image_size = texture.size_vec2();
|
||||||
|
let scale = (max_size.x / image_size.x).min(max_size.y / image_size.y);
|
||||||
|
let desired_size = image_size * scale.max(0.01);
|
||||||
|
let image = egui::Image::new(texture)
|
||||||
|
.fit_to_exact_size(desired_size)
|
||||||
|
.sense(if clickable { Sense::click() } else { Sense::hover() });
|
||||||
|
ui.add(image).clicked()
|
||||||
|
}
|
||||||
@@ -0,0 +1,432 @@
|
|||||||
|
use image::{GrayImage, Luma};
|
||||||
|
use rayon::prelude::*;
|
||||||
|
|
||||||
|
const TAU: f32 = 6.283_185_5;
|
||||||
|
const EROSION_LACUNARITY: f32 = 2.0;
|
||||||
|
const EROSION_GAIN: f32 = 0.5;
|
||||||
|
const EROSION_CELL_SCALE: f32 = 0.7;
|
||||||
|
const EROSION_NORMALIZATION: f32 = 0.5;
|
||||||
|
const EROSION_ROUNDING: [f32; 4] = [0.1, 0.0, 0.1, 2.0];
|
||||||
|
const EROSION_ONSET: [f32; 4] = [1.25, 1.25, 2.8, 1.5];
|
||||||
|
const EROSION_ASSUMED_SLOPE: [f32; 2] = [0.7, 1.0];
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct GenerationParams {
|
||||||
|
pub image_width: usize,
|
||||||
|
pub image_height: usize,
|
||||||
|
pub generation_scale: f32,
|
||||||
|
pub erosion_scale: f32,
|
||||||
|
pub random_seed: u64,
|
||||||
|
pub erosion_strength: f32,
|
||||||
|
pub erosion_gully_weight: f32,
|
||||||
|
pub erosion_detail: f32,
|
||||||
|
pub erosion_octaves: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for GenerationParams {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
image_width: 512,
|
||||||
|
image_height: 512,
|
||||||
|
generation_scale: 6.0,
|
||||||
|
erosion_scale: 0.15,
|
||||||
|
random_seed: 0,
|
||||||
|
erosion_strength: 0.05,
|
||||||
|
erosion_gully_weight: 0.5,
|
||||||
|
erosion_detail: 1.5,
|
||||||
|
erosion_octaves: 5,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct GeneratedImages {
|
||||||
|
pub width: usize,
|
||||||
|
pub height: usize,
|
||||||
|
pub heightmap_image: GrayImage,
|
||||||
|
pub bumpmap_image: GrayImage,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, Default)]
|
||||||
|
struct Vec2 {
|
||||||
|
x: f32,
|
||||||
|
y: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Vec2 {
|
||||||
|
fn new(x: f32, y: f32) -> Self {
|
||||||
|
Self { x, y }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dot(self, other: Self) -> f32 {
|
||||||
|
self.x * other.x + self.y * other.y
|
||||||
|
}
|
||||||
|
|
||||||
|
fn length(self) -> f32 {
|
||||||
|
self.dot(self).sqrt()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_safe(self) -> Self {
|
||||||
|
let length = self.length();
|
||||||
|
if length > 1e-10 {
|
||||||
|
Self::new(self.x / length, self.y / length)
|
||||||
|
} else {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::ops::Add for Vec2 {
|
||||||
|
type Output = Self;
|
||||||
|
|
||||||
|
fn add(self, rhs: Self) -> Self::Output {
|
||||||
|
Self::new(self.x + rhs.x, self.y + rhs.y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::ops::AddAssign for Vec2 {
|
||||||
|
fn add_assign(&mut self, rhs: Self) {
|
||||||
|
self.x += rhs.x;
|
||||||
|
self.y += rhs.y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::ops::Sub for Vec2 {
|
||||||
|
type Output = Self;
|
||||||
|
|
||||||
|
fn sub(self, rhs: Self) -> Self::Output {
|
||||||
|
Self::new(self.x - rhs.x, self.y - rhs.y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::ops::Mul<f32> for Vec2 {
|
||||||
|
type Output = Self;
|
||||||
|
|
||||||
|
fn mul(self, rhs: f32) -> Self::Output {
|
||||||
|
Self::new(self.x * rhs, self.y * rhs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mix(a: f32, b: f32, t: f32) -> f32 {
|
||||||
|
a + (b - a) * t
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mix2(a: Vec2, b: Vec2, t: f32) -> Vec2 {
|
||||||
|
Vec2::new(mix(a.x, b.x, t), mix(a.y, b.y, t))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clamp(x: f32, lo: f32, hi: f32) -> f32 {
|
||||||
|
x.clamp(lo, hi)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clamp01(x: f32) -> f32 {
|
||||||
|
clamp(x, 0.0, 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hash22(p: Vec2, random_seed: u64) -> Vec2 {
|
||||||
|
let seed = random_seed as f64;
|
||||||
|
let seed_x = seed * 0.754_877_666_246_692_7;
|
||||||
|
let seed_y = seed * 0.569_840_290_998_053_2;
|
||||||
|
let kx = 0.318_309_9_f64;
|
||||||
|
let ky = 0.367_879_4_f64;
|
||||||
|
|
||||||
|
let px = ((p.x as f64) + seed_x) * kx + ky;
|
||||||
|
let py = ((p.y as f64) + seed_y) * ky + kx;
|
||||||
|
let val = px * py * (px + py);
|
||||||
|
let f_val = val - val.floor();
|
||||||
|
|
||||||
|
let rx = -1.0 + 2.0 * ((16.0 * kx * f_val) - (16.0 * kx * f_val).floor());
|
||||||
|
let ry = -1.0 + 2.0 * ((16.0 * ky * f_val) - (16.0 * ky * f_val).floor());
|
||||||
|
Vec2::new(rx as f32, ry as f32)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn noised(p: Vec2, random_seed: u64) -> (f32, Vec2) {
|
||||||
|
let i = Vec2::new(p.x.floor(), p.y.floor());
|
||||||
|
let f = Vec2::new(p.x - i.x, p.y - i.y);
|
||||||
|
|
||||||
|
let u = Vec2::new(
|
||||||
|
f.x * f.x * f.x * (f.x * (f.x * 6.0 - 15.0) + 10.0),
|
||||||
|
f.y * f.y * f.y * (f.y * (f.y * 6.0 - 15.0) + 10.0),
|
||||||
|
);
|
||||||
|
let du = Vec2::new(
|
||||||
|
30.0 * f.x * f.x * (f.x * (f.x - 2.0) + 1.0),
|
||||||
|
30.0 * f.y * f.y * (f.y * (f.y - 2.0) + 1.0),
|
||||||
|
);
|
||||||
|
|
||||||
|
let ga = hash22(i + Vec2::new(0.0, 0.0), random_seed);
|
||||||
|
let gb = hash22(i + Vec2::new(1.0, 0.0), random_seed);
|
||||||
|
let gc = hash22(i + Vec2::new(0.0, 1.0), random_seed);
|
||||||
|
let gd = hash22(i + Vec2::new(1.0, 1.0), random_seed);
|
||||||
|
|
||||||
|
let va = ga.dot(f - Vec2::new(0.0, 0.0));
|
||||||
|
let vb = gb.dot(f - Vec2::new(1.0, 0.0));
|
||||||
|
let vc = gc.dot(f - Vec2::new(0.0, 1.0));
|
||||||
|
let vd = gd.dot(f - Vec2::new(1.0, 1.0));
|
||||||
|
|
||||||
|
let val = va + u.x * (vb - va) + u.y * (vc - va) + u.x * u.y * (va - vb - vc + vd);
|
||||||
|
|
||||||
|
let deriv_x = ga.x
|
||||||
|
+ u.x * (gb.x - ga.x)
|
||||||
|
+ u.y * (gc.x - ga.x)
|
||||||
|
+ u.x * u.y * (ga.x - gb.x - gc.x + gd.x)
|
||||||
|
+ du.x * (u.y * (va - vb - vc + vd) + (vb - va));
|
||||||
|
let deriv_y = ga.y
|
||||||
|
+ u.x * (gb.y - ga.y)
|
||||||
|
+ u.y * (gc.y - ga.y)
|
||||||
|
+ u.x * u.y * (ga.y - gb.y - gc.y + gd.y)
|
||||||
|
+ du.y * (u.x * (va - vb - vc + vd) + (vc - va));
|
||||||
|
|
||||||
|
(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) {
|
||||||
|
let mut val = 0.0;
|
||||||
|
let mut deriv = Vec2::default();
|
||||||
|
let mut nf = freq;
|
||||||
|
let mut na = 1.0;
|
||||||
|
for _ in 0..octaves {
|
||||||
|
let (v, d) = noised(p * nf, random_seed);
|
||||||
|
val += v * na;
|
||||||
|
deriv += d * (na * nf);
|
||||||
|
na *= gain;
|
||||||
|
nf *= lacunarity;
|
||||||
|
}
|
||||||
|
(val, deriv)
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
let p_int = Vec2::new(p.x.floor(), p.y.floor());
|
||||||
|
let p_frac = p - p_int;
|
||||||
|
|
||||||
|
let mut phase_dir = Vec2::default();
|
||||||
|
let mut weight_sum = 0.0;
|
||||||
|
|
||||||
|
for i in -1..3 {
|
||||||
|
for j in -1..3 {
|
||||||
|
let grid_offset = Vec2::new(i as f32, j as f32);
|
||||||
|
let grid_point = p_int + grid_offset;
|
||||||
|
let random_offset = hash22(grid_point, random_seed) * 0.5;
|
||||||
|
let vector_from_cell_point = p_frac - grid_offset - random_offset;
|
||||||
|
let sqr_dist = vector_from_cell_point.dot(vector_from_cell_point);
|
||||||
|
|
||||||
|
let mut weight = (-sqr_dist * 2.0).exp();
|
||||||
|
weight = weight.max(0.0) - 0.01111;
|
||||||
|
weight = weight.max(0.0);
|
||||||
|
weight_sum += weight;
|
||||||
|
|
||||||
|
let wave_input = vector_from_cell_point.dot(side_dir) + offset_tau;
|
||||||
|
phase_dir.x += wave_input.cos() * weight;
|
||||||
|
phase_dir.y += wave_input.sin() * weight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if weight_sum < 1e-6 {
|
||||||
|
return (Vec2::default(), side_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
let interpolated = phase_dir * (1.0 / weight_sum);
|
||||||
|
let magnitude = interpolated.dot(interpolated).sqrt().max(1.0 - normalization);
|
||||||
|
(interpolated * (1.0 / magnitude), side_dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pow_inv(t: f32, power: f32) -> f32 {
|
||||||
|
1.0 - (1.0 - clamp01(t)).powf(power)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ease_out(t: f32) -> f32 {
|
||||||
|
let v = 1.0 - clamp01(t);
|
||||||
|
1.0 - v * v
|
||||||
|
}
|
||||||
|
|
||||||
|
fn smooth_start(t: f32, smoothing: f32) -> f32 {
|
||||||
|
if t >= smoothing {
|
||||||
|
t - 0.5 * smoothing
|
||||||
|
} else {
|
||||||
|
0.5 * t * t / smoothing
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate(params: &GenerationParams) -> GeneratedImages {
|
||||||
|
let width = params.image_width;
|
||||||
|
let height = params.image_height;
|
||||||
|
let scale = params.erosion_scale;
|
||||||
|
let strength_base = params.erosion_strength * scale;
|
||||||
|
|
||||||
|
let mut heightmap = vec![0.0_f32; width * height];
|
||||||
|
heightmap
|
||||||
|
.par_chunks_mut(width)
|
||||||
|
.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 height_freq = 3.0;
|
||||||
|
let height_amp = 0.125;
|
||||||
|
let (val, deriv) = fractal_noise(p, height_freq, 3, 2.0, 0.1, params.random_seed);
|
||||||
|
|
||||||
|
let n_height = val * height_amp + 0.5;
|
||||||
|
let curr_slope = deriv * height_amp;
|
||||||
|
|
||||||
|
let mut fade_target = clamp((n_height - 0.5) / (height_amp * 0.6), -1.0, 1.0);
|
||||||
|
let mut freq = 1.0 / (scale * EROSION_CELL_SCALE);
|
||||||
|
let slope_length = curr_slope.length().max(1e-10);
|
||||||
|
|
||||||
|
let rounding_for_input = mix(
|
||||||
|
EROSION_ROUNDING[1],
|
||||||
|
EROSION_ROUNDING[0],
|
||||||
|
clamp01(fade_target + 0.5),
|
||||||
|
) * EROSION_ROUNDING[2];
|
||||||
|
let mut combi_mask = ease_out(smooth_start(
|
||||||
|
slope_length * EROSION_ONSET[0],
|
||||||
|
rounding_for_input * EROSION_ONSET[0],
|
||||||
|
));
|
||||||
|
|
||||||
|
let mut gully_slope = mix2(
|
||||||
|
curr_slope,
|
||||||
|
curr_slope * (EROSION_ASSUMED_SLOPE[0] / slope_length),
|
||||||
|
EROSION_ASSUMED_SLOPE[1],
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut strength = strength_base;
|
||||||
|
let mut rounding_mult = 1.0;
|
||||||
|
let mut total_h_delta = 0.0;
|
||||||
|
let mut total_strength = 0.0;
|
||||||
|
|
||||||
|
for _ in 0..params.erosion_octaves {
|
||||||
|
let (phacelle_vec, side_dir) = phacelle_noise(
|
||||||
|
p * freq,
|
||||||
|
gully_slope.normalize_safe(),
|
||||||
|
EROSION_CELL_SCALE,
|
||||||
|
0.25,
|
||||||
|
EROSION_NORMALIZATION,
|
||||||
|
params.random_seed,
|
||||||
|
);
|
||||||
|
|
||||||
|
let p_deriv_dir = side_dir * -freq;
|
||||||
|
let sloping = phacelle_vec.y.abs();
|
||||||
|
gully_slope += p_deriv_dir
|
||||||
|
* phacelle_vec.y.signum()
|
||||||
|
* strength
|
||||||
|
* params.erosion_gully_weight;
|
||||||
|
|
||||||
|
let gullies_h = phacelle_vec.x;
|
||||||
|
let faded_gullies_h = mix(
|
||||||
|
fade_target,
|
||||||
|
gullies_h * params.erosion_gully_weight,
|
||||||
|
combi_mask,
|
||||||
|
);
|
||||||
|
total_h_delta += faded_gullies_h * strength;
|
||||||
|
total_strength += strength;
|
||||||
|
fade_target = faded_gullies_h;
|
||||||
|
|
||||||
|
let rounding_for_octave = mix(
|
||||||
|
EROSION_ROUNDING[1],
|
||||||
|
EROSION_ROUNDING[0],
|
||||||
|
clamp01(phacelle_vec.x + 0.5),
|
||||||
|
) * rounding_mult;
|
||||||
|
let new_mask = ease_out(smooth_start(
|
||||||
|
sloping * EROSION_ONSET[1],
|
||||||
|
rounding_for_octave * EROSION_ONSET[1],
|
||||||
|
));
|
||||||
|
combi_mask = pow_inv(combi_mask, params.erosion_detail) * new_mask;
|
||||||
|
strength *= EROSION_GAIN;
|
||||||
|
freq *= EROSION_LACUNARITY;
|
||||||
|
rounding_mult *= EROSION_ROUNDING[3];
|
||||||
|
}
|
||||||
|
|
||||||
|
let terrain_height_offset = [-0.65, 0.0];
|
||||||
|
let final_fade_target = clamp((n_height - 0.5) / (height_amp * 0.6), -1.0, 1.0);
|
||||||
|
let offset = mix(
|
||||||
|
terrain_height_offset[0],
|
||||||
|
-final_fade_target,
|
||||||
|
terrain_height_offset[1],
|
||||||
|
) * total_strength;
|
||||||
|
|
||||||
|
*value = n_height + total_h_delta + offset;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let heightmap_normalized = normalize_values(&heightmap);
|
||||||
|
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);
|
||||||
|
|
||||||
|
GeneratedImages {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
heightmap_image,
|
||||||
|
bumpmap_image,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_values(values: &[f32]) -> Vec<f32> {
|
||||||
|
let mut min_val = f32::INFINITY;
|
||||||
|
let mut max_val = f32::NEG_INFINITY;
|
||||||
|
for value in values {
|
||||||
|
min_val = min_val.min(*value);
|
||||||
|
max_val = max_val.max(*value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (max_val - min_val).abs() < 1e-10 {
|
||||||
|
return vec![0.0; values.len()];
|
||||||
|
}
|
||||||
|
|
||||||
|
values
|
||||||
|
.iter()
|
||||||
|
.map(|value| (value - min_val) / (max_val - min_val))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn grayscale_image_from_values(values: &[f32], width: usize, height: usize) -> GrayImage {
|
||||||
|
let mut image = GrayImage::new(width as u32, height as u32);
|
||||||
|
for y in 0..height {
|
||||||
|
for x in 0..width {
|
||||||
|
let value = values[y * width + x].clamp(0.0, 1.0);
|
||||||
|
image.put_pixel(x as u32, y as u32, Luma([(value * 255.0).round() as u8]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
image
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_shading(heightmap: &[f32], width: usize, height: usize, strength: f32) -> Vec<f32> {
|
||||||
|
let mut output = vec![0.0; width * height];
|
||||||
|
|
||||||
|
let lx = -1.0_f32;
|
||||||
|
let ly = -1.0_f32;
|
||||||
|
let lz = 1.0_f32;
|
||||||
|
let l_len = (lx * lx + ly * ly + lz * lz).sqrt();
|
||||||
|
let (lx, ly, lz) = (lx / l_len, ly / l_len, lz / l_len);
|
||||||
|
|
||||||
|
for y in 0..height {
|
||||||
|
for x in 0..width {
|
||||||
|
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 mut dx = (right - left) * 0.5 * strength;
|
||||||
|
let mut dy = (down - up) * 0.5 * strength;
|
||||||
|
dx *= width as f32;
|
||||||
|
dy *= height as f32;
|
||||||
|
|
||||||
|
let n_len = (dx * dx + dy * dy + 1.0).sqrt();
|
||||||
|
let nx = -dx / n_len;
|
||||||
|
let ny = -dy / n_len;
|
||||||
|
let nz = 1.0 / n_len;
|
||||||
|
let dot = nx * lx + ny * ly + nz * lz;
|
||||||
|
output[y * width + x] = dot.clamp(0.0, 1.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample_height(heightmap: &[f32], width: usize, _height: usize, x: usize, y: usize) -> f32 {
|
||||||
|
heightmap[y * width + x]
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
mod app;
|
||||||
|
mod generator;
|
||||||
|
|
||||||
|
use app::TerrainApp;
|
||||||
|
|
||||||
|
fn main() -> eframe::Result<()> {
|
||||||
|
let options = eframe::NativeOptions {
|
||||||
|
viewport: eframe::egui::ViewportBuilder::default()
|
||||||
|
.with_inner_size([1400.0, 900.0])
|
||||||
|
.with_min_inner_size([1000.0, 720.0])
|
||||||
|
.with_title("Advanced Erosion Terrain Generator"),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
eframe::run_native(
|
||||||
|
"Advanced Erosion Terrain Generator",
|
||||||
|
options,
|
||||||
|
Box::new(|cc| Ok(Box::new(TerrainApp::new(cc)))),
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user