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
|
||||
plan/
|
||||
tests/
|
||||
/plan/
|
||||
/tests/
|
||||
|
||||
# PyCharm
|
||||
# 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
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
# Added by cargo
|
||||
|
||||
/target/
|
||||
/binaries/
|
||||
|
||||
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
|
||||
|
||||
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:
|
||||
|
||||
@@ -8,23 +8,23 @@ https://www.shadertoy.com/view/wXcfWn
|
||||
https://www.shadertoy.com/view/sf23W1
|
||||
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/`.
|
||||
The `tests/` directory remains prototype-only reference code and is not imported by the plugin.
|
||||
- `src/` contains the standalone Rust app and generator implementation.
|
||||
- `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.
|
||||
The current plugin implementation is pure Python and does not depend on `numpy` or the test virtual environment.
|
||||
```bash
|
||||
cargo run
|
||||
```
|
||||
|
||||
## 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:
|
||||
|
||||
- Generation Scale
|
||||
- Erosion Scale
|
||||
- Random Seed with a randomize button
|
||||
- Advanced dropdown with Strength, Gully Weight, Detail, and Octaves
|
||||
- A left control panel with `Generation` and `Advanced` tabs
|
||||
- Generation controls for image width, image height, generation scale, erosion scale, and random seed
|
||||
- Advanced controls for strength, gully weight, detail, and octaves
|
||||
- A right preview area showing both the normalized heightmap and the shaded bumpmap
|
||||
|
||||
+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