Search code examples
rustgame-engine

Is there a better way to pass a callback function to a struct in Rust?


I'm building a game engine / CPU 3D renderer to learn the basics of rendering following a course.

I'm trying to organize the code so that things are encapsulated and I can provide a public API that's easy to use.

For this, I want to provide this engine.on_update(|&mut engine| {}) method so users can pass in their own code and use the engine in there. This callback is then executed as part of the engine's update loop.

Here is what this looks like now (I trimmed the code to make this easier as its already quite long)

This is the engine module.

use std::time::{Duration, Instant};

use sdl2::{
    event::Event,
    keyboard::Keycode,
    pixels::PixelFormatEnum,
    render::{Canvas, Texture, UpdateTextureError},
    video::Window,
    EventPump,
};

use crate::buffer::{ClearAuto, ClearColor, ColorBuffer, Drawable};
use crate::utils::NumOption;

pub struct EngineConfigParams {
    pub window_title: Option<String>,
    pub width: Option<usize>,
    pub height: Option<usize>,
    pub clear_color: Option<u32>,
    pub fps: Option<u32>,
}

impl Default for EngineConfigParams {
    fn default() -> Self {
        EngineConfigParams {
            window_title: None,
            width: None,
            height: None,
            clear_color: None,
            fps: None,
        }
    }
}

pub struct EngineConfig {
    window_title: String,
    width: usize,
    height: usize,
    clear_color: u32,
    fps: u32,
}

impl EngineConfig {
    pub fn new(params: EngineConfigParams) -> Self {
        let default = EngineConfig::default();
        EngineConfig {
            window_title: params.window_title.unwrap_or(default.window_title),
            width: params.width.unwrap_gt_or(0, default.width),
            height: params.height.unwrap_gt_or(0, default.height),
            clear_color: params.clear_color.unwrap_or(default.clear_color),
            fps: params.fps.unwrap_gt_or(0, default.fps),
        }
    }

    pub fn window_title(&self) -> &String {
        &self.window_title
    }

    pub fn width(&self) -> usize {
        self.width
    }

    pub fn height(&self) -> usize {
        self.height
    }

    pub fn clear_color(&self) -> u32 {
        self.clear_color
    }

    pub fn fps(&self) -> u32 {
        self.fps
    }
}

impl Default for EngineConfig {
    fn default() -> Self {
        EngineConfig {
            window_title: "3D renderer".to_string(),
            width: 800,
            height: 600,
            clear_color: 0xFF000000,
            fps: 60,
        }
    }
}

type EngineUpdateFn<'a> = &'a mut dyn FnMut(&mut EngineCore);

pub struct Engine<'a> {
    core: EngineCore,
    update: Option<EngineUpdateFn<'a>>,
    previous_frame_time: Instant,
    target_frame_time: Duration,
}

impl<'a> Engine<'a> {
    pub fn build(mut config: EngineConfig) -> Engine<'a> {
        let ctx = sdl2::init().unwrap();
        let video = ctx.video().unwrap();

        match video.display_mode(0, 0) {
            Ok(mode) => {
                config.width = mode.w as usize;
                config.height = mode.h as usize;
                println!("Display mode: {:?}", mode);
            }
            Err(e) => eprintln!(
                "Failed to get display mode: {}, using default width and height",
                e
            ),
        };

        let width = config.width;
        let height = config.height;

        let window = video
            .window(&config.window_title, width as u32, height as u32)
            .borderless()
            .position_centered()
            .fullscreen()
            .build()
            .unwrap();

        let canvas = window.into_canvas().build().unwrap();

        let color_buffer = ColorBuffer::new(width, height);

        let event_pump = ctx.event_pump().unwrap();

        println!("WindowCtx w: {} h: {}", width, height);

        let fps = config.fps;

        Engine {
            core: EngineCore {
                config,
                canvas,
                color_buffer,
                event_pump,
            },
            update: None,
            previous_frame_time: Instant::now(),
            target_frame_time: Duration::new(0, 1_000_000_000u32 / fps),
        }
    }

    pub fn config(&self) -> &EngineConfig {
        &self.core.config
    }

    pub fn on_update(&mut self, f: EngineUpdateFn<'a>) {
        self.update = Some(f);
        self.update();
    }

    pub fn user_update(&mut self) {
        self.update.as_mut().unwrap()(&mut self.core);
    }

    pub fn update(&mut self) {
        self.target_frame_time = Duration::new(0, 1_000_000_000u32 / self.core.config.fps);
        let texture_creator = self.core.canvas.texture_creator();
        let mut texture = texture_creator
            .create_texture(
                PixelFormatEnum::ARGB8888,
                sdl2::render::TextureAccess::Streaming,
                self.core.config.width as u32,
                self.core.config.height as u32,
            )
            .unwrap();

        let mut running = true;
        while running {
            self.previous_frame_time = Instant::now();

            running = self.core.process_input();

            self.user_update();
            self.core.render_buffer(&mut texture).unwrap();
            self.core.clear();

            let now = Instant::now();
            let frame_time = now - self.previous_frame_time;
            println!(
                "Time this frame {}ms {} FPS",
                frame_time.as_millis(),
                1000u128 / frame_time.as_millis()
            );

            if frame_time.as_nanos() < self.target_frame_time.as_nanos() {
                ::std::thread::sleep(self.target_frame_time - frame_time);
            }
        }
    }
}

pub struct EngineCore {
    config: EngineConfig,
    canvas: Canvas<Window>,
    color_buffer: ColorBuffer,
    event_pump: EventPump,
}

impl EngineCore {
    fn process_input(&mut self) -> bool {
        for event in self.event_pump.poll_iter() {
            match event {
                Event::Quit { .. }
                | Event::KeyDown {
                    keycode: Some(Keycode::Escape),
                    ..
                } => {
                    println!("Received quit event, shutting down");
                    return false;
                }
                _ => {}
            }
        }
        true
    }

    fn render_buffer(&mut self, texture: &mut Texture) -> Result<(), UpdateTextureError> {
        self.copy_buffer_to_canvas(texture)?;

        self.canvas.present();

        Ok(())
    }

    fn copy_buffer_to_canvas(&mut self, texture: &mut Texture) -> Result<(), UpdateTextureError> {
        texture.update(None, self.color_buffer.pixels(), self.config.width * 4)?;
        self.canvas.copy(texture, None, None).unwrap();

        Ok(())
    }

    pub fn config(&self) -> &EngineConfig {
        &self.config
    }
}

impl ClearAuto for EngineCore {
    fn clear(&mut self) {
        self.color_buffer.clear(self.config.clear_color);
    }
}

impl Drawable for EngineCore {
    fn draw_grid(&mut self, spacing: usize, color: Option<u32>) {
        self.color_buffer.draw_grid(spacing, color);
    }

    fn draw_rect(&mut self, x: usize, y: usize, width: usize, height: usize, color: u32) {
        self.color_buffer.draw_rect(x, y, width, height, color);
    }

    fn draw_point(&mut self, x: usize, y: usize, color: u32) {
        self.color_buffer.draw_point(x, y, color);
    }
}

And in main.rs (Again trimmed of the mesh/points manipulations)

use renderer3d::prelude::*;
use vecx::Vec3;

pub fn main() {
    let mut eng = Engine::build(EngineConfig {
        window_title: "3d Renderer".to_string(),
        width: 800,
        height: 600,
        clear_color: 0xFF000000,
    });

    let points = build_cube();

    let fov = 640.0;
    let cam_pos = Vec3(0.0, 0.0, -5.0);
    let mut rotation = Vec3(0.0, 0.0, 0.0);

    println!("Start update");
    eng.on_update(&mut |eng: &mut Engine| {
        eng.draw_grid(10, Some(0xFF333333));
        rotation = rotation + Vec3(0.01, 0.02, 0.0);

        let mut points = transform_points(&points, rotation);
        points = project_points(&points, cam_pos, fov);

        points.iter().for_each(|point| {
            let mut x = point.x();
            let mut y = point.y();

            x += eng.config().width as f64 / 2.0;
            y += eng.config().height as f64 / 2.0;

            eng.draw_rect(x as usize, y as usize, 4, 4, 0xFFFF0000);
        });
    });
}

As you can see, it's important that this callback can access and mutate variables from the outer scope.

I've been through a lot of trial and error and I ended up finding something that works but the fact I have to do unsafe code makes me wonder if that's really the correct way to do this.

Especially I'm worried that I'm trying to design this too much as I would in other less safe/restrictive languages and that I'm missing how to design this properly in Rust.

Update

Here is the full final code after applying the accepted answer's solution

engine module

use std::time::{Duration, Instant};

use sdl2::{
    event::Event,
    keyboard::Keycode,
    pixels::PixelFormatEnum,
    render::{Canvas, Texture, UpdateTextureError},
    video::Window,
    EventPump,
};

use crate::buffer::{ClearAuto, ClearColor, ColorBuffer, Drawable};
use crate::utils::NumOption;

pub struct EngineConfigParams {
    pub window_title: Option<String>,
    pub width: Option<usize>,
    pub height: Option<usize>,
    pub clear_color: Option<u32>,
    pub fps: Option<u32>,
}

impl Default for EngineConfigParams {
    fn default() -> Self {
        EngineConfigParams {
            window_title: None,
            width: None,
            height: None,
            clear_color: None,
            fps: None,
        }
    }
}

pub struct EngineConfig {
    window_title: String,
    width: usize,
    height: usize,
    clear_color: u32,
    fps: u32,
}

impl EngineConfig {
    pub fn new(params: EngineConfigParams) -> Self {
        let default = EngineConfig::default();
        EngineConfig {
            window_title: params.window_title.unwrap_or(default.window_title),
            width: params.width.unwrap_gt_or(0, default.width),
            height: params.height.unwrap_gt_or(0, default.height),
            clear_color: params.clear_color.unwrap_or(default.clear_color),
            fps: params.fps.unwrap_gt_or(0, default.fps),
        }
    }

    pub fn window_title(&self) -> &String {
        &self.window_title
    }

    pub fn width(&self) -> usize {
        self.width
    }

    pub fn height(&self) -> usize {
        self.height
    }
}

impl Default for EngineConfig {
    fn default() -> Self {
        EngineConfig {
            window_title: "3D renderer".to_string(),
            width: 800,
            height: 600,
            clear_color: 0xFF000000,
            fps: 60,
        }
    }
}

type EngineUpdateFn<'a> = &'a mut dyn FnMut(&mut EngineCore);

pub struct Engine<'a> {
    core: EngineCore,
    update: Option<EngineUpdateFn<'a>>,
    previous_frame_time: Instant,
    target_frame_time: Duration,
}

impl<'a> Engine<'a> {
    pub fn build(mut config: EngineConfig) -> Engine<'a> {
        let ctx = sdl2::init().unwrap();
        let video = ctx.video().unwrap();

        config = EngineConfig {
            width: config.width,
            height: config.height,

            ..EngineConfig::default()
        };

        match video.display_mode(0, 0) {
            Ok(mode) => {
                config.width = mode.w as usize;
                config.height = mode.h as usize;
                println!("Display mode: {:?}", mode);
            }
            Err(e) => eprintln!(
                "Failed to get display mode: {}, using default width and height",
                e
            ),
        };

        let width = config.width;
        let height = config.height;

        let window = video
            .window(&config.window_title, width as u32, height as u32)
            .borderless()
            .position_centered()
            .fullscreen()
            .build()
            .unwrap();

        let canvas = window.into_canvas().build().unwrap();

        let color_buffer = ColorBuffer::new(width, height);

        let event_pump = ctx.event_pump().unwrap();

        println!("WindowCtx w: {} h: {}", width, height);

        let fps = config.fps;

        Engine {
            core: EngineCore {
                config,
                canvas,
                color_buffer,
                event_pump,
            },
            update: None,
            previous_frame_time: Instant::now(),
            target_frame_time: Duration::new(0, 1_000_000_000u32 / fps),
        }
    }

    pub fn config(&self) -> &EngineConfig {
        &self.core.config
    }

    pub fn on_update(&mut self, f: EngineUpdateFn<'a>) {
        self.update = Some(f);
        self.update();
    }

    pub fn user_update(&mut self) {
        self.update.as_mut().unwrap()(&mut self.core);
    }

    pub fn update(&mut self) {
        self.target_frame_time = Duration::new(0, 1_000_000_000u32 / self.core.config.fps);
        let texture_creator = self.core.canvas.texture_creator();
        let mut texture = texture_creator
            .create_texture(
                PixelFormatEnum::ARGB8888,
                sdl2::render::TextureAccess::Streaming,
                self.core.config.width as u32,
                self.core.config.height as u32,
            )
            .unwrap();

        let mut running = true;
        while running {
            self.previous_frame_time = Instant::now();

            running = self.core.process_input();

            self.user_update();
            self.core.render_buffer(&mut texture).unwrap();
            self.core.clear();

            let now = Instant::now();
            let frame_time = now - self.previous_frame_time;
            println!(
                "Time this frame {}ms {} FPS",
                frame_time.as_millis(),
                1000u128 / frame_time.as_millis()
            );

            if frame_time.as_nanos() < self.target_frame_time.as_nanos() {
                ::std::thread::sleep(self.target_frame_time - frame_time);
            }
        }
    }
}

pub struct EngineCore {
    config: EngineConfig,
    canvas: Canvas<Window>,
    color_buffer: ColorBuffer,
    event_pump: EventPump,
}

impl EngineCore {
    fn process_input(&mut self) -> bool {
        for event in self.event_pump.poll_iter() {
            match event {
                Event::Quit { .. }
                | Event::KeyDown {
                    keycode: Some(Keycode::Escape),
                    ..
                } => {
                    println!("Received quit event, shutting down");
                    return false;
                }
                _ => {}
            }
        }
        true
    }

    fn render_buffer(&mut self, texture: &mut Texture) -> Result<(), UpdateTextureError> {
        self.copy_buffer_to_canvas(texture)?;

        self.canvas.present();

        Ok(())
    }

    fn copy_buffer_to_canvas(&mut self, texture: &mut Texture) -> Result<(), UpdateTextureError> {
        texture.update(None, self.color_buffer.pixels(), self.config.width * 4)?;
        self.canvas.copy(texture, None, None).unwrap();

        Ok(())
    }

    pub fn config(&self) -> &EngineConfig {
        &self.config
    }
}

impl ClearAuto for EngineCore {
    fn clear(&mut self) {
        self.color_buffer.clear(self.config.clear_color);
    }
}

impl Drawable for EngineCore {
    fn draw_grid(&mut self, spacing: usize, color: Option<u32>) {
        self.color_buffer.draw_grid(spacing, color);
    }

    fn draw_rect(&mut self, x: usize, y: usize, width: usize, height: usize, color: u32) {
        self.color_buffer.draw_rect(x, y, width, height, color);
    }

    fn draw_point(&mut self, x: usize, y: usize, color: u32) {
        self.color_buffer.draw_point(x, y, color);
    }
}

main.rs

use renderer3d::{prelude::*, Mesh};
use vecx::Vec3;

pub fn main() {
    let mut eng = Engine::build(EngineConfig::new(EngineConfigParams {
        window_title: Some("3d Renderer".to_string()),
        ..EngineConfigParams::default()
    }));

    let cube = Mesh::cube();

    let fov = 640.0;
    let cam_pos = Vec3(0.0, 0.0, -5.0);
    let mut rotation = Vec3(0.0, 0.0, 0.0);

    println!("Start update");
    eng.on_update(&mut |eng| {
        eng.draw_grid(10, Some(0xFF333333));
        rotation = rotation + Vec3(0.01, 0.02, 0.0);

        let mut points = cube
            .face_vertices()
            .map(|fv| transform_points(&fv, rotation))
            .flatten()
            .collect();

        points = project_points(&points, cam_pos, fov);

        points.iter().for_each(|point| {
            let mut x = point.x();
            let mut y = point.y();

            x += eng.config().width() as f64 / 2.0;
            y += eng.config().height() as f64 / 2.0;

            eng.draw_rect(x as usize, y as usize, 4, 4, 0xFFFF0000);
        });
    });
}

fn transform_points(points: &Vec<Vec3>, rotation: Vec3) -> Vec<Vec3> {
    points.iter().map(|p| p.rot(rotation)).collect()
}

fn project_points(points: &Vec<Vec3>, cam_pos: Vec3, fov: f64) -> Vec<Vec3> {
    points
        .iter()
        .map(|point| {
            let z = point.z() + cam_pos.z();
            let mut x = point.x() / z;
            let mut y = point.y() / z;
            x *= fov;
            y *= fov;

            Vec3(x, y, z)
        })
        .collect()
}

Solution

  • The problem is not the variables from the outer scope, it's the fact that the callback has mutable access to Engine but it itself comes from engine, so there are two coexisting mutable references. Theoretically, it could for example access the captured variables both by the data pointer in Engine and by using them directly from different threads, causing a data race.

    Since you probably don't need access to the callback inside the callback, I would split Engine into two structs: one will contain the callback and the other, and have an update() and on_update() methods, and the other will include all other stuff. This way, the callback will take a mutable reference to the inner struct, which it can - since it is not contained within, but will still be able to call all engine's functions.