Implement a basic engine with separate modules

This commit is contained in:
reo 2025-06-29 23:17:53 +03:00
parent 0135974d08
commit 95070f854c
20 changed files with 1253 additions and 117 deletions

24
src/camera.rs Normal file
View file

@ -0,0 +1,24 @@
use glam::{Mat4, Vec3};
#[derive(Copy, Clone)]
pub struct Camera {
pub eye: Vec3,
pub center: Vec3,
pub up: Vec3,
pub fovy: f32,
pub aspect: f32,
pub znear: f32,
pub zfar: f32,
}
impl Camera {
pub fn view(&self) -> Mat4 {
Mat4::look_at_rh(self.eye, self.center, self.up)
}
pub fn projection(&self) -> Mat4 {
Mat4::perspective_rh(self.fovy, self.aspect, self.znear, self.zfar)
}
pub fn view_proj(&self) -> Mat4 {
self.projection() * self.view()
}
}

32
src/ecs.rs Normal file
View file

@ -0,0 +1,32 @@
use glam::{Mat4, Quat, Vec3};
use hecs::World;
/// ------------ components ------------
#[derive(Copy, Clone)]
pub struct Transform {
pub translation: Vec3,
pub rotation: Quat,
pub scale: Vec3,
}
impl Transform {
pub fn matrix(&self) -> Mat4 {
Mat4::from_scale_rotation_translation(self.scale, self.rotation, self.translation)
}
}
#[derive(Clone)]
pub struct MeshHandle(pub usize);
/// ------------ systems ------------
pub fn rotation_system(world: &mut World, dt: f32) {
for (_, transform) in world.query_mut::<&mut Transform>() {
transform.rotation *= Quat::from_rotation_y(dt);
}
}
/// Update the aspect ratio for all camera components in the world.
pub fn set_camera_aspect(world: &mut World, aspect: f32) {
for (_, cam) in world.query_mut::<&mut crate::camera::Camera>() {
cam.aspect = aspect;
}
}

View file

@ -1,127 +1,86 @@
#[macro_use]
extern crate glium;
use glium::Surface;
mod teapot;
mod camera;
mod ecs;
mod model;
mod render;
fn main() {
use anyhow::Result;
use camera::Camera;
use ecs::{rotation_system, MeshHandle, Transform};
use glam::{Quat, Vec3};
use glium::backend::glutin::SimpleWindowBuilder;
use render::{Renderer, GliumRenderer};
use std::time::Instant;
fn main() -> Result<()> {
let event_loop = glium::winit::event_loop::EventLoop::builder()
.build()
.expect("event loop building");
let (window, display) = glium::backend::glutin::SimpleWindowBuilder::new()
.with_title("Glium tutorial #3")
.expect("create event-loop");
let (window, display) = SimpleWindowBuilder::new()
.with_title("fps")
.with_inner_size(1280, 720)
.build(&event_loop);
let mut world = hecs::World::new();
let positions = glium::VertexBuffer::new(&display, &teapot::VERTICES).unwrap();
let normals = glium::VertexBuffer::new(&display, &teapot::NORMALS).unwrap();
let indices = glium::IndexBuffer::new(&display, glium::index::PrimitiveType::TrianglesList,
&teapot::INDICES).unwrap();
let mesh = model::load_gltf("resources/monkey-smooth.gltf", &display)?;
// let mesh = model::cube(&display)?;
let mut renderer = GliumRenderer::new(display)?;
let mesh_id = renderer.meshes.len();
renderer.meshes.push(mesh);
let vertex_shader_src = r#"
#version 140
world.spawn((
Transform {
translation: Vec3::ZERO,
rotation: Quat::IDENTITY,
scale: Vec3::ONE,
},
MeshHandle(mesh_id),
));
in vec3 position;
in vec3 normal;
{
let (w, h): (u32, u32) = window.inner_size().into();
world.spawn((Camera {
eye: Vec3::new(3.0, 2.0, 3.0),
center: Vec3::ZERO,
up: Vec3::Y,
fovy: 45_f32.to_radians(),
aspect: w as f32 / h as f32,
znear: 0.1,
zfar: 100.0,
},));
}
out vec3 v_normal;
event_loop
.run(move |event, el| {
use glium::winit::event::{Event, WindowEvent};
uniform mat4 matrix;
uniform mat4 perspective;
void main() {
v_normal = transpose(inverse(mat3(matrix))) * normal;
gl_Position = perspective * matrix * vec4(position, 1.0);
}
"#;
let fragment_shader_src = r#"
#version 140
in vec3 v_normal;
out vec4 color;
uniform vec3 u_light;
void main() {
float brightness = dot(normalize(v_normal), normalize(u_light));
vec3 dark_color = vec3(0.6, 0.0, 0.0);
vec3 regular_color = vec3(1.0, 0.0, 0.0);
color = vec4(mix(dark_color, regular_color, brightness), 1.0);
}
"#;
let program = glium::Program::from_source(&display, vertex_shader_src, fragment_shader_src, None).unwrap();
let light = [-1.0, 0.4, 0.9f32];
let mut t: f32 = 0.0;
#[allow(deprecated)]
event_loop.run(move |ev, window_target| {
match ev {
glium::winit::event::Event::WindowEvent { event, .. } => match event {
glium::winit::event::WindowEvent::CloseRequested => {
window_target.exit();
match event {
Event::WindowEvent { event, .. } => match event {
WindowEvent::CloseRequested => el.exit(),
WindowEvent::Resized(sz) => {
ecs::set_camera_aspect(&mut world, sz.width as f32 / sz.height as f32);
}
WindowEvent::RedrawRequested => {
renderer.render(&world);
}
_ => {}
},
// We now need to render everyting in response to a RedrawRequested event due to the animation
glium::winit::event::WindowEvent::RedrawRequested => {
let mut target = display.draw();
target.clear_color_and_depth((0.0, 0.0, 0.0, 1.0), 1.0);
t += 0.01;
let x = t.sin() * 0.5;
let y = t.cos() * 0.5;
let perspective = {
let (width, height) = target.get_dimensions();
let aspect_ratio = height as f32 / width as f32;
let fov: f32 = 3.141592 / 3.0;
let zfar = 1024.0;
let znear = 0.1;
let f = 1.0 / (fov / 2.0).tan();
[
[f * aspect_ratio , 0.0, 0.0 , 0.0],
[ 0.0 , f , 0.0 , 0.0],
[ 0.0 , 0.0, (zfar+znear)/(zfar-znear) , 1.0],
[ 0.0 , 0.0, -(2.0*zfar*znear)/(zfar-znear), 0.0],
]
Event::AboutToWait => {
// -- update logic --
let now = Instant::now();
static mut LAST: Option<Instant> = None;
let dt = unsafe { // FIXME
let last = LAST.replace(now).unwrap_or(now);
(now - last).as_secs_f32()
};
rotation_system(&mut world, dt);
let uniforms = uniform! {
matrix: [
[0.01, 0.0, 0.0, 0.0],
[0.0, 0.01, 0.0, 0.0],
[0.0, 0.0, 0.01, 0.0],
[x, y, 2.0, 1.0f32 ],
],
u_light: light,
perspective: perspective
};
let params = glium::DrawParameters {
depth: glium::Depth {
test: glium::draw_parameters::DepthTest::IfLess,
write: true,
.. Default::default()
},
.. Default::default()
};
target.draw((&positions, &normals), &indices, &program, &uniforms,
&params).unwrap();
target.finish().unwrap();
},
// Because glium doesn't know about windows we need to resize the display
// when the window's size has changed.
glium::winit::event::WindowEvent::Resized(window_size) => {
display.resize(window_size.into());
},
_ => (),
},
// By requesting a redraw in response to a RedrawEventsCleared event we get continuous rendering.
// For applications that only change due to user input you could remove this handler.
glium::winit::event::Event::AboutToWait => {
window.request_redraw();
},
_ => (),
}
})
.unwrap();
// ask for next frame
window.request_redraw();
}
_ => {}
}
})
.map_err(Into::into)
}

124
src/model.rs Normal file
View file

@ -0,0 +1,124 @@
//! GPU-ready mesh loader for **glTF 2.0**
//!
//! Loads the first mesh/primitive found in a .gltf/.glb file.
use anyhow::{Context, Result};
use glium::{backend::Facade, implement_vertex, IndexBuffer, VertexBuffer};
use glium::index::PrimitiveType;
use gltf::mesh::util::ReadIndices;
use std::{fmt::Debug, path::Path};
#[derive(Copy, Clone)]
pub struct Vertex {
pub position: [f32; 3],
pub normal: [f32; 3],
}
implement_vertex!(Vertex, position, normal);
pub struct Mesh {
pub vbuf: VertexBuffer<Vertex>,
pub ibuf: IndexBuffer<u32>,
}
/// Load a glTF 2.0 file from disk and upload the first primitive to the GPU.
pub fn load_gltf<P, F>(path: P, facade: &F) -> Result<Mesh>
where
P: AsRef<Path> + Debug, // `gltf::import` wants Debug for diagnostics :contentReference[oaicite:3]{index=3}
F: Facade + ?Sized,
{
// -- parse the asset & bring buffer blobs into memory --
let (doc, buffers, _images) =
gltf::import(path.as_ref()).context("failed to import glTF file")?; // :contentReference[oaicite:4]{index=4}
// -- grab the very first mesh / primitive --
let mesh = doc.meshes().next().context("glTF has no meshes")?;
let primitive = mesh.primitives().next().context("mesh has no primitives")?;
// -- read vertex and index streams using the util::Reader helper --
let reader = primitive.reader(|buf| Some(&buffers[buf.index()].0)); // Reader pattern :contentReference[oaicite:5]{index=5}
let positions : Vec<[f32; 3]> = reader
.read_positions()
.context("primitive is missing POSITION attribute")? // POSITION is mandatory :contentReference[oaicite:6]{index=6}
.collect();
let normals : Vec<[f32; 3]> = reader
.read_normals()
.context("primitive is missing NORMAL attribute")?
.collect();
let indices : Vec<u32> = reader
.read_indices()
.context("primitive has no indices")?
.into_u32()
.collect(); // ReadIndices enum :contentReference[oaicite:7]{index=7}
// -- interleave into our engine's Vertex struct --
let vertices: Vec<Vertex> = positions
.into_iter()
.zip(normals.into_iter())
.map(|(p, n)| Vertex { position: p, normal: n })
.collect();
// -- immutable GPU buffers (fast path in glium) --
let vbuf = VertexBuffer::immutable(facade, &vertices)?; // Immutable VBO :contentReference[oaicite:8]{index=8}
let ibuf = IndexBuffer ::immutable(facade, PrimitiveType::TrianglesList, &indices)?;
Ok(Mesh { vbuf, ibuf })
}
/// Create a unit cube (edge length = 2) with per-face normals.
pub fn cube<F>(facade: &F) -> Result<Mesh>
where
F: Facade + ?Sized,
{
// 24 unique vertices (4 per face) so that each face has a flat normal.
let vertices: [Vertex; 24] = [
// Front (+Z)
Vertex { position: [-1.0, -1.0, 1.0], normal: [ 0.0, 0.0, 1.0] },
Vertex { position: [ 1.0, -1.0, 1.0], normal: [ 0.0, 0.0, 1.0] },
Vertex { position: [ 1.0, 1.0, 1.0], normal: [ 0.0, 0.0, 1.0] },
Vertex { position: [-1.0, 1.0, 1.0], normal: [ 0.0, 0.0, 1.0] },
// Back (-Z)
Vertex { position: [ 1.0, -1.0, -1.0], normal: [ 0.0, 0.0, -1.0] },
Vertex { position: [-1.0, -1.0, -1.0], normal: [ 0.0, 0.0, -1.0] },
Vertex { position: [-1.0, 1.0, -1.0], normal: [ 0.0, 0.0, -1.0] },
Vertex { position: [ 1.0, 1.0, -1.0], normal: [ 0.0, 0.0, -1.0] },
// Left (-X)
Vertex { position: [-1.0, -1.0, -1.0], normal: [-1.0, 0.0, 0.0] },
Vertex { position: [-1.0, -1.0, 1.0], normal: [-1.0, 0.0, 0.0] },
Vertex { position: [-1.0, 1.0, 1.0], normal: [-1.0, 0.0, 0.0] },
Vertex { position: [-1.0, 1.0, -1.0], normal: [-1.0, 0.0, 0.0] },
// Right (+X)
Vertex { position: [ 1.0, -1.0, 1.0], normal: [ 1.0, 0.0, 0.0] },
Vertex { position: [ 1.0, -1.0, -1.0], normal: [ 1.0, 0.0, 0.0] },
Vertex { position: [ 1.0, 1.0, -1.0], normal: [ 1.0, 0.0, 0.0] },
Vertex { position: [ 1.0, 1.0, 1.0], normal: [ 1.0, 0.0, 0.0] },
// Top (+Y)
Vertex { position: [-1.0, 1.0, 1.0], normal: [ 0.0, 1.0, 0.0] },
Vertex { position: [ 1.0, 1.0, 1.0], normal: [ 0.0, 1.0, 0.0] },
Vertex { position: [ 1.0, 1.0, -1.0], normal: [ 0.0, 1.0, 0.0] },
Vertex { position: [-1.0, 1.0, -1.0], normal: [ 0.0, 1.0, 0.0] },
// Bottom (-Y)
Vertex { position: [-1.0, -1.0, -1.0], normal: [ 0.0, -1.0, 0.0] },
Vertex { position: [ 1.0, -1.0, -1.0], normal: [ 0.0, -1.0, 0.0] },
Vertex { position: [ 1.0, -1.0, 1.0], normal: [ 0.0, -1.0, 0.0] },
Vertex { position: [-1.0, -1.0, 1.0], normal: [ 0.0, -1.0, 0.0] },
];
let mut indices: Vec<u32> = Vec::with_capacity(36);
for face in 0..6 {
let o = (face * 4) as u32;
indices.extend_from_slice(&[o, o + 1, o + 2, o, o + 2, o + 3]);
}
let vbuf = VertexBuffer::immutable(facade, &vertices)?;
let ibuf = IndexBuffer::immutable(facade, PrimitiveType::TrianglesList, &indices)?;
Ok(Mesh { vbuf, ibuf })
}

95
src/render.rs Normal file
View file

@ -0,0 +1,95 @@
use crate::camera::Camera;
use crate::ecs::{MeshHandle, Transform};
use crate::model::Mesh;
use glium::{uniform, Program, Surface};
use glam::Vec3;
use hecs::World;
use glium::glutin::surface::WindowSurface;
/// Generic rendering backend trait.
pub trait Renderer {
/// Render a single frame for the given `World`.
fn render(&mut self, world: &World);
}
/// Concrete OpenGL (glium) renderer implementing `Renderer`.
pub struct GliumRenderer {
display: glium::Display<WindowSurface>,
program: Program,
pub meshes: Vec<Mesh>,
params: glium::DrawParameters<'static>,
}
impl GliumRenderer {
/// Create a new OpenGL renderer consuming the provided `display`.
pub fn new(display: glium::Display<WindowSurface>) -> anyhow::Result<Self> {
const VERT: &str = r#"
#version 330 core
in vec3 position;
in vec3 normal;
uniform mat4 model;
uniform mat4 view;
uniform mat4 projection;
uniform vec3 light_dir;
out vec3 v_color;
void main() {
vec3 n = normalize(mat3(model) * normal);
float diff = max(dot(n, -light_dir), 0.0);
vec3 base = vec3(0.6, 0.6, 0.8);
v_color = base * diff + 0.1;
gl_Position = projection * view * model * vec4(position, 1.0);
}"#;
const FRAG: &str = r#"
#version 330 core
in vec3 v_color;
out vec4 color;
void main() { color = vec4(v_color, 1.0); }"#;
let program = Program::from_source(&display, VERT, FRAG, None)?;
let params = glium::DrawParameters {
depth: glium::Depth {
test: glium::draw_parameters::DepthTest::IfLess,
write: true,
.. Default::default()
},
.. Default::default()
};
Ok(Self { display, program, meshes: Vec::new(), params })
}
}
impl Renderer for GliumRenderer {
fn render(&mut self, world: &World) {
let mut frame = self.display.draw();
frame.clear_color_and_depth((0.1, 0.1, 0.15, 1.0), 1.0);
// Expect exactly one active camera in the world.
let cam = match world.query::<&Camera>().iter().next() {
Some((_, cam)) => *cam,
None => {
eprintln!("[renderer] No camera component found skipping frame");
return;
}
};
let light_dir: Vec3 = Vec3::new(-1.0, -1.0, -1.0).normalize();
for (_, (tr, mh)) in world.query::<(&Transform, &MeshHandle)>().iter() {
let mesh = &self.meshes[mh.0];
let uniforms = uniform! {
model: tr.matrix().to_cols_array_2d(),
view: cam.view().to_cols_array_2d(),
projection: cam.projection().to_cols_array_2d(),
light_dir: [light_dir.x, light_dir.y, light_dir.z],
};
frame.draw(&mesh.vbuf, &mesh.ibuf, &self.program, &uniforms, &self.params)
.unwrap();
}
frame.finish().unwrap();
}
}