Files
rnes/src/render.rs
T

130 lines
4.5 KiB
Rust

use eframe::egui;
use crate::input::{key_to_button, Controller, Key};
use crate::palette::index_to_rgb;
pub trait Renderer {
fn present(&mut self, framebuffer: &[u8], width: u16, height: u16);
fn should_close(&self) -> bool { false } // headless never closes
fn poll_input(&mut self, _controller: &mut Controller) {} // headless: no input
}
pub struct PpmRenderer {
frame: u32,
}
impl PpmRenderer {
pub fn new() -> Self {
Self { frame: 0 }
}
}
impl Renderer for PpmRenderer {
fn present(&mut self, framebuffer: &[u8], width: u16, height: u16) {
std::fs::create_dir_all("renders").expect("failed to create renders dir");
let path = format!("renders/frame_{:04}.ppm", self.frame);
std::fs::write(&path, ppm_bytes(framebuffer, width, height))
.expect("failed to write PPM file");
self.frame += 1;
}
}
// The GUI renderer: egui window, texture upload, keyboard -> controller.
pub struct WindowRenderer {
ctx: egui::Context,
texture: Option<egui::TextureHandle>,
rgb: Vec<u8>, // reusable RGB buffer (palette indices -> RGB)
width: usize,
height: usize,
}
impl WindowRenderer {
pub fn new(cc: &eframe::CreationContext<'_>, width: u16, height: u16) -> Self {
Self {
ctx: cc.egui_ctx.clone(),
texture: None,
rgb: vec![0; width as usize * height as usize * 3],
width: width as usize,
height: height as usize,
}
}
// Called from eframe::App::ui: draws the texture filling the window.
pub fn draw(&mut self, ui: &mut egui::Ui) {
if let Some(tex) = &self.texture {
let avail = ui.available_size();
ui.add(egui::Image::new(tex).fit_to_exact_size(avail));
}
}
}
impl Renderer for WindowRenderer {
fn present(&mut self, framebuffer: &[u8], width: u16, height: u16) {
// palette indices -> RGB
for (i, &idx) in framebuffer.iter().enumerate() {
let (r, g, b) = index_to_rgb(idx);
self.rgb[i * 3] = r;
self.rgb[i * 3 + 1] = g;
self.rgb[i * 3 + 2] = b;
}
let image = egui::ColorImage::from_rgb([self.width, self.height], &self.rgb);
match &mut self.texture {
Some(t) => t.set(image, egui::TextureOptions::NEAREST),
None => {
let t = self.ctx.load_texture("nes-screen", image, egui::TextureOptions::NEAREST);
self.texture = Some(t);
}
}
}
fn poll_input(&mut self, controller: &mut Controller) {
let buttons = self.ctx.input(|i| {
let mut b = 0u8;
if i.key_down(egui::Key::Z) { b |= key_to_button(Key::A); }
if i.key_down(egui::Key::X) { b |= key_to_button(Key::B); }
if i.modifiers.shift { b |= key_to_button(Key::Select); }
if i.key_down(egui::Key::Enter) { b |= key_to_button(Key::Start); }
if i.key_down(egui::Key::ArrowUp) { b |= key_to_button(Key::Up); }
if i.key_down(egui::Key::ArrowDown) { b |= key_to_button(Key::Down); }
if i.key_down(egui::Key::ArrowLeft) { b |= key_to_button(Key::Left); }
if i.key_down(egui::Key::ArrowRight) { b |= key_to_button(Key::Right); }
b
});
controller.set_buttons(buttons);
}
}
pub fn ppm_bytes(framebuffer: &[u8], width: u16, height: u16) -> Vec<u8> {
let mut out = format!("P6\n{width} {height}\n255\n").into_bytes();
for &idx in framebuffer {
let (r, g, b) = index_to_rgb(idx);
out.extend_from_slice(&[r, g, b]);
}
out
}
// Tests
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ppm_bytes_has_correct_header() {
let fb = [0u8; 4];
let bytes = ppm_bytes(&fb, 2, 2);
let expected_header = "P6\n2 2\n255\n";
let got = String::from_utf8_lossy(&bytes[..expected_header.len()]);
assert_eq!(got, expected_header);
}
#[test]
fn ppm_bytes_emits_rgb_per_pixel() {
let fb = [0x21u8, 0x21, 0x00, 0x3F]; // 4 pixels: 2x2
let bytes = ppm_bytes(&fb, 2, 2);
let header = format!("P6\n2 2\n255\n");
let pixels = &bytes[header.len()..];
assert_eq!(pixels.len(), 2 * 2 * 3); // 12 RGB bytes
assert_eq!(&pixels[0..3], &[0x64, 0x8D, 0xFF]); // pixel 0 = 0x21, unchanged
assert_eq!(&pixels[9..12], &[0x66, 0x87, 0x96]); // pixel 3 = 0x3F (was 6..9)
}
}