commit 2db9ca215a2f0d0bec7e08977fa63f499f7949bf Author: Rodrigo Verdiani Date: Tue Jun 16 14:34:31 2026 -0300 chore: initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dfeb38b --- /dev/null +++ b/.gitignore @@ -0,0 +1,50 @@ +# Created by https://www.toptal.com/developers/gitignore/api/rust,linux,git +# Edit at https://www.toptal.com/developers/gitignore?templates=rust,linux,git + +### Git ### +# Created by git for backups. To disable backups in Git: +# $ git config --global mergetool.keepBackup false +*.orig + +# Created by git when using merge tools for conflicts +*.BACKUP.* +*.BASE.* +*.LOCAL.* +*.REMOTE.* +*_BACKUP_*.txt +*_BASE_*.txt +*_LOCAL_*.txt +*_REMOTE_*.txt + +### Linux ### +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### Rust ### +# Generated by Cargo +# will have compiled files and executables +debug/ +target/ + +# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries +# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html +Cargo.lock + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb + +# End of https://www.toptal.com/developers/gitignore/api/rust,linux,git diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..5d9789f --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "engine" +version = "0.1.0" +edition = "2021" + +[dependencies] +sdl3 = "0.18" +glow = "0.17" +glam = "0.33" +noise = "0.9" +bytemuck = "1" diff --git a/README.md b/README.md new file mode 100644 index 0000000..3358559 --- /dev/null +++ b/README.md @@ -0,0 +1,70 @@ +# Engine 3D + +Scaffold de engine 3D em Rust usando SDL3 e OpenGL 3.3+. + +- Terreno procedural com Perlin Noise (FBM multi-oitavas) +- Malha contínua triangulada +- Iluminação difusa + especular (Blinn-Phong) + névoa atmosférica +- Mar com ondas de Gerstner, reflexões Fresnel e transparência por profundidade +- Câmera FPS livre com mouse look +- Game loop com update (60Hz) e render desacoplados + +## Dependências + +| Crate | Uso | +|---|---| +| `sdl3` | Janela e contexto OpenGL | +| `glow` | Bindings OpenGL em Rust | +| `glam` | Álgebra linear 3D (Mat4, Vec3) | +| `noise` | Perlin Noise / FBM | +| `bytemuck` | Cast seguro de slices para bytes | + +**Requer SDL3 instalado no sistema:** + +```bash +# Fedora +sudo dnf install SDL3-devel + +# Ubuntu/Debian +sudo apt install libsdl3-dev +``` + +## Compilar e rodar + +```bash +cargo run --release +``` + +## Controles + +| Tecla | Ação | +|---|---| +| W / A / S / D | Mover câmera (frente/esq/trás/dir) | +| Space / Shift | Subir / Descer | +| Mouse | Olhar ao redor (FPS look — inicia capturado) | +| Tab | Alternar captura do mouse | +| ESC | Soltar mouse / Fechar | + +## Estrutura + +``` +src/ +├── main.rs # Inicialização SDL3 + game loop, 3-pass render +├── camera.rs # Câmera FPS (WASD, mouse look, view/projection) +├── input.rs # Polling eventos SDL3, estado teclas +├── terrain.rs # Heightmap Perlin FBM + geração de malha +├── water.rs # Malha do mar (plano no nível do mar, 256x256) +├── renderer.rs # VAO/VBO/EBO terreno+água, depth FBO, 3-pass pipeline +├── shader.rs # Compilação/link GLSL +└── shaders/ + ├── terrain.vert # Vertex shader terreno (MVP, normals) + ├── terrain.frag # Fragment shader terreno (luz, névoa, gradiente por altura) + ├── water.vert # Vertex shader água (5 ondas Gerstner, normal perturbada) + └── water.frag # Fragment shader água (Fresnel, specular, transparência por depth) +``` + +## Pipeline de renderização + +1. **Terreno** — render opaco (escreve cor + depth) +2. **Blit depth** — copia depth buffer → FBO com depth texture +3. **Água** — render com alpha blending, depth-test on, depth-write off. Lê depth texture para calcular coluna d'água (raso = transparente, fundo = opaco) diff --git a/src/camera.rs b/src/camera.rs new file mode 100644 index 0000000..4c60318 --- /dev/null +++ b/src/camera.rs @@ -0,0 +1,103 @@ +use glam::{Mat4, Vec3}; +use sdl3::keyboard::Scancode; + +use crate::input::InputState; + +pub struct Camera { + pub position: Vec3, + yaw: f32, + pitch: f32, + front: Vec3, + up: Vec3, + right: Vec3, + world_up: Vec3, + aspect: f32, + fov: f32, + near: f32, + far: f32, + movement_speed: f32, + mouse_sensitivity: f32, +} + +impl Camera { + pub fn new(position: Vec3, aspect: f32) -> Self { + let world_up = Vec3::new(0.0, 1.0, 0.0); + let mut camera = Self { + position, + yaw: -90.0_f32, + pitch: 0.0, + front: Vec3::new(0.0, 0.0, -1.0), + up: Vec3::ZERO, + right: Vec3::ZERO, + world_up, + aspect, + fov: 60.0, + near: 0.1, + far: 500.0, + movement_speed: 8.0, + mouse_sensitivity: 0.1, + }; + camera.update_vectors(); + camera + } + + pub fn view_matrix(&self) -> Mat4 { + Mat4::look_at_rh(self.position, self.position + self.front, self.up) + } + + pub fn projection_matrix(&self) -> Mat4 { + Mat4::perspective_rh(self.fov.to_radians(), self.aspect, self.near, self.far) + } + + pub fn process_keyboard(&mut self, input: &InputState, dt: f32) { + let velocity = self.movement_speed * dt; + + if input.is_key_down(Scancode::W) { + self.position += self.front * velocity; + } + if input.is_key_down(Scancode::S) { + self.position -= self.front * velocity; + } + if input.is_key_down(Scancode::A) { + self.position -= self.right * velocity; + } + if input.is_key_down(Scancode::D) { + self.position += self.right * velocity; + } + if input.is_key_down(Scancode::Space) { + self.position += self.world_up * velocity; + } + if input.is_key_down(Scancode::LShift) || input.is_key_down(Scancode::RShift) { + self.position -= self.world_up * velocity; + } + } + + pub fn process_mouse(&mut self, dx: f32, dy: f32) { + self.yaw += dx * self.mouse_sensitivity; + self.pitch -= dy * self.mouse_sensitivity; + + if self.pitch > 89.0 { + self.pitch = 89.0; + } + if self.pitch < -89.0 { + self.pitch = -89.0; + } + + self.update_vectors(); + } + + fn update_vectors(&mut self) { + let yaw_rad = self.yaw.to_radians(); + let pitch_rad = self.pitch.to_radians(); + + self.front = Vec3::new( + yaw_rad.cos() * pitch_rad.cos(), + pitch_rad.sin(), + yaw_rad.sin() * pitch_rad.cos(), + ) + .normalize(); + + self.right = self.front.cross(self.world_up).normalize(); + self.up = self.right.cross(self.front).normalize(); + } +} diff --git a/src/input.rs b/src/input.rs new file mode 100644 index 0000000..29af07f --- /dev/null +++ b/src/input.rs @@ -0,0 +1,29 @@ +use std::collections::HashSet; +use sdl3::keyboard::Scancode; + +pub struct InputState { + pub keys: HashSet, + pub mouse_dx: f32, + pub mouse_dy: f32, + pub mouse_captured: bool, +} + +impl InputState { + pub fn new() -> Self { + Self { + keys: HashSet::new(), + mouse_dx: 0.0, + mouse_dy: 0.0, + mouse_captured: false, + } + } + + pub fn is_key_down(&self, scancode: Scancode) -> bool { + self.keys.contains(&scancode) + } + + pub fn reset_mouse_delta(&mut self) { + self.mouse_dx = 0.0; + self.mouse_dy = 0.0; + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..3cd33f2 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,175 @@ +mod camera; +mod input; +mod renderer; +mod shader; +mod terrain; +mod water; + +use std::time::Instant; + +use glow::HasContext; +use sdl3::event::Event; +use sdl3::keyboard::Scancode; + +use camera::Camera; +use input::InputState; +use renderer::Renderer; +use terrain::{compute_ocean_mask, generate_terrain}; +use water::generate_water; + +fn main() -> Result<(), String> { + let sdl = sdl3::init().map_err(|e| e.to_string())?; + let video = sdl.video().map_err(|e| e.to_string())?; + let mouse = sdl.mouse(); + + let gl_attr = video.gl_attr(); + gl_attr.set_context_version(3, 3); + gl_attr.set_context_profile(sdl3::video::GLProfile::Core); + gl_attr.set_double_buffer(true); + gl_attr.set_depth_size(24); + gl_attr.set_stencil_size(8); + + let window = video + .window("Engine 3D", 1280, 720) + .opengl() + .resizable() + .build() + .map_err(|e| e.to_string())?; + + let gl_context = window.gl_create_context().map_err(|e| e.to_string())?; + let _ = window.gl_make_current(&gl_context); + + let gl = unsafe { + glow::Context::from_loader_function(|name| { + video + .gl_get_proc_address(name) + .map(|f| f as *const std::ffi::c_void) + .unwrap_or(std::ptr::null()) + }) + }; + + unsafe { + gl.enable(glow::DEPTH_TEST); + gl.enable(glow::CULL_FACE); + gl.cull_face(glow::BACK); + gl.clear_color(0.5, 0.7, 0.85, 1.0); + } + + let terrain_mesh = generate_terrain(200, 100.0, 0.02, 15.0, 42); + + let sea_level = terrain_mesh.min_height + + (terrain_mesh.max_height - terrain_mesh.min_height) * 0.40; + let ocean_mask = compute_ocean_mask( + &terrain_mesh.heightmap, + sea_level, + terrain_mesh.grid_resolution, + ); + let mask_res = terrain_mesh.grid_resolution + 1; + + let water_mesh = generate_water( + 100.0, + terrain_mesh.min_height, + terrain_mesh.max_height, + 256, + ); + + let win_size = window.size(); + let renderer = Renderer::new( + &gl, + &terrain_mesh, + &water_mesh, + &ocean_mask, + mask_res, + win_size.0 as i32, + win_size.1 as i32, + )?; + + let mut camera = Camera::new(glam::Vec3::new(0.0, 15.0, 10.0), 1280.0 / 720.0); + let mut input = InputState::new(); + input.mouse_captured = true; + mouse.set_relative_mouse_mode(&window, true); + + let mut event_pump = sdl.event_pump().map_err(|e| e.to_string())?; + let mut last_frame = Instant::now(); + let mut running = true; + let mut total_time: f32 = 0.0; + + while running { + let current = Instant::now(); + let dt = current.duration_since(last_frame).as_secs_f32(); + last_frame = current; + total_time += dt; + + input.reset_mouse_delta(); + + for event in event_pump.poll_iter() { + match event { + Event::Quit { .. } => running = false, + Event::KeyDown { + scancode: Some(sc), + .. + } => { + match sc { + Scancode::Escape => { + if input.mouse_captured { + input.mouse_captured = false; + mouse.set_relative_mouse_mode(&window, false); + } else { + running = false; + } + } + Scancode::Tab => { + input.mouse_captured = !input.mouse_captured; + mouse.set_relative_mouse_mode(&window, input.mouse_captured); + } + _ => { + input.keys.insert(sc); + } + } + } + Event::KeyUp { + scancode: Some(sc), + .. + } => { + input.keys.remove(&sc); + } + Event::MouseMotion { xrel, yrel, .. } if input.mouse_captured => { + input.mouse_dx += xrel as f32; + input.mouse_dy += yrel as f32; + } + _ => {} + } + } + + camera.process_keyboard(&input, dt); + if input.mouse_captured { + camera.process_mouse(input.mouse_dx, input.mouse_dy); + } + + unsafe { + gl.clear(glow::COLOR_BUFFER_BIT | glow::DEPTH_BUFFER_BIT); + } + + let view = camera.view_matrix(); + let proj = camera.projection_matrix(); + let pos = camera.position; + + renderer.render_terrain(&gl, &view, &proj, pos); + renderer.copy_depth_to_texture(&gl); + + unsafe { + gl.enable(glow::BLEND); + gl.blend_func(glow::SRC_ALPHA, glow::ONE_MINUS_SRC_ALPHA); + gl.depth_mask(false); + } + renderer.render_water(&gl, &view, &proj, pos, total_time); + unsafe { + gl.depth_mask(true); + gl.disable(glow::BLEND); + } + + window.gl_swap_window(); + } + + Ok(()) +} diff --git a/src/renderer.rs b/src/renderer.rs new file mode 100644 index 0000000..618cd0e --- /dev/null +++ b/src/renderer.rs @@ -0,0 +1,334 @@ +use glam::{Mat4, Vec3}; +use glow::HasContext; + +use crate::shader::ShaderProgram; +use crate::terrain::TerrainMesh; +use crate::water::WaterMesh; + +pub struct Renderer { + terrain_vao: glow::VertexArray, + _terrain_vbo: glow::Buffer, + _terrain_ebo: glow::Buffer, + terrain_shader: ShaderProgram, + terrain_index_count: i32, + terrain_min_height: f32, + terrain_max_height: f32, + + water_vao: glow::VertexArray, + _water_vbo: glow::Buffer, + _water_ebo: glow::Buffer, + water_shader: ShaderProgram, + water_index_count: i32, + + depth_texture: glow::Texture, + depth_fbo: glow::Framebuffer, + ocean_mask_texture: glow::Texture, + terrain_scale: f32, + width: i32, + height: i32, +} + +fn setup_mesh_vao( + gl: &glow::Context, + vertices: &[f32], + indices: &[u32], +) -> Result<(glow::VertexArray, glow::Buffer, glow::Buffer), String> { + unsafe { + let vao = gl.create_vertex_array().map_err(|e| e.to_string())?; + let vbo = gl.create_buffer().map_err(|e| e.to_string())?; + let ebo = gl.create_buffer().map_err(|e| e.to_string())?; + + gl.bind_vertex_array(Some(vao)); + + gl.bind_buffer(glow::ARRAY_BUFFER, Some(vbo)); + gl.buffer_data_u8_slice( + glow::ARRAY_BUFFER, + bytemuck::cast_slice(vertices), + glow::STATIC_DRAW, + ); + + gl.bind_buffer(glow::ELEMENT_ARRAY_BUFFER, Some(ebo)); + gl.buffer_data_u8_slice( + glow::ELEMENT_ARRAY_BUFFER, + bytemuck::cast_slice(indices), + glow::STATIC_DRAW, + ); + + let stride = 8 * std::mem::size_of::() as i32; + + gl.vertex_attrib_pointer_f32(0, 3, glow::FLOAT, false, stride, 0); + gl.enable_vertex_attrib_array(0); + + gl.vertex_attrib_pointer_f32( + 1, + 3, + glow::FLOAT, + false, + stride, + (3 * std::mem::size_of::()) as i32, + ); + gl.enable_vertex_attrib_array(1); + + gl.vertex_attrib_pointer_f32( + 2, + 2, + glow::FLOAT, + false, + stride, + (6 * std::mem::size_of::()) as i32, + ); + gl.enable_vertex_attrib_array(2); + + gl.bind_vertex_array(None); + + Ok((vao, vbo, ebo)) + } +} + +impl Renderer { + pub fn new( + gl: &glow::Context, + terrain: &TerrainMesh, + water: &WaterMesh, + ocean_mask: &[u8], + mask_resolution: usize, + width: i32, + height: i32, + ) -> Result { + let terrain_shader = ShaderProgram::new( + gl, + include_str!("shaders/terrain.vert"), + include_str!("shaders/terrain.frag"), + )?; + + let water_shader = ShaderProgram::new( + gl, + include_str!("shaders/water.vert"), + include_str!("shaders/water.frag"), + )?; + + let (terrain_vao, terrain_vbo, terrain_ebo) = + setup_mesh_vao(gl, &terrain.vertices, &terrain.indices)?; + + let (water_vao, water_vbo, water_ebo) = + setup_mesh_vao(gl, &water.vertices, &water.indices)?; + + let (depth_texture, depth_fbo) = unsafe { create_depth_texture(gl, width, height)? }; + + let ocean_mask_texture = + unsafe { upload_ocean_mask(gl, ocean_mask, mask_resolution)? }; + + Ok(Self { + terrain_vao, + _terrain_vbo: terrain_vbo, + _terrain_ebo: terrain_ebo, + terrain_shader, + terrain_index_count: terrain.index_count, + terrain_min_height: terrain.min_height, + terrain_max_height: terrain.max_height, + + water_vao, + _water_vbo: water_vbo, + _water_ebo: water_ebo, + water_shader, + water_index_count: water.index_count, + + depth_texture, + depth_fbo, + ocean_mask_texture, + terrain_scale: terrain.world_scale, + width, + height, + }) + } + + pub fn render_terrain( + &self, + gl: &glow::Context, + view: &Mat4, + projection: &Mat4, + camera_pos: Vec3, + ) { + unsafe { + self.terrain_shader.bind(gl); + + let model = Mat4::IDENTITY; + self.terrain_shader.set_mat4(gl, "model", &model); + self.terrain_shader.set_mat4(gl, "view", view); + self.terrain_shader.set_mat4(gl, "projection", projection); + + let light_dir = glam::Vec3::new(0.8, 1.0, 0.3).normalize(); + let light_color = glam::Vec3::new(1.0, 0.95, 0.8); + + self.terrain_shader.set_vec3(gl, "lightDir", light_dir); + self.terrain_shader.set_vec3(gl, "lightColor", light_color); + self.terrain_shader.set_vec3(gl, "viewPos", camera_pos); + + let fog_color = glam::Vec3::new(0.5, 0.7, 0.85); + self.terrain_shader.set_vec3(gl, "fogColor", fog_color); + self.terrain_shader.set_float(gl, "fogDensity", 0.003); + + self.terrain_shader + .set_float(gl, "minHeight", self.terrain_min_height); + self.terrain_shader + .set_float(gl, "maxHeight", self.terrain_max_height); + + gl.bind_vertex_array(Some(self.terrain_vao)); + gl.draw_elements( + glow::TRIANGLES, + self.terrain_index_count, + glow::UNSIGNED_INT, + 0, + ); + gl.bind_vertex_array(None); + } + } + + pub fn copy_depth_to_texture(&self, gl: &glow::Context) { + unsafe { + gl.bind_framebuffer(glow::READ_FRAMEBUFFER, None); + gl.bind_framebuffer(glow::DRAW_FRAMEBUFFER, Some(self.depth_fbo)); + gl.blit_framebuffer( + 0, + 0, + self.width, + self.height, + 0, + 0, + self.width, + self.height, + glow::DEPTH_BUFFER_BIT, + glow::NEAREST, + ); + gl.bind_framebuffer(glow::FRAMEBUFFER, None); + } + } + + pub fn render_water( + &self, + gl: &glow::Context, + view: &Mat4, + projection: &Mat4, + camera_pos: Vec3, + time: f32, + ) { + unsafe { + self.water_shader.bind(gl); + + let model = Mat4::IDENTITY; + self.water_shader.set_mat4(gl, "model", &model); + self.water_shader.set_mat4(gl, "view", view); + self.water_shader.set_mat4(gl, "projection", projection); + self.water_shader.set_float(gl, "time", time); + + let light_dir = glam::Vec3::new(0.8, 1.0, 0.3).normalize(); + let light_color = glam::Vec3::new(1.0, 0.95, 0.8); + + self.water_shader.set_vec3(gl, "lightDir", light_dir); + self.water_shader.set_vec3(gl, "lightColor", light_color); + self.water_shader.set_vec3(gl, "viewPos", camera_pos); + + self.water_shader + .set_vec3(gl, "waterDeep", glam::Vec3::new(0.0, 0.2, 0.4)); + self.water_shader + .set_vec3(gl, "waterShallow", glam::Vec3::new(0.0, 0.5, 0.7)); + self.water_shader + .set_vec3(gl, "skyColor", glam::Vec3::new(0.5, 0.7, 0.85)); + self.water_shader.set_float(gl, "maxWaterDepth", 5.0); + + let fog_color = glam::Vec3::new(0.5, 0.7, 0.85); + self.water_shader.set_vec3(gl, "fogColor", fog_color); + self.water_shader.set_float(gl, "fogDensity", 0.003); + + self.water_shader + .set_float(gl, "terrainScale", self.terrain_scale); + + gl.active_texture(glow::TEXTURE0); + gl.bind_texture(glow::TEXTURE_2D, Some(self.depth_texture)); + + gl.active_texture(glow::TEXTURE1); + gl.bind_texture(glow::TEXTURE_2D, Some(self.ocean_mask_texture)); + self.water_shader.set_int(gl, "oceanMask", 1); + + gl.bind_vertex_array(Some(self.water_vao)); + gl.draw_elements( + glow::TRIANGLES, + self.water_index_count, + glow::UNSIGNED_INT, + 0, + ); + gl.bind_vertex_array(None); + } + } +} + +unsafe fn create_depth_texture( + gl: &glow::Context, + width: i32, + height: i32, +) -> Result<(glow::Texture, glow::Framebuffer), String> { + let texture = gl.create_texture().map_err(|e| e.to_string())?; + gl.bind_texture(glow::TEXTURE_2D, Some(texture)); + gl.tex_image_2d( + glow::TEXTURE_2D, + 0, + glow::DEPTH_COMPONENT24 as i32, + width, + height, + 0, + glow::DEPTH_COMPONENT, + glow::FLOAT, + glow::PixelUnpackData::Slice(None), + ); + gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_MIN_FILTER, glow::NEAREST as i32); + gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_MAG_FILTER, glow::NEAREST as i32); + gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::CLAMP_TO_EDGE as i32); + gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::CLAMP_TO_EDGE as i32); + + let fbo = gl.create_framebuffer().map_err(|e| e.to_string())?; + gl.bind_framebuffer(glow::FRAMEBUFFER, Some(fbo)); + gl.framebuffer_texture_2d( + glow::FRAMEBUFFER, + glow::DEPTH_ATTACHMENT, + glow::TEXTURE_2D, + Some(texture), + 0, + ); + + gl.bind_framebuffer(glow::FRAMEBUFFER, None); + gl.bind_texture(glow::TEXTURE_2D, None); + + Ok((texture, fbo)) +} + +unsafe fn upload_ocean_mask( + gl: &glow::Context, + data: &[u8], + resolution: usize, +) -> Result { + let texture = gl.create_texture().map_err(|e| e.to_string())?; + gl.bind_texture(glow::TEXTURE_2D, Some(texture)); + gl.tex_image_2d( + glow::TEXTURE_2D, + 0, + glow::RGBA as i32, + resolution as i32, + resolution as i32, + 0, + glow::RGBA, + glow::UNSIGNED_BYTE, + glow::PixelUnpackData::Slice(Some(data)), + ); + gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_MIN_FILTER, glow::LINEAR as i32); + gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_MAG_FILTER, glow::LINEAR as i32); + gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_S, glow::CLAMP_TO_EDGE as i32); + gl.tex_parameter_i32(glow::TEXTURE_2D, glow::TEXTURE_WRAP_T, glow::CLAMP_TO_EDGE as i32); + gl.bind_texture(glow::TEXTURE_2D, None); + + Ok(texture) +} + +impl Drop for Renderer { + fn drop(&mut self) { + } +} diff --git a/src/shader.rs b/src/shader.rs new file mode 100644 index 0000000..955c4de --- /dev/null +++ b/src/shader.rs @@ -0,0 +1,98 @@ +use glam::{Mat4, Vec3}; +use glow::HasContext; + +pub struct ShaderProgram { + program: glow::Program, +} + +impl ShaderProgram { + pub fn new(gl: &glow::Context, vert_src: &str, frag_src: &str) -> Result { + unsafe { + let vertex_shader = compile_shader(gl, glow::VERTEX_SHADER, vert_src)?; + let fragment_shader = compile_shader(gl, glow::FRAGMENT_SHADER, frag_src)?; + + let program = gl.create_program().map_err(|e| e.to_string())?; + gl.attach_shader(program, vertex_shader); + gl.attach_shader(program, fragment_shader); + gl.link_program(program); + + if !gl.get_program_link_status(program) { + let log = gl.get_program_info_log(program); + gl.delete_program(program); + gl.delete_shader(vertex_shader); + gl.delete_shader(fragment_shader); + return Err(format!("Program link failed: {log}")); + } + + gl.delete_shader(vertex_shader); + gl.delete_shader(fragment_shader); + + Ok(Self { program }) + } + } + + pub fn bind(&self, gl: &glow::Context) { + unsafe { + gl.use_program(Some(self.program)); + } + } + + pub fn set_mat4(&self, gl: &glow::Context, name: &str, mat: &Mat4) { + unsafe { + let location = gl.get_uniform_location(self.program, name); + if let Some(loc) = location { + gl.uniform_matrix_4_f32_slice(Some(&loc), false, mat.as_ref()); + } + } + } + + pub fn set_vec3(&self, gl: &glow::Context, name: &str, vec: Vec3) { + unsafe { + let location = gl.get_uniform_location(self.program, name); + if let Some(loc) = location { + gl.uniform_3_f32(Some(&loc), vec.x, vec.y, vec.z); + } + } + } + + pub fn set_float(&self, gl: &glow::Context, name: &str, value: f32) { + unsafe { + let location = gl.get_uniform_location(self.program, name); + if let Some(loc) = location { + gl.uniform_1_f32(Some(&loc), value); + } + } + } + + pub fn set_int(&self, gl: &glow::Context, name: &str, value: i32) { + unsafe { + let location = gl.get_uniform_location(self.program, name); + if let Some(loc) = location { + gl.uniform_1_i32(Some(&loc), value); + } + } + } +} + +impl Drop for ShaderProgram { + fn drop(&mut self) { + } +} + +unsafe fn compile_shader( + gl: &glow::Context, + shader_type: u32, + source: &str, +) -> Result { + let shader = gl.create_shader(shader_type).map_err(|e| e.to_string())?; + gl.shader_source(shader, source); + gl.compile_shader(shader); + + if !gl.get_shader_compile_status(shader) { + let log = gl.get_shader_info_log(shader); + gl.delete_shader(shader); + return Err(format!("Shader compilation failed: {log}")); + } + + Ok(shader) +} diff --git a/src/shaders/terrain.frag b/src/shaders/terrain.frag new file mode 100644 index 0000000..d10bdb5 --- /dev/null +++ b/src/shaders/terrain.frag @@ -0,0 +1,60 @@ +#version 330 core + +in vec3 FragPos; +in vec3 Normal; +in float Height; + +out vec4 FragColor; + +uniform vec3 lightDir; +uniform vec3 lightColor; +uniform vec3 viewPos; + +uniform vec3 fogColor; +uniform float fogDensity; + +uniform float minHeight; +uniform float maxHeight; + +void main() +{ + float ambientStrength = 0.25; + vec3 ambient = ambientStrength * lightColor; + + vec3 norm = normalize(Normal); + vec3 lightDirection = normalize(lightDir); + float diff = max(dot(norm, lightDirection), 0.0); + vec3 diffuse = diff * lightColor; + + float specularStrength = 0.5; + vec3 viewDirection = normalize(viewPos - FragPos); + vec3 halfwayDir = normalize(lightDirection + viewDirection); + float spec = pow(max(dot(norm, halfwayDir), 0.0), 32.0); + vec3 specular = specularStrength * spec * lightColor; + + float t = (Height - minHeight) / (maxHeight - minHeight); + t = clamp(t, 0.0, 1.0); + + vec3 green = vec3(0.2, 0.6, 0.1); + vec3 brown = vec3(0.55, 0.35, 0.1); + vec3 gray = vec3(0.5, 0.5, 0.5); + vec3 white = vec3(0.9, 0.9, 0.95); + + vec3 terrainColor; + if (t < 0.4) + terrainColor = mix(green, brown, t / 0.4); + else if (t < 0.7) + terrainColor = mix(brown, gray, (t - 0.4) / 0.3); + else + terrainColor = mix(gray, white, (t - 0.7) / 0.3); + + vec3 objectColor = terrainColor; + vec3 result = (ambient + diffuse + specular) * objectColor; + + float distance = length(viewPos - FragPos); + float fogFactor = 1.0 - exp(-distance * fogDensity); + fogFactor = clamp(fogFactor, 0.0, 1.0); + result = mix(result, fogColor, fogFactor); + + FragColor = vec4(result, 1.0); +} diff --git a/src/shaders/terrain.vert b/src/shaders/terrain.vert new file mode 100644 index 0000000..e2dbd68 --- /dev/null +++ b/src/shaders/terrain.vert @@ -0,0 +1,21 @@ +#version 330 core + +layout (location = 0) in vec3 aPos; +layout (location = 1) in vec3 aNormal; +layout (location = 2) in vec2 aTexCoord; + +uniform mat4 model; +uniform mat4 view; +uniform mat4 projection; + +out vec3 FragPos; +out vec3 Normal; +out float Height; + +void main() +{ + FragPos = vec3(model * vec4(aPos, 1.0)); + Normal = mat3(transpose(inverse(model))) * aNormal; + Height = aPos.y; + gl_Position = projection * view * vec4(FragPos, 1.0); +} diff --git a/src/shaders/water.frag b/src/shaders/water.frag new file mode 100644 index 0000000..4f7eb78 --- /dev/null +++ b/src/shaders/water.frag @@ -0,0 +1,74 @@ +#version 330 core + +in vec3 FragPos; +in vec3 Normal; +in vec2 TexCoord; + +out vec4 FragColor; + +uniform vec3 viewPos; +uniform vec3 lightDir; +uniform vec3 lightColor; +uniform vec3 waterDeep; +uniform vec3 waterShallow; +uniform vec3 skyColor; +uniform sampler2D depthTex; +uniform float maxWaterDepth; +uniform vec3 fogColor; +uniform float fogDensity; +uniform sampler2D oceanMask; +uniform float terrainScale; + +float linearize(float ndc) +{ + float near = 0.1; + float far = 500.0; + return (2.0 * near * far) / (far + near - (ndc * 2.0 - 1.0) * (far - near)); +} + +void main() +{ + vec2 maskUV = FragPos.xz / terrainScale + 0.5; + float oceanFactor; + if (maskUV.x < 0.0 || maskUV.x > 1.0 || maskUV.y < 0.0 || maskUV.y > 1.0) { + oceanFactor = 1.0; + } else { + oceanFactor = texture(oceanMask, maskUV).r; + } + if (oceanFactor < 0.5) + discard; + + vec3 norm = normalize(Normal); + vec3 viewDir = normalize(viewPos - FragPos); + + float fresnel = pow(1.0 - max(dot(norm, viewDir), 0.0), 3.0); + + vec3 reflected = skyColor * 1.3; + + vec3 L = normalize(lightDir); + vec3 H = normalize(L + viewDir); + float spec = pow(max(dot(norm, H), 0.0), 256.0); + vec3 sun = spec * lightColor * 1.0; + + float upness = max(dot(norm, vec3(0.0, 1.0, 0.0)), 0.0); + vec3 base = mix(waterShallow, waterDeep, upness); + + vec3 color = mix(base, reflected, fresnel * 0.85); + color += sun; + + vec2 screenUV = gl_FragCoord.xy / textureSize(depthTex, 0); + float terrainD = texture(depthTex, screenUV).r; + float waterD = gl_FragCoord.z; + + float tLinear = linearize(terrainD); + float wLinear = linearize(waterD); + float column = tLinear - wLinear; + float alpha = clamp(column / maxWaterDepth, 0.15, 0.9); + + float dist = length(viewPos - FragPos); + float fog = 1.0 - exp(-dist * fogDensity); + fog = clamp(fog, 0.0, 1.0); + color = mix(color, fogColor, fog); + + FragColor = vec4(color, alpha); +} diff --git a/src/shaders/water.vert b/src/shaders/water.vert new file mode 100644 index 0000000..d99153b --- /dev/null +++ b/src/shaders/water.vert @@ -0,0 +1,60 @@ +#version 330 core + +layout (location = 0) in vec3 aPos; +layout (location = 1) in vec3 aNormal; +layout (location = 2) in vec2 aTexCoord; + +uniform mat4 model; +uniform mat4 view; +uniform mat4 projection; +uniform float time; + +out vec3 FragPos; +out vec3 Normal; +out vec2 TexCoord; + +const float PI = 3.14159265359; + +vec3 gerstner(vec2 dir, float freq, float amp, float steep, float phase, vec3 pos, inout vec3 nor) +{ + float w = freq * 2.0 * PI; + float Q = steep / (w * amp * 2.5 + 0.01); + float WA = w * amp; + float fi = w * dot(dir, pos.xz) + time * phase; + float C = cos(fi); + float S = sin(fi); + + pos.x += Q * amp * dir.x * C; + pos.y += amp * S; + pos.z += Q * amp * dir.y * C; + + nor.x -= dir.x * WA * C; + nor.y += Q * WA * S; + nor.z -= dir.y * WA * C; + + return pos; +} + +void main() +{ + vec3 pos = aPos; + vec3 nor = aNormal; + + // Large slow swell + pos = gerstner(normalize(vec2( 0.7, 0.7)), 0.04, 2.0, 0.35, 0.5, pos, nor); + // Medium swell crossing + pos = gerstner(normalize(vec2(-0.6, 0.8)), 0.08, 1.1, 0.40, 1.2, pos, nor); + // Medium swell opposite + pos = gerstner(normalize(vec2( 0.9, -0.4)), 0.10, 0.7, 0.35, 0.9, pos, nor); + // Choppy surface + pos = gerstner(normalize(vec2(-0.3, -0.95)), 0.18, 0.3, 0.55, 1.8, pos, nor); + // Fine ripples + pos = gerstner(normalize(vec2( 0.5, -0.85)), 0.28, 0.08, 0.35, 2.5, pos, nor); + + nor = normalize(nor); + + FragPos = vec3(model * vec4(pos, 1.0)); + Normal = mat3(transpose(inverse(model))) * nor; + TexCoord = aTexCoord; + gl_Position = projection * view * vec4(FragPos, 1.0); +} diff --git a/src/terrain.rs b/src/terrain.rs new file mode 100644 index 0000000..1d9d149 --- /dev/null +++ b/src/terrain.rs @@ -0,0 +1,212 @@ +use noise::{NoiseFn, Perlin, Fbm}; +use std::collections::VecDeque; + +pub struct TerrainMesh { + pub vertices: Vec, + pub indices: Vec, + pub index_count: i32, + pub min_height: f32, + pub max_height: f32, + pub heightmap: Vec>, + pub grid_resolution: usize, + pub world_scale: f32, +} + +pub fn generate_terrain( + grid_resolution: usize, + world_scale: f32, + noise_freq: f64, + height_multiplier: f32, + seed: u32, +) -> TerrainMesh { + let fbm = Fbm::::new(seed); + let vertices_per_row = grid_resolution + 1; + let total_vertices = vertices_per_row * vertices_per_row; + let half = grid_resolution as f32 * 0.5; + let cell_size = world_scale / grid_resolution as f32; + + let mut heightmap = vec![vec![0.0f32; vertices_per_row]; vertices_per_row]; + let mut min_h = f32::MAX; + let mut max_h = f32::MIN; + + for j in 0..vertices_per_row { + for i in 0..vertices_per_row { + let x = i as f64 * noise_freq; + let z = j as f64 * noise_freq; + let h = fbm.get([x, z]) as f32 * height_multiplier; + heightmap[j][i] = h; + if h < min_h { + min_h = h; + } + if h > max_h { + max_h = h; + } + } + } + + let mut vertices: Vec = Vec::with_capacity(total_vertices * 8); + + for j in 0..vertices_per_row { + for i in 0..vertices_per_row { + let px = (i as f32 - half) * cell_size; + let pz = (j as f32 - half) * cell_size; + let py = heightmap[j][i]; + + let h_left = if i > 0 { + heightmap[j][i - 1] + } else { + py + }; + let h_right = if i < grid_resolution { + heightmap[j][i + 1] + } else { + py + }; + let h_down = if j > 0 { + heightmap[j - 1][i] + } else { + py + }; + let h_up = if j < grid_resolution { + heightmap[j + 1][i] + } else { + py + }; + + let tangent_x = + glam::Vec3::new(2.0 * cell_size, h_right - h_left, 0.0).normalize(); + let tangent_z = + glam::Vec3::new(0.0, h_up - h_down, 2.0 * cell_size).normalize(); + let normal = tangent_z.cross(tangent_x).normalize(); + + let u = i as f32 / grid_resolution as f32; + let v = j as f32 / grid_resolution as f32; + + vertices.push(px); + vertices.push(py); + vertices.push(pz); + vertices.push(normal.x); + vertices.push(normal.y); + vertices.push(normal.z); + vertices.push(u); + vertices.push(v); + } + } + + let mut indices: Vec = Vec::with_capacity(grid_resolution * grid_resolution * 6); + let vpr = vertices_per_row as u32; + + for j in 0..grid_resolution as u32 { + for i in 0..grid_resolution as u32 { + let a = i + j * vpr; + let b = i + 1 + j * vpr; + let c = i + (j + 1) * vpr; + let d = i + 1 + (j + 1) * vpr; + + indices.push(a); + indices.push(c); + indices.push(b); + + indices.push(b); + indices.push(c); + indices.push(d); + } + } + + let index_count = indices.len() as i32; + + TerrainMesh { + vertices, + indices, + index_count, + min_height: min_h, + max_height: max_h, + heightmap, + grid_resolution, + world_scale, + } +} + +pub fn compute_ocean_mask( + heightmap: &[Vec], + sea_level: f32, + grid_resolution: usize, +) -> Vec { + let size = grid_resolution + 1; + let mut visited = vec![false; size * size]; + let mut queue = VecDeque::new(); + + for i in 0..size { + queue.push_back((i, 0)); + queue.push_back((i, size - 1)); + } + for j in 1..size - 1 { + queue.push_back((0, j)); + queue.push_back((size - 1, j)); + } + + while let Some((i, j)) = queue.pop_front() { + if visited[j * size + i] { + continue; + } + if heightmap[j][i] >= sea_level { + continue; + } + visited[j * size + i] = true; + + if i > 0 { + queue.push_back((i - 1, j)); + } + if i < size - 1 { + queue.push_back((i + 1, j)); + } + if j > 0 { + queue.push_back((i, j - 1)); + } + if j < size - 1 { + queue.push_back((i, j + 1)); + } + } + + let dilated = dilate_mask(&visited, size); + + let mut mask = vec![0u8; size * size * 4]; + for j in 0..size { + for i in 0..size { + let v = if dilated[j * size + i] { 255u8 } else { 0u8 }; + let idx = (j * size + i) * 4; + mask[idx] = v; + mask[idx + 1] = v; + mask[idx + 2] = v; + mask[idx + 3] = 255; + } + } + + mask +} + +fn dilate_mask(visited: &[bool], size: usize) -> Vec { + let mut result = visited.to_vec(); + for j in 0..size { + for i in 0..size { + if visited[j * size + i] { + continue; + } + let mut has_neighbor = false; + for (di, dj) in &[(-1, 0), (1, 0), (0, -1), (0, 1)] { + let ni = i as isize + di; + let nj = j as isize + dj; + if ni >= 0 && nj >= 0 && ni < size as isize && nj < size as isize { + if visited[nj as usize * size + ni as usize] { + has_neighbor = true; + break; + } + } + } + if has_neighbor { + result[j * size + i] = true; + } + } + } + result +} diff --git a/src/water.rs b/src/water.rs new file mode 100644 index 0000000..c28facf --- /dev/null +++ b/src/water.rs @@ -0,0 +1,66 @@ +pub struct WaterMesh { + pub vertices: Vec, + pub indices: Vec, + pub index_count: i32, +} + +pub fn generate_water( + terrain_scale: f32, + terrain_min_h: f32, + terrain_max_h: f32, + grid_resolution: usize, +) -> WaterMesh { + let sea_level = terrain_min_h + (terrain_max_h - terrain_min_h) * 0.40; + let water_scale = terrain_scale * 2.0; + let vertices_per_row = grid_resolution + 1; + let total_vertices = vertices_per_row * vertices_per_row; + let half = grid_resolution as f32 * 0.5; + let cell_size = water_scale / grid_resolution as f32; + + let mut vertices: Vec = Vec::with_capacity(total_vertices * 8); + + for j in 0..vertices_per_row { + for i in 0..vertices_per_row { + let px = (i as f32 - half) * cell_size; + let pz = (j as f32 - half) * cell_size; + let py = sea_level; + + vertices.push(px); + vertices.push(py); + vertices.push(pz); + vertices.push(0.0); + vertices.push(1.0); + vertices.push(0.0); + vertices.push(i as f32 / grid_resolution as f32); + vertices.push(j as f32 / grid_resolution as f32); + } + } + + let mut indices: Vec = Vec::with_capacity(grid_resolution * grid_resolution * 6); + let vpr = vertices_per_row as u32; + + for j in 0..grid_resolution as u32 { + for i in 0..grid_resolution as u32 { + let a = i + j * vpr; + let b = i + 1 + j * vpr; + let c = i + (j + 1) * vpr; + let d = i + 1 + (j + 1) * vpr; + + indices.push(a); + indices.push(c); + indices.push(b); + + indices.push(b); + indices.push(c); + indices.push(d); + } + } + + let index_count = indices.len() as i32; + + WaterMesh { + vertices, + indices, + index_count, + } +}