diff --git a/README.md b/README.md index 98116b0..9cc692e 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,29 @@ # 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 krita plugin designed to implement Runevision's (Skovbo Johansen) fast erosion filter on top of noise for realistic looking terrain. + +See here: -See here: - -https://www.shadertoy.com/view/wXcfWn -https://www.shadertoy.com/view/sf23W1 -https://blog.runevision.com/2026/03/fast-and-gorgeous-erosion-filter.html \ No newline at end of file +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 + +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. + +## Installing in Krita + +Copy `pykrita/advanced_erosion_generator.desktop` and the `pykrita/advanced_erosion_generator/` folder into Krita's `pykrita` directory. + +## Current UI + +The plugin adds `Tools > Scripts > Generate Advanced Erosion Terrain` and uses the active document resolution as the terrain output size. + +Controls: + +- Generation Scale +- Erosion Scale +- Random Seed with a randomize button +- Advanced dropdown with Strength, Gully Weight, Detail, and Octaves diff --git a/pykrita/advanced_erosion_generator.desktop b/pykrita/advanced_erosion_generator.desktop new file mode 100644 index 0000000..de0e84d --- /dev/null +++ b/pykrita/advanced_erosion_generator.desktop @@ -0,0 +1,7 @@ +[Desktop Entry] +Type=Service +ServiceTypes=Krita/PythonPlugin +X-KDE-Library=advanced_erosion_generator +X-Python-2-Compatible=false +Name=Advanced Erosion Generator +Comment=Generate erosion-based terrain heightmaps at the active canvas resolution. diff --git a/pykrita/advanced_erosion_generator/__init__.py b/pykrita/advanced_erosion_generator/__init__.py new file mode 100644 index 0000000..e4d58f5 --- /dev/null +++ b/pykrita/advanced_erosion_generator/__init__.py @@ -0,0 +1,8 @@ +from krita import Krita + +from .extension import AdvancedErosionGeneratorExtension + + +app = Krita.instance() +extension = AdvancedErosionGeneratorExtension(parent=app) +app.addExtension(extension) diff --git a/pykrita/advanced_erosion_generator/extension.py b/pykrita/advanced_erosion_generator/extension.py new file mode 100644 index 0000000..abd717c --- /dev/null +++ b/pykrita/advanced_erosion_generator/extension.py @@ -0,0 +1,286 @@ +import random +import traceback + +from krita import Extension, Krita +from PyQt5.QtCore import QByteArray, Qt +from PyQt5.QtWidgets import ( + QApplication, + QDialog, + QDialogButtonBox, + QDoubleSpinBox, + QFormLayout, + QHBoxLayout, + QLabel, + QMessageBox, + QPushButton, + QSlider, + QSpinBox, + QToolButton, + QVBoxLayout, + QWidget, +) + +from .generator import GenerationSettings, generate_heightmap, heightmap_to_pixel_data + + +class FloatSliderInput(QWidget): + def __init__(self, minimum, maximum, step, value, decimals=2, parent=None): + super().__init__(parent) + self._factor = 10 ** decimals + + self.slider = QSlider(Qt.Horizontal, self) + self.slider.setRange(int(round(minimum * self._factor)), int(round(maximum * self._factor))) + self.slider.setSingleStep(max(1, int(round(step * self._factor)))) + self.slider.setPageStep(max(1, int(round(step * self._factor * 5)))) + + self.spin = QDoubleSpinBox(self) + self.spin.setRange(minimum, maximum) + self.spin.setDecimals(decimals) + self.spin.setSingleStep(step) + + layout = QHBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.slider, 1) + layout.addWidget(self.spin) + self.setLayout(layout) + + self.slider.valueChanged.connect(self._sync_from_slider) + self.spin.valueChanged.connect(self._sync_from_spin) + self.set_value(value) + + def _sync_from_slider(self, slider_value): + spin_value = slider_value / self._factor + if abs(self.spin.value() - spin_value) > (0.5 / self._factor): + self.spin.blockSignals(True) + self.spin.setValue(spin_value) + self.spin.blockSignals(False) + + def _sync_from_spin(self, spin_value): + slider_value = int(round(spin_value * self._factor)) + if self.slider.value() != slider_value: + self.slider.blockSignals(True) + self.slider.setValue(slider_value) + self.slider.blockSignals(False) + + def set_value(self, value): + self.spin.setValue(value) + self.slider.setValue(int(round(value * self._factor))) + + def value(self): + return self.spin.value() + + +class IntSliderInput(QWidget): + def __init__(self, minimum, maximum, step, value, parent=None): + super().__init__(parent) + + self.slider = QSlider(Qt.Horizontal, self) + self.slider.setRange(minimum, maximum) + self.slider.setSingleStep(step) + self.slider.setPageStep(max(step, step * 2)) + + self.spin = QSpinBox(self) + self.spin.setRange(minimum, maximum) + self.spin.setSingleStep(step) + + layout = QHBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.slider, 1) + layout.addWidget(self.spin) + self.setLayout(layout) + + self.slider.valueChanged.connect(self._sync_from_slider) + self.spin.valueChanged.connect(self._sync_from_spin) + self.set_value(value) + + def _sync_from_slider(self, slider_value): + if self.spin.value() != slider_value: + self.spin.blockSignals(True) + self.spin.setValue(slider_value) + self.spin.blockSignals(False) + + def _sync_from_spin(self, spin_value): + if self.slider.value() != spin_value: + self.slider.blockSignals(True) + self.slider.setValue(spin_value) + self.slider.blockSignals(False) + + def set_value(self, value): + self.spin.setValue(value) + self.slider.setValue(value) + + def value(self): + return self.spin.value() + + +class CollapsibleSection(QWidget): + def __init__(self, title, parent=None): + super().__init__(parent) + + self.button = QToolButton(self) + self.button.setText(title) + self.button.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) + self.button.setArrowType(Qt.RightArrow) + self.button.setCheckable(True) + self.button.setChecked(False) + + self.content = QWidget(self) + self.content.setVisible(False) + + layout = QVBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.button) + layout.addWidget(self.content) + self.setLayout(layout) + + self.button.toggled.connect(self._toggle) + + def _toggle(self, expanded): + self.button.setArrowType(Qt.DownArrow if expanded else Qt.RightArrow) + self.content.setVisible(expanded) + + def set_content_layout(self, layout): + self.content.setLayout(layout) + + +class TerrainDialog(QDialog): + def __init__(self, document, parent=None): + super().__init__(parent) + self.document = document + + self.setWindowTitle("Advanced Erosion Generator") + self.setModal(True) + self.resize(560, 320) + + main_layout = QVBoxLayout() + + doc_label = QLabel( + f"Output size: {self.document.width()} x {self.document.height()} px", + self, + ) + main_layout.addWidget(doc_label) + + form_layout = QFormLayout() + + self.generation_scale = FloatSliderInput(0.1, 10.0, 0.1, 1.0, decimals=2, parent=self) + self.erosion_scale = FloatSliderInput(0.08, 0.25, 0.01, 0.15, decimals=2, parent=self) + + self.seed_input = QSpinBox(self) + self.seed_input.setRange(0, 2147483647) + self.seed_input.setValue(0) + + seed_row = QWidget(self) + seed_layout = QHBoxLayout() + seed_layout.setContentsMargins(0, 0, 0, 0) + seed_layout.addWidget(self.seed_input, 1) + + self.randomize_seed_button = QPushButton("Randomize", self) + self.randomize_seed_button.clicked.connect(self._randomize_seed) + seed_layout.addWidget(self.randomize_seed_button) + seed_row.setLayout(seed_layout) + + form_layout.addRow("Generation Scale", self.generation_scale) + form_layout.addRow("Erosion Scale", self.erosion_scale) + form_layout.addRow("Random Seed", seed_row) + + main_layout.addLayout(form_layout) + + advanced_section = CollapsibleSection("Advanced", self) + advanced_layout = QFormLayout() + advanced_layout.setContentsMargins(12, 0, 0, 0) + + self.erosion_strength = FloatSliderInput(0.01, 0.10, 0.01, 0.05, decimals=2, parent=self) + self.erosion_gully_weight = FloatSliderInput(0.0, 1.0, 0.01, 0.5, decimals=2, parent=self) + self.erosion_detail = FloatSliderInput(0.7, 3.0, 0.1, 1.5, decimals=2, parent=self) + self.erosion_octaves = IntSliderInput(1, 8, 1, 5, parent=self) + + advanced_layout.addRow("Strength", self.erosion_strength) + advanced_layout.addRow("Gully Weight", self.erosion_gully_weight) + advanced_layout.addRow("Detail", self.erosion_detail) + advanced_layout.addRow("Octaves", self.erosion_octaves) + advanced_section.set_content_layout(advanced_layout) + main_layout.addWidget(advanced_section) + + button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel, self) + button_box.accepted.connect(self.accept) + button_box.rejected.connect(self.reject) + main_layout.addWidget(button_box) + + self.setLayout(main_layout) + + def _randomize_seed(self): + self.seed_input.setValue(random.randint(0, 2147483647)) + + def settings(self): + return GenerationSettings( + generation_scale=self.generation_scale.value(), + erosion_scale=self.erosion_scale.value(), + random_seed=self.seed_input.value(), + erosion_strength=self.erosion_strength.value(), + erosion_gully_weight=self.erosion_gully_weight.value(), + erosion_detail=self.erosion_detail.value(), + erosion_octaves=self.erosion_octaves.value(), + ) + + +class AdvancedErosionGeneratorExtension(Extension): + def __init__(self, parent): + super().__init__(parent) + self.action = None + + def setup(self): + pass + + def createActions(self, window): + self.action = window.createAction( + "advanced_erosion_generator", + "Generate Advanced Erosion Terrain", + "tools/scripts", + ) + self.action.triggered.connect(self.open_dialog) + + def open_dialog(self): + document = Krita.instance().activeDocument() + if document is None: + QMessageBox.warning( + None, + "Advanced Erosion Generator", + "Open or create a document before generating terrain.", + ) + return + + parent_window = None + active_window = Krita.instance().activeWindow() + if active_window is not None: + parent_window = active_window.qwindow() + + dialog = TerrainDialog(document, parent=parent_window) + if dialog.exec_() != QDialog.Accepted: + return + + settings = dialog.settings() + self._generate_into_document(document, settings) + + def _generate_into_document(self, document, settings): + QApplication.setOverrideCursor(Qt.WaitCursor) + try: + width = int(document.width()) + height = int(document.height()) + heightmap = generate_heightmap(width, height, settings) + pixel_data = heightmap_to_pixel_data( + heightmap, document.colorModel(), document.colorDepth() + ) + + layer_name = f"Eroded Terrain {settings.random_seed}" + layer = document.createNode(layer_name, "paintlayer") + document.rootNode().addChildNode(layer, None) + layer.setPixelData(QByteArray(pixel_data), 0, 0, width, height) + document.refreshProjection() + except Exception as exc: + QMessageBox.critical( + None, + "Advanced Erosion Generator", + f"Terrain generation failed:\n{exc}\n\n{traceback.format_exc()}", + ) + finally: + QApplication.restoreOverrideCursor() diff --git a/pykrita/advanced_erosion_generator/generator.py b/pykrita/advanced_erosion_generator/generator.py new file mode 100644 index 0000000..85c3e0c --- /dev/null +++ b/pykrita/advanced_erosion_generator/generator.py @@ -0,0 +1,391 @@ +from dataclasses import dataclass + +import numpy as np + +try: + from numba import njit, prange +except ImportError: + def njit(*args, **kwargs): + if args and callable(args[0]) and len(args) == 1 and not kwargs: + return args[0] + + def decorator(func): + return func + + return decorator + + prange = range + + +TAU = np.float32(6.28318530717959) + +EROSION_LACUNARITY = np.float32(2.0) +EROSION_GAIN = np.float32(0.5) +EROSION_CELL_SCALE = np.float32(0.7) +EROSION_NORMALIZATION = np.float32(0.5) +EROSION_ROUNDING = np.array([0.1, 0.0, 0.1, 2.0], dtype=np.float32) +EROSION_ONSET = np.array([1.25, 1.25, 2.8, 1.5], dtype=np.float32) +EROSION_ASSUMED_SLOPE = np.array([0.7, 1.0], dtype=np.float32) + + +@dataclass(frozen=True) +class GenerationSettings: + generation_scale: float = 1.0 + erosion_scale: float = 0.15 + random_seed: int = 0 + erosion_strength: float = 0.05 + erosion_gully_weight: float = 0.5 + erosion_detail: float = 1.5 + erosion_octaves: int = 5 + + +@njit(inline="always") +def fract(x): + return x - np.floor(x) + + +@njit(inline="always") +def mix(a, b, t): + return a + (b - a) * np.float32(t) + + +@njit(inline="always") +def clamp(x, lo, hi): + return min(max(x, np.float32(lo)), np.float32(hi)) + + +@njit(inline="always") +def clamp01(x): + return clamp(x, 0.0, 1.0) + + +@njit(inline="always") +def hash22(p, random_seed): + seed_x = float(random_seed) * 0.7548776662466927 + seed_y = float(random_seed) * 0.5698402909980532 + kx, ky = 0.3183099, 0.3678794 + px = (float(p[0]) + seed_x) * kx + ky + py = (float(p[1]) + seed_y) * ky + kx + + val = px * py * (px + py) + f_val = val - np.floor(val) + + rx = -1.0 + 2.0 * fract(16.0 * kx * f_val) + ry = -1.0 + 2.0 * fract(16.0 * ky * f_val) + return np.array([rx, ry], dtype=np.float32) + + +@njit(inline="always") +def noised(p, random_seed): + i = np.floor(p) + f = (p - i).astype(np.float32) + + u = f * f * f * (f * (f * np.float32(6.0) - np.float32(15.0)) + np.float32(10.0)) + du = np.float32(30.0) * f * f * (f * (f - np.float32(2.0)) + np.float32(1.0)) + + ga = hash22(i + np.array([0.0, 0.0], dtype=np.float32), random_seed) + gb = hash22(i + np.array([1.0, 0.0], dtype=np.float32), random_seed) + gc = hash22(i + np.array([0.0, 1.0], dtype=np.float32), random_seed) + gd = hash22(i + np.array([1.0, 1.0], dtype=np.float32), random_seed) + + va = np.dot(ga, f - np.array([0.0, 0.0], dtype=np.float32)) + vb = np.dot(gb, f - np.array([1.0, 0.0], dtype=np.float32)) + vc = np.dot(gc, f - np.array([0.0, 1.0], dtype=np.float32)) + vd = np.dot(gd, f - np.array([1.0, 1.0], dtype=np.float32)) + + val = va + u[0] * (vb - va) + u[1] * (vc - va) + u[0] * u[1] * (va - vb - vc + vd) + + deriv_x = ( + ga[0] + + u[0] * (gb[0] - ga[0]) + + u[1] * (gc[0] - ga[0]) + + u[0] * u[1] * (ga[0] - gb[0] - gc[0] + gd[0]) + + du[0] * (u[1] * (va - vb - vc + vd) + (vb - va)) + ) + deriv_y = ( + ga[1] + + u[0] * (gb[1] - ga[1]) + + u[1] * (gc[1] - ga[1]) + + u[0] * u[1] * (ga[1] - gb[1] - gc[1] + gd[1]) + + du[1] * (u[0] * (va - vb - vc + vd) + (vc - va)) + ) + + return val, np.array([deriv_x, deriv_y], dtype=np.float32) + + +@njit(inline="always") +def fractal_noise(p, freq, octaves, lacunarity, gain, random_seed): + val = np.float32(0.0) + deriv = np.zeros(2, dtype=np.float32) + nf = np.float32(freq) + na = np.float32(1.0) + for _ in range(octaves): + v, d = noised(p * nf, random_seed) + val += np.float32(v) * na + deriv += d * na * nf + na *= np.float32(gain) + nf *= np.float32(lacunarity) + return val, deriv + + +@njit(inline="always") +def phacelle_noise(p, norm_dir, freq, offset, normalization, random_seed): + side_dir = np.array([-norm_dir[1], norm_dir[0]], dtype=np.float32) * freq * TAU + offset_tau = np.float32(offset) * TAU + + p_int = np.floor(p) + p_frac = (p - p_int).astype(np.float32) + + phase_dir = np.zeros(2, dtype=np.float32) + weight_sum = np.float32(0.0) + + for i in range(-1, 3): + for j in range(-1, 3): + grid_offset = np.array([float(i), float(j)], dtype=np.float32) + grid_point = p_int + grid_offset + random_offset = hash22(grid_point, random_seed) * np.float32(0.5) + + vector_from_cell_point = p_frac - grid_offset - random_offset + sqr_dist = np.dot(vector_from_cell_point, vector_from_cell_point) + + weight = np.exp(-sqr_dist * np.float32(2.0)) + weight = max(np.float32(0.0), weight - np.float32(0.01111)) + weight_sum += weight + + wave_input = np.dot(vector_from_cell_point, side_dir) + offset_tau + phase_dir[0] += np.cos(wave_input) * weight + phase_dir[1] += np.sin(wave_input) * weight + + if weight_sum < 1e-6: + return np.array([0.0, 0.0], dtype=np.float32), side_dir + + interpolated = phase_dir / weight_sum + magnitude = np.sqrt(np.dot(interpolated, interpolated)) + magnitude = max(np.float32(1.0) - np.float32(normalization), magnitude) + + return interpolated / magnitude, side_dir + + +@njit(inline="always") +def pow_inv(t, power): + return np.float32(1.0) - np.power(np.float32(1.0) - clamp01(t), np.float32(power)) + + +@njit(inline="always") +def ease_out(t): + v = np.float32(1.0) - clamp01(t) + return np.float32(1.0) - v * v + + +@njit(inline="always") +def smooth_start(t, smoothing): + if t >= smoothing: + return t - np.float32(0.5) * np.float32(smoothing) + return np.float32(0.5) * t * t / np.float32(smoothing) + + +@njit(inline="always") +def safe_normalize(n): + length = np.sqrt(np.dot(n, n)) + if length > 1e-10: + return n / length + return n + + +@njit(parallel=True) +def _run_erosion( + width, + height, + generation_scale, + erosion_scale, + random_seed, + erosion_strength, + erosion_gully_weight, + erosion_detail, + erosion_octaves, +): + output = np.zeros((height, width), dtype=np.float32) + + scale = np.float32(erosion_scale) + strength_base = np.float32(erosion_strength) * scale + + for y in prange(height): + for x in range(width): + p = np.array([x / width, y / height], dtype=np.float32) * np.float32( + generation_scale + ) + + height_freq = np.float32(3.0) + height_amp = np.float32(0.125) + val, deriv = fractal_noise( + p, height_freq, 3, 2.0, 0.1, np.int64(random_seed) + ) + + n_height = val * height_amp + np.float32(0.5) + n_slope = deriv * height_amp + + curr_height = n_height + curr_slope = n_slope + + fade_target = clamp( + (curr_height - np.float32(0.5)) / (height_amp * np.float32(0.6)), + -1.0, + 1.0, + ) + + freq = np.float32(1.0) / (scale * EROSION_CELL_SCALE) + slope_length = max( + np.sqrt(np.dot(curr_slope, curr_slope)), np.float32(1e-10) + ) + + rounding_for_input = ( + mix( + EROSION_ROUNDING[1], + EROSION_ROUNDING[0], + clamp01(fade_target + np.float32(0.5)), + ) + * EROSION_ROUNDING[2] + ) + combi_mask = ease_out( + smooth_start( + slope_length * EROSION_ONSET[0], + rounding_for_input * EROSION_ONSET[0], + ) + ) + + gully_slope = mix( + curr_slope, + curr_slope / slope_length * EROSION_ASSUMED_SLOPE[0], + EROSION_ASSUMED_SLOPE[1], + ) + + strength = strength_base + rounding_mult = np.float32(1.0) + total_h_delta = np.float32(0.0) + total_strength = np.float32(0.0) + + for _ in range(erosion_octaves): + phacelle_vec, side_dir = phacelle_noise( + p * freq, + safe_normalize(gully_slope), + EROSION_CELL_SCALE, + 0.25, + EROSION_NORMALIZATION, + np.int64(random_seed), + ) + p_deriv_dir = side_dir * -freq + sloping = abs(phacelle_vec[1]) + + gully_slope += ( + np.sign(phacelle_vec[1]) + * p_deriv_dir + * strength + * np.float32(erosion_gully_weight) + ) + gullies_h = phacelle_vec[0] + faded_gullies_h = mix( + fade_target, + gullies_h * np.float32(erosion_gully_weight), + combi_mask, + ) + total_h_delta += faded_gullies_h * strength + total_strength += strength + fade_target = faded_gullies_h + + rounding_for_octave = ( + mix( + EROSION_ROUNDING[1], + EROSION_ROUNDING[0], + clamp01(phacelle_vec[0] + np.float32(0.5)), + ) + * rounding_mult + ) + new_mask = ease_out( + smooth_start( + sloping * EROSION_ONSET[1], + rounding_for_octave * EROSION_ONSET[1], + ) + ) + combi_mask = pow_inv(combi_mask, erosion_detail) * new_mask + strength *= EROSION_GAIN + freq *= EROSION_LACUNARITY + rounding_mult *= EROSION_ROUNDING[3] + + terrain_height_offset = np.array([-0.65, 0.0], dtype=np.float32) + final_fade_target = clamp( + (n_height - np.float32(0.5)) / (height_amp * np.float32(0.6)), + -1.0, + 1.0, + ) + offset = ( + mix( + terrain_height_offset[0], + -final_fade_target, + terrain_height_offset[1], + ) + * total_strength + ) + + output[y, x] = n_height + total_h_delta + offset + + return output + + +def generate_heightmap(width, height, settings): + return _run_erosion( + width=int(width), + height=int(height), + generation_scale=np.float32(settings.generation_scale), + erosion_scale=np.float32(settings.erosion_scale), + random_seed=np.int64(settings.random_seed), + erosion_strength=np.float32(settings.erosion_strength), + erosion_gully_weight=np.float32(settings.erosion_gully_weight), + erosion_detail=np.float32(settings.erosion_detail), + erosion_octaves=int(settings.erosion_octaves), + ) + + +def normalize_heightmap(heightmap): + h_min = float(np.min(heightmap)) + h_max = float(np.max(heightmap)) + if abs(h_max - h_min) < 1e-10: + return np.zeros_like(heightmap, dtype=np.float32) + return ((heightmap - h_min) / (h_max - h_min)).astype(np.float32) + + +def heightmap_to_pixel_data(heightmap, color_model, color_depth): + normalized = normalize_heightmap(heightmap) + + if color_depth == "U8": + max_value = 255 + gray = (normalized * max_value).astype(np.uint8) + alpha_value = np.uint8(max_value) + alpha_dtype = np.uint8 + elif color_depth == "U16": + max_value = 65535 + gray = (normalized * max_value).astype(np.uint16) + alpha_value = np.uint16(max_value) + alpha_dtype = np.uint16 + elif color_depth == "F16": + gray = normalized.astype(np.float16) + alpha_value = np.float16(1.0) + alpha_dtype = np.float16 + elif color_depth == "F32": + gray = normalized.astype(np.float32) + alpha_value = np.float32(1.0) + alpha_dtype = np.float32 + else: + raise ValueError(f"Unsupported document color depth: {color_depth}") + + alpha = np.full(gray.shape, alpha_value, dtype=alpha_dtype) + + if color_model == "RGBA": + channels = np.stack((gray, gray, gray, alpha), axis=-1) + elif color_model == "GRAYA": + channels = np.stack((gray, alpha), axis=-1) + else: + raise ValueError( + f"Unsupported document color model: {color_model}. Use an RGBA or grayscale document." + ) + + return channels.tobytes()