Refactor structure, implement layers, add Erode/Fill/layer panel

- Restructure folder hierarchy: max 4 .rs per dir using subfolders
  (ops/operations -> brush/ + shaping/, model/ -> world/, io/* -> subtypes, etc.)
- Fix golden_test.rs comment, extract BrushApply helper, add World::with_heightmap
- Implement 6 layer data generators (Caves, River, Frost, Trees, Biome, Resources)
  with noise-based (OpenSimplex) and terrain-derived algorithms
- Fix clippy warnings, dead code cleanup, import path fixes
- GUI: add Erode&Fill tool modes with radio buttons/sliders/apply_tool
- GUI: add Layers panel with 6 checkboxes + CPU viewport overlay rendering
This commit is contained in:
loki5512344 2026-06-24 12:27:55 +02:00
parent 3d3798136d
commit bdd4564240
104 changed files with 5184 additions and 3912 deletions

View file

@ -12,5 +12,7 @@ log.workspace = true
env_logger.workspace = true
egui = "0.31"
eframe = "0.31"
eframe = { version = "0.31", features = ["wgpu"] }
egui-wgpu = "0.31"
wgpu = { version = "24", features = ["vulkan-portability", "webgl"] }
rfd = "0.15"

View file

@ -1,185 +0,0 @@
use std::collections::VecDeque;
use terrafier_core::World;
use terrafier_core::model::terrain::Terrain;
use terrafier_core::ops::operations::Operation;
#[derive(Clone, Copy, PartialEq)]
pub enum ToolMode {
Raise,
Lower,
Smooth,
Flatten,
Paint,
Inspect,
}
impl ToolMode {
pub fn name(&self) -> &'static str {
match self {
ToolMode::Raise => "Raise",
ToolMode::Lower => "Lower",
ToolMode::Smooth => "Smooth",
ToolMode::Flatten => "Flatten",
ToolMode::Paint => "Paint",
ToolMode::Inspect => "Inspect",
}
}
}
pub struct TerrafierApp {
pub world: Option<World>,
pub selected_tile: Option<(i32, i32)>,
pub tool_mode: ToolMode,
pub brush_radius: u32,
pub brush_strength: f64,
pub selected_terrain: Terrain,
pub target_height: i16,
pub smooth_iterations: u32,
pub undo_stack: VecDeque<Box<dyn Operation>>,
pub redo_stack: Vec<Box<dyn Operation>>,
pub brush_local_x: Option<u32>,
pub brush_local_z: Option<u32>,
pub show_heightmap: bool,
pub show_new_world: bool,
pub show_export: bool,
pub status_message: String,
pub world_name: String,
pub world_seed: String,
pub export_path: String,
}
impl TerrafierApp {
pub fn new() -> Self {
Self {
world: None,
selected_tile: None,
tool_mode: ToolMode::Raise,
brush_radius: 16,
brush_strength: 0.5,
selected_terrain: Terrain::Grass,
target_height: 64,
smooth_iterations: 3,
undo_stack: VecDeque::new(),
redo_stack: Vec::new(),
brush_local_x: None,
brush_local_z: None,
show_heightmap: false,
show_new_world: false,
show_export: false,
status_message: "Ready".to_string(),
world_name: String::new(),
world_seed: String::new(),
export_path: String::new(),
}
}
pub fn save_for_undo(&mut self, op: Box<dyn Operation>) {
self.undo_stack.push_back(op);
if self.undo_stack.len() > 50 {
self.undo_stack.pop_front();
}
self.redo_stack.clear();
}
pub fn undo(&mut self) {
if let Some(op) = self.undo_stack.pop_back() {
if let Some(ref mut world) = self.world {
let dim = world.overworld_mut().expect("world has no overworld");
let inv = op.inverse();
if let Err(e) = inv.apply(dim) {
self.status_message = format!("Undo error: {:?}", e);
self.undo_stack.push_back(op);
return;
}
self.redo_stack.push(op);
self.status_message = "Undo".to_string();
}
} else {
self.status_message = "Nothing to undo".to_string();
}
}
pub fn redo(&mut self) {
if let Some(op) = self.redo_stack.pop() {
if let Some(ref mut world) = self.world {
let dim = world.overworld_mut().expect("world has no overworld");
if let Err(e) = op.apply(dim) {
self.status_message = format!("Redo error: {:?}", e);
self.redo_stack.push(op);
return;
}
self.undo_stack.push_back(op);
self.status_message = "Redo".to_string();
}
} else {
self.status_message = "Nothing to redo".to_string();
}
}
}
impl eframe::App for TerrafierApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
egui::TopBottomPanel::top("menu").show(ctx, |ui| {
ui.horizontal(|ui| {
if ui.button("New World").clicked() {
self.show_new_world = true;
}
if ui.button("Open").clicked()
&& let Some(path) = rfd::FileDialog::new().pick_folder()
{
match terrafier_core::io::import::import(&path) {
Ok(world) => {
self.world = Some(world);
self.selected_tile = None;
self.undo_stack.clear();
self.redo_stack.clear();
self.status_message = format!("Opened world from {}", path.display());
}
Err(e) => {
self.status_message = format!("Open error: {}", e);
}
}
}
if ui.button("Export").clicked() {
self.show_export = true;
}
ui.separator();
if ui.button("Undo").clicked() {
self.undo();
}
if ui.button("Redo").clicked() {
self.redo();
}
});
});
egui::SidePanel::left("tools")
.resizable(false)
.default_width(200.0)
.show(ctx, |ui| {
crate::tools::show_tools_panel(ui, self);
});
egui::CentralPanel::default().show(ctx, |ui| {
crate::view::show_viewport(ui, self);
});
egui::TopBottomPanel::bottom("status").show(ctx, |ui| {
ui.horizontal(|ui| {
ui.label(&self.status_message);
if let Some((tx, tz)) = self.selected_tile {
ui.separator();
ui.label(format!("Tile ({}, {})", tx, tz));
}
if self.world.is_some() {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label("World loaded");
});
}
});
});
crate::dialogs::handle_dialogs(self, ctx);
}
}

132
gui/src/app/mod.rs Normal file
View file

@ -0,0 +1,132 @@
use std::collections::VecDeque;
use terrafier_core::World;
use terrafier_core::model::types::Terrain;
use terrafier_core::ops::operations::Operation;
use crate::renderer::GpuRenderer;
mod ops;
mod update;
pub enum GpuStatus {
Enabled,
FallbackCpu(&'static str),
}
#[derive(Clone, Copy, PartialEq)]
pub enum ToolMode {
Raise,
Lower,
Smooth,
Flatten,
Paint,
Erode,
Fill,
Inspect,
}
impl ToolMode {
pub fn name(&self) -> &'static str {
match self {
ToolMode::Raise => "Raise",
ToolMode::Lower => "Lower",
ToolMode::Smooth => "Smooth",
ToolMode::Flatten => "Flatten",
ToolMode::Paint => "Paint",
ToolMode::Erode => "Erode",
ToolMode::Fill => "Fill",
ToolMode::Inspect => "Inspect",
}
}
}
pub const LAYER_CAVES_IDX: usize = 0;
pub const LAYER_RIVER_IDX: usize = 1;
pub const LAYER_FROST_IDX: usize = 2;
pub const LAYER_TREES_IDX: usize = 3;
pub const LAYER_BIOME_IDX: usize = 4;
pub const LAYER_RESOURCES_IDX: usize = 5;
pub const LAYER_NAMES: [&str; 6] = ["Caves", "River", "Frost", "Trees", "Biome", "Resources"];
pub struct TerrafierApp {
pub world: Option<World>,
pub selected_tile: Option<(i32, i32)>,
pub tool_mode: ToolMode,
pub brush_radius: u32,
pub brush_strength: f64,
pub selected_terrain: Terrain,
pub target_height: i16,
pub smooth_iterations: u32,
pub erode_iterations: u32,
pub erode_talus_angle: f64,
pub undo_stack: VecDeque<Box<dyn Operation>>,
pub redo_stack: Vec<Box<dyn Operation>>,
pub brush_local_x: Option<u32>,
pub brush_local_z: Option<u32>,
pub show_heightmap: bool,
pub show_new_world: bool,
pub show_export: bool,
pub status_message: String,
pub world_name: String,
pub world_seed: String,
pub export_path: String,
pub renderer: Option<GpuRenderer>,
pub show_gpu_render: bool,
pub gpu_status: GpuStatus,
pub cpu_texture_handle: Option<egui::TextureHandle>,
pub layer_visible: [bool; 6],
}
impl TerrafierApp {
pub fn new(cc: &eframe::CreationContext) -> Self {
let (renderer, gpu_status) = if let Some(render_state) = &cc.wgpu_render_state {
let device: std::sync::Arc<wgpu::Device> = render_state.device.clone().into();
let queue: std::sync::Arc<wgpu::Queue> = render_state.queue.clone().into();
let adapter: std::sync::Arc<wgpu::Adapter> = render_state.adapter.clone().into();
let mut gpu_renderer = GpuRenderer::new(
device,
queue,
&adapter,
128,
128,
include_str!("../renderer/shaders.wgsl"),
);
let mut renderer_lock = render_state.renderer.write();
gpu_renderer.register_texture(&mut renderer_lock);
(Some(gpu_renderer), GpuStatus::Enabled)
} else {
(None, GpuStatus::FallbackCpu("GPU init failed — no wgpu adapter found"))
};
Self {
world: None,
selected_tile: None,
tool_mode: ToolMode::Raise,
brush_radius: 16,
brush_strength: 0.5,
selected_terrain: Terrain::Grass,
target_height: 64,
smooth_iterations: 3,
erode_iterations: 3,
erode_talus_angle: 0.5,
undo_stack: VecDeque::new(),
redo_stack: Vec::new(),
brush_local_x: None,
brush_local_z: None,
show_heightmap: false,
show_new_world: false,
show_export: false,
status_message: "Ready".to_string(),
world_name: String::new(),
world_seed: String::new(),
export_path: String::new(),
renderer,
show_gpu_render: true,
gpu_status,
cpu_texture_handle: None,
layer_visible: [false; 6],
}
}
}

48
gui/src/app/ops.rs Normal file
View file

@ -0,0 +1,48 @@
use terrafier_core::ops::operations::Operation;
use crate::app::TerrafierApp;
impl TerrafierApp {
pub fn save_for_undo(&mut self, op: Box<dyn Operation>) {
self.undo_stack.push_back(op);
if self.undo_stack.len() > 50 {
self.undo_stack.pop_front();
}
self.redo_stack.clear();
}
pub fn undo(&mut self) {
if let Some(op) = self.undo_stack.pop_back() {
if let Some(ref mut world) = self.world {
let dim = world.overworld_mut().expect("world has no overworld");
let inv = op.inverse();
if let Err(e) = inv.apply(dim) {
self.status_message = format!("Undo error: {:?}", e);
self.undo_stack.push_back(op);
return;
}
self.redo_stack.push(op);
self.status_message = "Undo".to_string();
}
} else {
self.status_message = "Nothing to undo".to_string();
}
}
pub fn redo(&mut self) {
if let Some(op) = self.redo_stack.pop() {
if let Some(ref mut world) = self.world {
let dim = world.overworld_mut().expect("world has no overworld");
if let Err(e) = op.apply(dim) {
self.status_message = format!("Redo error: {:?}", e);
self.redo_stack.push(op);
return;
}
self.undo_stack.push_back(op);
self.status_message = "Redo".to_string();
}
} else {
self.status_message = "Nothing to redo".to_string();
}
}
}

91
gui/src/app/update.rs Normal file
View file

@ -0,0 +1,91 @@
use crate::app::{GpuStatus, TerrafierApp};
impl eframe::App for TerrafierApp {
fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
egui::TopBottomPanel::top("menu").show(ctx, |ui| {
ui.horizontal(|ui| {
if ui.button("New World").clicked() {
self.show_new_world = true;
}
if ui.button("Open").clicked()
&& let Some(path) = rfd::FileDialog::new().pick_folder()
{
match terrafier_core::io::import::import(&path) {
Ok(world) => {
self.world = Some(world);
self.selected_tile = None;
self.undo_stack.clear();
self.redo_stack.clear();
self.status_message = format!("Opened world from {}", path.display());
}
Err(e) => {
self.status_message = format!("Open error: {}", e);
}
}
}
if ui.button("Export").clicked() {
self.show_export = true;
}
ui.separator();
if ui.button("Undo").clicked() {
self.undo();
}
if ui.button("Redo").clicked() {
self.redo();
}
});
});
egui::SidePanel::left("tools")
.resizable(false)
.default_width(200.0)
.show(ctx, |ui| {
crate::view::show_tools_panel(ui, self);
});
egui::CentralPanel::default().show(ctx, |ui| {
crate::view::show_viewport(ui, self);
});
egui::TopBottomPanel::bottom("status").show(ctx, |ui| {
ui.horizontal(|ui| {
ui.label(&self.status_message);
if let Some((tx, tz)) = self.selected_tile {
ui.separator();
ui.label(format!("Tile ({}, {})", tx, tz));
}
if self.world.is_some() {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label("World loaded");
});
}
if matches!(self.gpu_status, GpuStatus::FallbackCpu(_)) {
ui.separator();
ui.label(" CPU fallback");
}
});
});
if let Some(ref mut renderer) = self.renderer
&& let Some(ref world) = self.world
&& let Some(tile) = world.dimensions[0].tiles.values().next()
{
renderer.render(
&tile.heightmap.as_slice().try_into().expect("heightmap size"),
&tile.terrain.as_slice().try_into().expect("terrain size"),
self.show_heightmap,
);
if let Some(render_state) = frame.wgpu_render_state() {
let mut renderer_lock = render_state.renderer.write();
if renderer.texture_id.is_none() {
renderer.register_texture(&mut renderer_lock);
} else {
renderer.update_texture(&mut renderer_lock);
}
}
}
crate::dialogs::handle_dialogs(self, ctx);
}
}

6
gui/src/lib.rs Normal file
View file

@ -0,0 +1,6 @@
mod app;
mod dialogs;
mod renderer;
mod view;
pub use app::TerrafierApp;

View file

@ -1,15 +1,15 @@
fn main() -> Result<(), Box<dyn std::error::Error>> {
fn main() -> Result<(), eframe::Error> {
env_logger::init();
let native_options = eframe::NativeOptions::default();
let native_options = eframe::NativeOptions {
renderer: eframe::Renderer::Wgpu,
viewport: egui::ViewportBuilder::default().with_inner_size([1280.0, 800.0]),
..Default::default()
};
eframe::run_native(
"Terrafier",
native_options,
Box::new(|_cc| Ok(Box::new(app::TerrafierApp::new()))),
)?;
Ok(())
Box::new(|cc| Ok(Box::new(terrafier_gui::TerrafierApp::new(cc)))),
)
}
mod app;
mod dialogs;
mod tools;
mod view;

3
gui/src/renderer/mod.rs Normal file
View file

@ -0,0 +1,3 @@
mod pipeline;
mod render;
pub use pipeline::*;

View file

@ -0,0 +1,140 @@
use std::sync::Arc;
#[repr(C)]
#[derive(Clone, Copy)]
pub struct Uniforms {
pub(crate) min_height: i32,
pub(crate) max_height: i32,
pub(crate) show_heightmap: u32,
pub(crate) tile_size: u32,
}
pub struct GpuRenderer {
pub(crate) device: Arc<wgpu::Device>,
pub(crate) queue: Arc<wgpu::Queue>,
pub(crate) compute_pipeline: wgpu::ComputePipeline,
pub(crate) heightmap_buffer: wgpu::Buffer,
pub(crate) terrain_buffer: wgpu::Buffer,
pub(crate) uniform_buffer: wgpu::Buffer,
pub(crate) output_buffer: wgpu::Buffer,
pub(crate) output_texture: wgpu::Texture,
pub(crate) output_texture_view: wgpu::TextureView,
pub(crate) bind_group: wgpu::BindGroup,
pub texture_id: Option<egui::TextureId>,
pub width: u32,
pub height: u32,
pub(crate) last_heightmap: Vec<i16>,
pub(crate) last_terrain: Vec<u8>,
pub(crate) last_show_heightmap: bool,
pub(crate) needs_upload: bool,
}
impl GpuRenderer {
pub fn new(
device: Arc<wgpu::Device>,
queue: Arc<wgpu::Queue>,
adapter: &wgpu::Adapter,
width: u32,
height: u32,
shader_source: &str,
) -> Self {
let limits = adapter.limits();
let needed_storage: u64 = 65536;
if limits.max_buffer_size < needed_storage {
log::warn!(
"Adapter buffer size limit ({}) < needed ({})",
limits.max_buffer_size,
needed_storage,
);
}
let bind_group_layout =
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("terrain_bind_group_layout"),
entries: &[
wgpu::BindGroupLayoutEntry { binding: 0, visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None }, count: None },
wgpu::BindGroupLayoutEntry { binding: 1, visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Storage { read_only: true }, has_dynamic_offset: false, min_binding_size: None }, count: None },
wgpu::BindGroupLayoutEntry { binding: 2, visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Storage { read_only: true }, has_dynamic_offset: false, min_binding_size: None }, count: None },
wgpu::BindGroupLayoutEntry { binding: 3, visibility: wgpu::ShaderStages::COMPUTE, ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Storage { read_only: false }, has_dynamic_offset: false, min_binding_size: None }, count: None },
],
});
let shader_module =
device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("terrain_shader"),
source: wgpu::ShaderSource::Wgsl(shader_source.into()),
});
let pipeline_layout =
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("terrain_pipeline_layout"),
bind_group_layouts: &[&bind_group_layout],
push_constant_ranges: &[],
});
let compute_pipeline =
device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("terrain_compute_pipeline"),
layout: Some(&pipeline_layout),
module: &shader_module,
entry_point: Some("main"),
compilation_options: wgpu::PipelineCompilationOptions::default(),
cache: None,
});
let heightmap_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("heightmap_buffer"), size: 8192 * 4,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false,
});
let terrain_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("terrain_buffer"), size: 4096 * 4,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false,
});
let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("uniform_buffer"), size: std::mem::size_of::<Uniforms>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, mapped_at_creation: false,
});
let output_size = (width * height * 4) as u64;
let output_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("output_buffer"), size: output_size,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC, mapped_at_creation: false,
});
let output_texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("output_texture"),
size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 },
mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8UnormSrgb,
view_formats: &[wgpu::TextureFormat::Rgba8UnormSrgb],
usage: wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::TEXTURE_BINDING,
});
let output_texture_view = output_texture.create_view(&Default::default());
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("terrain_bind_group"),
layout: &bind_group_layout,
entries: &[
wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { buffer: &uniform_buffer, offset: 0, size: None }) },
wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { buffer: &heightmap_buffer, offset: 0, size: None }) },
wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { buffer: &terrain_buffer, offset: 0, size: None }) },
wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { buffer: &output_buffer, offset: 0, size: None }) },
],
});
Self {
device, queue, compute_pipeline, heightmap_buffer, terrain_buffer,
uniform_buffer, output_buffer, output_texture, output_texture_view, bind_group,
texture_id: None, width, height,
last_heightmap: vec![0i16; 16384],
last_terrain: vec![0u8; 16384],
last_show_heightmap: false,
needs_upload: true,
}
}
pub(crate) fn pack_heightmap(heightmap: &[i16; 16384]) -> Vec<u32> {
heightmap.chunks(2).map(|chunk| {
let low = chunk[0] as u32 & 0xFFFF;
let high = chunk[1] as u32 & 0xFFFF;
low | (high << 16)
}).collect()
}
pub(crate) fn pack_terrain(terrain: &[u8; 16384]) -> Vec<u32> {
terrain.chunks(4).map(|chunk| {
chunk[0] as u32 | (chunk[1] as u32) << 8 | (chunk[2] as u32) << 16 | (chunk[3] as u32) << 24
}).collect()
}
}

123
gui/src/renderer/render.rs Normal file
View file

@ -0,0 +1,123 @@
use crate::renderer::GpuRenderer;
use crate::renderer::Uniforms;
fn as_u8_slice<T>(data: &[T]) -> &[u8] {
unsafe {
std::slice::from_raw_parts(
data.as_ptr() as *const u8,
std::mem::size_of_val(data),
)
}
}
fn as_u8_value<T>(data: &T) -> &[u8] {
unsafe {
std::slice::from_raw_parts(
data as *const T as *const u8,
std::mem::size_of::<T>(),
)
}
}
impl GpuRenderer {
pub fn render(
&mut self,
heightmap: &[i16; 16384],
terrain: &[u8; 16384],
show_heightmap: bool,
) {
if !self.needs_upload
&& heightmap == &self.last_heightmap[..]
&& terrain == &self.last_terrain[..]
&& show_heightmap == self.last_show_heightmap
{
return;
}
self.last_heightmap.copy_from_slice(heightmap);
self.last_terrain.copy_from_slice(terrain);
self.last_show_heightmap = show_heightmap;
self.needs_upload = false;
let min_height = *heightmap.iter().min().unwrap();
let max_height = *heightmap.iter().max().unwrap();
let packed_heightmap = Self::pack_heightmap(heightmap);
self.queue
.write_buffer(&self.heightmap_buffer, 0, as_u8_slice(&packed_heightmap));
let packed_terrain = Self::pack_terrain(terrain);
self.queue
.write_buffer(&self.terrain_buffer, 0, as_u8_slice(&packed_terrain));
let uniforms = Uniforms {
min_height: min_height as i32,
max_height: max_height as i32,
show_heightmap: if show_heightmap { 1 } else { 0 },
tile_size: 128,
};
self.queue
.write_buffer(&self.uniform_buffer, 0, as_u8_value(&uniforms));
let mut encoder = self.device.create_command_encoder(&Default::default());
{
let mut cpass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("terrain_compute_pass"),
timestamp_writes: None,
});
cpass.set_pipeline(&self.compute_pipeline);
cpass.set_bind_group(0, &self.bind_group, &[]);
cpass.dispatch_workgroups(8, 8, 1);
}
let bytes_per_row = self.width * 4;
encoder.copy_buffer_to_texture(
wgpu::TexelCopyBufferInfo {
buffer: &self.output_buffer,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(bytes_per_row),
rows_per_image: Some(self.height),
},
},
wgpu::TexelCopyTextureInfo {
texture: &self.output_texture,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::Extent3d {
width: self.width,
height: self.height,
depth_or_array_layers: 1,
},
);
self.queue.submit(Some(encoder.finish()));
}
pub fn register_texture(&mut self, renderer: &mut egui_wgpu::Renderer) {
let id = renderer.register_native_texture(
&self.device,
&self.output_texture_view,
wgpu::FilterMode::Linear,
);
self.texture_id = Some(id);
}
pub fn update_texture(&mut self, renderer: &mut egui_wgpu::Renderer) {
if let Some(id) = self.texture_id {
renderer.update_egui_texture_from_wgpu_texture(
&self.device,
&self.output_texture_view,
wgpu::FilterMode::Linear,
id,
);
}
}
pub fn mark_dirty(&mut self) {
self.needs_upload = true;
}
}

View file

@ -0,0 +1,114 @@
struct Uniforms {
min_height: i32,
max_height: i32,
show_heightmap: u32,
tile_size: u32,
}
@group(0) @binding(0) var<uniform> uniforms: Uniforms;
@group(0) @binding(1) var<storage, read> heightmap_packed: array<u32>;
@group(0) @binding(2) var<storage, read> terrain_packed: array<u32>;
@group(0) @binding(3) var<storage, read_write> output: array<u32>;
fn unpack_heightmap(data: array<u32>, index: u32) -> i32 {
let packed = data[index / 2u];
var val: i32;
if index % 2u == 0u {
val = i32(packed & 0xFFFFu);
} else {
val = i32(packed >> 16u);
}
if val & 0x8000 != 0 {
val = val | 0xFFFF0000;
}
return val;
}
fn unpack_terrain(data: array<u32>, index: u32) -> u32 {
let packed = data[index / 4u];
let shift = (index % 4u) * 8u;
return (packed >> shift) & 0xFFu;
}
fn terrain_color(id: u32, height_factor: f32) -> vec3<f32> {
var color: vec3<f32>;
switch id {
case 0u: {
color = vec3<f32>(194.0, 178.0, 128.0);
}
case 1u: {
color = vec3<f32>(124.0, 189.0, 107.0);
}
case 2u: {
color = vec3<f32>(86.0, 140.0, 74.0);
}
case 3u: {
color = vec3<f32>(128.0, 128.0, 128.0);
}
case 4u: {
color = vec3<f32>(227.0, 212.0, 160.0);
}
case 5u: {
color = vec3<f32>(72.0, 107.0, 75.0);
}
case 6u: {
color = vec3<f32>(64.0, 128.0, 255.0);
}
default: {
color = vec3<f32>(128.0, 128.0, 128.0);
}
}
return color * height_factor;
}
fn height_color(norm: f32) -> vec3<f32> {
var color: vec3<f32>;
if norm < 0.25 {
let t = norm / 0.25;
color = mix(vec3<f32>(0.0, 0.0, 255.0), vec3<f32>(0.0, 255.0, 255.0), t);
} else if norm < 0.5 {
let t = (norm - 0.25) / 0.25;
color = mix(vec3<f32>(0.0, 255.0, 255.0), vec3<f32>(0.0, 255.0, 0.0), t);
} else if norm < 0.75 {
let t = (norm - 0.5) / 0.25;
color = mix(vec3<f32>(0.0, 255.0, 0.0), vec3<f32>(255.0, 255.0, 0.0), t);
} else {
let t = (norm - 0.75) / 0.25;
color = mix(vec3<f32>(255.0, 255.0, 0.0), vec3<f32>(255.0, 0.0, 0.0), t);
}
return color;
}
@compute @workgroup_size(16, 16, 1)
fn main(@builtin(global_invocation_id) id: vec3<u32>) {
if id.x >= uniforms.tile_size || id.y >= uniforms.tile_size {
return;
}
let index = id.y * uniforms.tile_size + id.x;
let height = unpack_heightmap(heightmap_packed, index);
let terrain_id = unpack_terrain(terrain_packed, index);
let height_factor = 0.7 + 0.3 * ((f32(height) + 64.0) / 384.0);
var color: vec3<f32>;
if uniforms.show_heightmap != 0u {
let height_range = f32(uniforms.max_height - uniforms.min_height);
var norm: f32;
if height_range <= 0.0 {
norm = 0.5;
} else {
norm = (f32(height) - f32(uniforms.min_height)) / height_range;
}
norm = clamp(norm, 0.0, 1.0);
color = height_color(norm);
} else {
color = terrain_color(terrain_id, height_factor);
}
output[index] = (u32(color.r) & 0xFFu) |
((u32(color.g) & 0xFFu) << 8u) |
((u32(color.b) & 0xFFu) << 16u) |
0xFF000000u;
}

100
gui/src/view.rs → gui/src/view/cpu.rs Executable file → Normal file
View file

@ -1,6 +1,58 @@
use egui::{Color32, ColorImage, TextureOptions, Vec2};
use terrafier_core::model::tile::{TILE_SIZE, LayerBuffer};
use terrafier_core::model::layers::{LAYER_CAVES, LAYER_RIVER, LAYER_FROST, LAYER_TREES, LAYER_BIOME, LAYER_RESOURCES};
use crate::app::{LAYER_CAVES_IDX, LAYER_RIVER_IDX, LAYER_FROST_IDX, LAYER_TREES_IDX, LAYER_BIOME_IDX, LAYER_RESOURCES_IDX};
use terrafier_core::model::tile::TILE_SIZE;
const LAYER_IDS: [u32; 6] = [LAYER_CAVES, LAYER_RIVER, LAYER_FROST, LAYER_TREES, LAYER_BIOME, LAYER_RESOURCES];
fn layer_value(tile: &terrafier_core::model::tile::Tile, idx: usize, layer_idx: usize) -> Option<f32> {
let layer_id = LAYER_IDS[layer_idx];
let buf = tile.layer_data.get(&layer_id)?;
let val = match buf {
LayerBuffer::Bit(data) => {
let word = data.get(idx / 64)?;
Some(if (word >> (idx % 64)) & 1 != 0 { 1.0 } else { 0.0 })
}
LayerBuffer::Nibble(data) => {
let byte = *data.get(idx / 2)?;
let nib = if idx % 2 == 0 { byte & 0x0F } else { (byte >> 4) & 0x0F };
Some(nib as f32 / 15.0)
}
LayerBuffer::Byte(data) => {
let v = *data.get(idx)? as f32;
Some(v / 255.0)
}
LayerBuffer::Int(_) => None,
};
val
}
fn layer_overlay(layer_idx: usize, value: f32) -> Option<(u8, u8, u8)> {
if value <= 0.0 {
return None;
}
match layer_idx {
LAYER_CAVES_IDX => Some(((value * 150.0) as u8, 0, 0)),
LAYER_RIVER_IDX => Some((0, 0, (128.0 + value * 127.0) as u8)),
LAYER_FROST_IDX => Some((255, 255, 255)),
LAYER_TREES_IDX => Some((0, (64.0 + value * 191.0) as u8, 0)),
LAYER_BIOME_IDX => {
let h = (value * 255.0) as u8;
Some((h.wrapping_mul(7), h.wrapping_mul(3), h))
}
LAYER_RESOURCES_IDX => Some(((value * 255.0) as u8, (value * 200.0) as u8, 0)),
_ => None,
}
}
fn blend_overlay(base: (u8, u8, u8), overlay: (u8, u8, u8), alpha: f32) -> (u8, u8, u8) {
let a = alpha.clamp(0.0, 1.0);
(
(base.0 as f32 * (1.0 - a) + overlay.0 as f32 * a) as u8,
(base.1 as f32 * (1.0 - a) + overlay.1 as f32 * a) as u8,
(base.2 as f32 * (1.0 - a) + overlay.2 as f32 * a) as u8,
)
}
const TERRAIN_COLORS: [Color32; 7] = [
Color32::from_rgb(194, 178, 128), // Desert
@ -12,13 +64,8 @@ const TERRAIN_COLORS: [Color32; 7] = [
Color32::from_rgb(64, 128, 255), // Water
];
pub fn show_viewport(ui: &mut egui::Ui, app: &mut crate::app::TerrafierApp) {
let Some(world) = &app.world else {
ui.centered_and_justified(|ui| {
ui.heading("No world loaded. Click 'New World' to begin.");
});
return;
};
pub fn render(ui: &mut egui::Ui, app: &mut crate::app::TerrafierApp) {
let Some(ref world) = app.world else { return; };
let dim = &world.dimensions[0];
let mut min_tx = i32::MAX;
@ -54,23 +101,18 @@ pub fn show_viewport(ui: &mut egui::Ui, app: &mut crate::app::TerrafierApp) {
let h = tile.heightmap[idx];
let height_factor = 0.7 + 0.3 * ((h + 64) as f32 / 384.0);
let (r, g, b) = if app.show_heightmap {
// Heat map: map height to color gradient
let (mut r, mut g, mut b) = if app.show_heightmap {
let norm = ((h + 64) as f32 / 384.0).clamp(0.0, 1.0);
if norm < 0.25 {
// Blue (low) -> Cyan
let t = norm / 0.25;
((t * 255.0) as u8, (t * 128.0) as u8, 255)
} else if norm < 0.5 {
// Cyan -> Green
let t = (norm - 0.25) / 0.25;
(0, (128.0 + t * 127.0) as u8, ((1.0 - t) * 255.0) as u8)
} else if norm < 0.75 {
// Green -> Yellow
let t = (norm - 0.5) / 0.25;
((t * 255.0) as u8, 255, 0)
} else {
// Yellow -> Red
let t = (norm - 0.75) / 0.25;
(255, ((1.0 - t) * 255.0) as u8, 0)
}
@ -81,6 +123,17 @@ pub fn show_viewport(ui: &mut egui::Ui, app: &mut crate::app::TerrafierApp) {
(r, g, b)
};
for li in 0..6 {
if app.layer_visible[li] {
if let Some(val) = layer_value(tile, idx, li) {
if let Some(over) = layer_overlay(li, val) {
let alpha = if val > 0.0 { (val * 0.6 + 0.2).min(0.8) } else { 0.0 };
(r, g, b) = blend_overlay((r, g, b), over, alpha);
}
}
}
}
let di = (pz + ly) * grid_w + (px + lx);
img_data[di * 4] = r;
img_data[di * 4 + 1] = g;
@ -91,22 +144,29 @@ pub fn show_viewport(ui: &mut egui::Ui, app: &mut crate::app::TerrafierApp) {
}
let color_image = ColorImage::from_rgba_unmultiplied([grid_w, grid_h], &img_data);
let texture_id = ui
.ctx()
.load_texture("world_map", color_image, TextureOptions::LINEAR);
let texture_id = if let Some(ref mut handle) = app.cpu_texture_handle {
handle.set(color_image, TextureOptions::LINEAR);
handle.id()
} else {
let handle = ui
.ctx()
.load_texture("world_map", color_image, TextureOptions::LINEAR);
let id = handle.id();
app.cpu_texture_handle = Some(handle);
id
};
let desired_size = Vec2::new(grid_w as f32, grid_h as f32);
let (response, painter) = ui.allocate_painter(desired_size, egui::Sense::click());
let rect = response.rect;
painter.image(
texture_id.id(),
texture_id,
rect,
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
Color32::WHITE,
);
// Draw selection border
if let Some((sel_tx, sel_tz)) = app.selected_tile
&& sel_tx >= min_tx
&& sel_tx <= max_tx
@ -125,7 +185,6 @@ pub fn show_viewport(ui: &mut egui::Ui, app: &mut crate::app::TerrafierApp) {
);
}
// Draw brush position marker
if let (Some((sel_tx, sel_tz)), Some(bx), Some(bz)) =
(app.selected_tile, app.brush_local_x, app.brush_local_z)
{
@ -143,7 +202,6 @@ pub fn show_viewport(ui: &mut egui::Ui, app: &mut crate::app::TerrafierApp) {
);
}
// Handle click to select tile
if response.clicked()
&& let Some(pos) = response.interact_pointer_pos()
{

109
gui/src/view/gpu.rs Normal file
View file

@ -0,0 +1,109 @@
use egui::{Color32, Vec2};
use terrafier_core::model::tile::TILE_SIZE;
pub fn try_render(ui: &mut egui::Ui, app: &mut crate::app::TerrafierApp) -> bool {
let Some(ref world) = app.world else {
return false;
};
let dim = &world.dimensions[0];
if let Some(ref renderer) = app.renderer
&& let Some(texture_id) = renderer.texture_id
{
let desired_size = Vec2::new(512.0, 512.0);
let (response, painter) = ui.allocate_painter(desired_size, egui::Sense::click());
let rect = response.rect;
painter.image(
texture_id,
rect,
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
Color32::WHITE,
);
let display_size = rect.width() / 4.0;
if let Some((sel_tx, sel_tz)) = app.selected_tile {
let mut min_tx = i32::MAX;
let mut max_tx = i32::MIN;
let mut min_tz = i32::MAX;
let mut max_tz = i32::MIN;
for &(tx, tz) in dim.tiles.keys() {
min_tx = min_tx.min(tx);
max_tx = max_tx.max(tx);
min_tz = min_tz.min(tz);
max_tz = max_tz.max(tz);
}
let bx = rect.min.x;
let bz = rect.min.y;
let sel_bx = bx + (sel_tx - min_tx) as f32 * display_size;
let sel_bz = bz + (sel_tz - min_tz) as f32 * display_size;
let border_rect = egui::Rect::from_min_size(
egui::pos2(sel_bx, sel_bz),
Vec2::splat(display_size),
);
painter.rect_stroke(
border_rect,
0.0,
egui::Stroke::new(3.0, Color32::WHITE),
egui::StrokeKind::Middle,
);
}
if let (Some((sel_tx, sel_tz)), Some(bx), Some(bz)) =
(app.selected_tile, app.brush_local_x, app.brush_local_z)
{
let mut min_tx = i32::MAX;
let mut min_tz = i32::MAX;
for &(tx, tz) in dim.tiles.keys() {
min_tx = min_tx.min(tx);
min_tz = min_tz.min(tz);
}
let tile_x_in_pixels = (sel_tx - min_tx) as f32 * display_size;
let tile_z_in_pixels = (sel_tz - min_tz) as f32 * display_size;
let brush_x = rect.min.x + tile_x_in_pixels
+ (bx as f32 * display_size / TILE_SIZE as f32);
let brush_z = rect.min.y + tile_z_in_pixels
+ (bz as f32 * display_size / TILE_SIZE as f32);
let brush_radius_px = app.brush_radius as f32 * display_size / TILE_SIZE as f32;
painter.circle_stroke(
egui::pos2(brush_x, brush_z),
brush_radius_px.max(2.0),
egui::Stroke::new(1.5, Color32::YELLOW),
);
}
if response.clicked()
&& let Some(pos) = response.interact_pointer_pos()
{
let lx = pos.x - rect.min.x;
let lz = pos.y - rect.min.y;
if lx >= 0.0 && lz >= 0.0 {
let mut min_tx = i32::MAX;
let mut min_tz = i32::MAX;
for &(tx, tz) in dim.tiles.keys() {
min_tx = min_tx.min(tx);
min_tz = min_tz.min(tz);
}
let tx = (lx / display_size).floor() as i32 + min_tx;
let tz = (lz / display_size).floor() as i32 + min_tz;
if dim.tiles.contains_key(&(tx, tz)) {
app.selected_tile = Some((tx, tz));
let local_x =
((lx as u32 % display_size as u32) * TILE_SIZE as u32 / display_size as u32).min(127);
let local_z =
((lz as u32 % display_size as u32) * TILE_SIZE as u32 / display_size as u32).min(127);
app.brush_local_x = Some(local_x);
app.brush_local_z = Some(local_z);
app.status_message = format!(
"Selected tile ({}, {}) at local ({}, {})",
tx, tz, local_x, local_z
);
}
}
}
return true;
}
false
}

19
gui/src/view/mod.rs Normal file
View file

@ -0,0 +1,19 @@
mod cpu;
mod gpu;
mod tools;
pub use tools::show_tools_panel;
pub fn show_viewport(ui: &mut egui::Ui, app: &mut crate::app::TerrafierApp) {
if app.world.is_none() {
ui.centered_and_justified(|ui| {
ui.heading("No world loaded. Click 'New World' to begin.");
});
return;
}
if gpu::try_render(ui, app) {
return;
}
cpu::render(ui, app);
}

63
gui/src/tools.rs → gui/src/view/tools.rs Executable file → Normal file
View file

@ -1,14 +1,14 @@
use std::sync::Arc;
use terrafier_core::Terrain;
use terrafier_core::model::brush::SymmetricBrush;
use terrafier_core::model::types::SymmetricBrush;
use terrafier_core::model::tile::TILE_SIZE;
use terrafier_core::ops::operations::{
FlattenOperation, HeightOperation, MultiTileOperation, Operation, PaintOperation,
SmoothOperation,
ErodeOperation, FillOperation, FlattenOperation, HeightOperation, MultiTileOperation,
Operation, PaintOperation, SmoothOperation,
};
use crate::app::{TerrafierApp, ToolMode};
use crate::app::{TerrafierApp, ToolMode, LAYER_NAMES};
pub fn show_tools_panel(ui: &mut egui::Ui, app: &mut TerrafierApp) {
ui.heading("Tools");
@ -20,6 +20,8 @@ pub fn show_tools_panel(ui: &mut egui::Ui, app: &mut TerrafierApp) {
ui.radio_value(&mut app.tool_mode, ToolMode::Smooth, "Smooth");
ui.radio_value(&mut app.tool_mode, ToolMode::Flatten, "Flatten");
ui.radio_value(&mut app.tool_mode, ToolMode::Paint, "Paint");
ui.radio_value(&mut app.tool_mode, ToolMode::Erode, "Erode");
ui.radio_value(&mut app.tool_mode, ToolMode::Fill, "Fill");
ui.radio_value(&mut app.tool_mode, ToolMode::Inspect, "Inspect");
ui.separator();
@ -34,7 +36,12 @@ pub fn show_tools_panel(ui: &mut egui::Ui, app: &mut TerrafierApp) {
ui.add(egui::Slider::new(&mut app.target_height, -64..=320).text("Height"));
}
if app.tool_mode == ToolMode::Paint {
if app.tool_mode == ToolMode::Erode {
ui.add(egui::Slider::new(&mut app.erode_iterations, 1..=20).text("Iterations"));
ui.add(egui::Slider::new(&mut app.erode_talus_angle, 0.0..=1.0).text("Talus angle"));
}
if app.tool_mode == ToolMode::Paint || app.tool_mode == ToolMode::Fill {
ui.label("Terrain:");
egui::ComboBox::from_label("Type")
.selected_text(app.selected_terrain.name())
@ -52,6 +59,12 @@ pub fn show_tools_panel(ui: &mut egui::Ui, app: &mut TerrafierApp) {
ui.separator();
ui.checkbox(&mut app.show_heightmap, "Heightmap view");
ui.separator();
ui.heading("Layers");
for (i, name) in LAYER_NAMES.iter().enumerate() {
ui.checkbox(&mut app.layer_visible[i], *name);
}
ui.separator();
if ui.button("Apply to selected tile").clicked() {
apply_tool(app);
@ -75,7 +88,6 @@ fn apply_tool(app: &mut TerrafierApp) {
let center = app.brush_local_x.unwrap_or(64);
let center_z = app.brush_local_z.unwrap_or(64);
// Handle Inspect separately — it doesn't modify the world
if app.tool_mode == ToolMode::Inspect {
if let Some(tile) = dim.tiles.get(&(tx, tz)) {
let count = tile.terrain.iter().filter(|&&t| t != 0).count();
@ -89,7 +101,30 @@ fn apply_tool(app: &mut TerrafierApp) {
return;
}
// Create the operation — always use MultiTileOperation for consistency
if app.tool_mode == ToolMode::Fill {
if dim.tiles.contains_key(&(tx, tz)) {
let op = FillOperation {
tile_x: tx,
tile_z: tz,
center_x: center,
center_z,
terrain: app.selected_terrain,
before_snapshot: Default::default(),
};
let result = op.apply(dim);
app.save_for_undo(Box::new(op));
match result {
Ok(()) => {
app.status_message = format!("Fill applied to ({},{})", tx, tz);
}
Err(e) => {
app.status_message = format!("Fill error: {:?}", e);
}
}
}
return;
}
let radius = app.brush_radius as i32;
let global_cx = tx * TILE_SIZE as i32 + center as i32;
let global_cz = tz * TILE_SIZE as i32 + center_z as i32;
@ -161,6 +196,18 @@ fn apply_tool(app: &mut TerrafierApp) {
brush,
before_snapshot: Default::default(),
}),
ToolMode::Erode => Box::new(ErodeOperation {
tile_x: otx,
tile_z: otz,
center_x: local_cx,
center_z: local_cz,
radius: app.brush_radius,
iterations: app.erode_iterations,
talus_angle: app.erode_talus_angle,
brush,
before_snapshot: Default::default(),
}),
ToolMode::Fill => unreachable!(),
ToolMode::Inspect => unreachable!(),
};
ops.push(op);
@ -175,10 +222,8 @@ fn apply_tool(app: &mut TerrafierApp) {
let multi_op = MultiTileOperation { operations: ops };
// Apply the operation first (fills before_snapshot for Flatten/Paint/Smooth)
let result = multi_op.apply(dim);
// Save for undo after applying (op now contains the pre-apply snapshot)
app.save_for_undo(Box::new(multi_op));
match result {