chore: initial commit

This commit is contained in:
Rodrigo Verdiani 2026-06-16 14:34:31 -03:00
commit 2db9ca215a
14 changed files with 1363 additions and 0 deletions

50
.gitignore vendored Normal file
View File

@ -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

11
Cargo.toml Normal file
View File

@ -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"

70
README.md Normal file
View File

@ -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)

103
src/camera.rs Normal file
View File

@ -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();
}
}

29
src/input.rs Normal file
View File

@ -0,0 +1,29 @@
use std::collections::HashSet;
use sdl3::keyboard::Scancode;
pub struct InputState {
pub keys: HashSet<Scancode>,
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;
}
}

175
src/main.rs Normal file
View File

@ -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(())
}

334
src/renderer.rs Normal file
View File

@ -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::<f32>() 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::<f32>()) as i32,
);
gl.enable_vertex_attrib_array(1);
gl.vertex_attrib_pointer_f32(
2,
2,
glow::FLOAT,
false,
stride,
(6 * std::mem::size_of::<f32>()) 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<Self, String> {
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<glow::Texture, 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::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) {
}
}

98
src/shader.rs Normal file
View File

@ -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<Self, String> {
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<glow::Shader, String> {
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)
}

60
src/shaders/terrain.frag Normal file
View File

@ -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);
}

21
src/shaders/terrain.vert Normal file
View File

@ -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);
}

74
src/shaders/water.frag Normal file
View File

@ -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);
}

60
src/shaders/water.vert Normal file
View File

@ -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);
}

212
src/terrain.rs Normal file
View File

@ -0,0 +1,212 @@
use noise::{NoiseFn, Perlin, Fbm};
use std::collections::VecDeque;
pub struct TerrainMesh {
pub vertices: Vec<f32>,
pub indices: Vec<u32>,
pub index_count: i32,
pub min_height: f32,
pub max_height: f32,
pub heightmap: Vec<Vec<f32>>,
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::<Perlin>::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<f32> = 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<u32> = 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<f32>],
sea_level: f32,
grid_resolution: usize,
) -> Vec<u8> {
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<bool> {
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
}

66
src/water.rs Normal file
View File

@ -0,0 +1,66 @@
pub struct WaterMesh {
pub vertices: Vec<f32>,
pub indices: Vec<u32>,
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<f32> = 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<u32> = 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,
}
}