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:
parent
3d3798136d
commit
bdd4564240
104 changed files with 5184 additions and 3912 deletions
|
|
@ -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"
|
||||
|
|
|
|||
185
gui/src/app.rs
185
gui/src/app.rs
|
|
@ -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
132
gui/src/app/mod.rs
Normal 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
48
gui/src/app/ops.rs
Normal 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
91
gui/src/app/update.rs
Normal 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
6
gui/src/lib.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
mod app;
|
||||
mod dialogs;
|
||||
mod renderer;
|
||||
mod view;
|
||||
|
||||
pub use app::TerrafierApp;
|
||||
|
|
@ -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
3
gui/src/renderer/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
mod pipeline;
|
||||
mod render;
|
||||
pub use pipeline::*;
|
||||
140
gui/src/renderer/pipeline.rs
Normal file
140
gui/src/renderer/pipeline.rs
Normal 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
123
gui/src/renderer/render.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
114
gui/src/renderer/shaders.wgsl
Normal file
114
gui/src/renderer/shaders.wgsl
Normal 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
100
gui/src/view.rs → gui/src/view/cpu.rs
Executable file → Normal 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
109
gui/src/view/gpu.rs
Normal 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
19
gui/src/view/mod.rs
Normal 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
63
gui/src/tools.rs → gui/src/view/tools.rs
Executable file → Normal 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 {
|
||||
Loading…
Add table
Add a link
Reference in a new issue