Switch to 16-bit truecolor framebuffer and PNG assets

pbio moves to git.bnd.wtf/irrlicht/pbio.git branch truecolor16: the
framebuffer is now RGB565 u16, no palette. Assets are loaded from PNG via
the png crate and quantized to RGB565 on load; transparency comes from the
alpha channel as a per-tile opacity mask, so any color (black included) can
be opaque. The TGA loader and the palette-order fragility are gone.

overworld.png is the unmodified Mini-Medieval Overworld sheet (same 24-column
layout as the old TGA, so map tile ids are unchanged); entities.png holds the
player sprite extracted from the old entities.tga.

Adds an ignored snapshot test that renders the first frame headlessly to a
PNG for eyeballing the renderer without a window.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irrlicht
2026-09-18 23:17:41 +02:00
co-authored by Claude Opus 5
parent 64a6e1d42d
commit 5a055c048d
14 changed files with 212 additions and 96 deletions
+38 -43
View File
@@ -1,49 +1,62 @@
use std::fs::File;
use std::io::Read;
use std::io::BufReader;
/// A decoded image in the framebuffer's pixel format: RGB565 plus a 1-bit alpha
/// mask. Truecolor sources are quantized on load (8 bit → 5/6/5 by truncation), so an
/// asset can never be in the "wrong" palette — there is none.
pub struct Image {
pub width: u32,
pub height: u32,
pub pixels: Vec<u8>,
pub pixels: Vec<u16>,
/// One flag per pixel: `true` = opaque. Alpha ≥ 128 counts as opaque.
pub opaque: Vec<bool>,
}
/// An 8×8 sprite: RGB565 pixels row-major plus an opacity bitmask (bit `y*8+x`).
#[derive(Clone, Copy)]
pub struct Tile {
pub px: [u16; 64],
pub mask: u64,
}
impl Image {
/// Load an 8-bit indexed TGA (type 1 or type 9) from disk.
pub fn from_tga(path: &str) -> Self {
let mut file = File::open(path).unwrap();
let mut data = Vec::new();
file.read_to_end(&mut data).unwrap();
/// Load a PNG from disk. Any color type / bit depth the `png` crate can expand to
/// 8-bit RGBA is accepted.
pub fn from_png(path: &str) -> Self {
let file = File::open(path).unwrap_or_else(|e| panic!("failed to open {path}: {e}"));
let mut decoder = png::Decoder::new(BufReader::new(file));
decoder.set_transformations(png::Transformations::normalize_to_color8() | png::Transformations::ALPHA);
let mut reader = decoder.read_info().unwrap_or_else(|e| panic!("bad PNG {path}: {e}"));
let mut buf = vec![0u8; reader.output_buffer_size().expect("PNG too large")];
let info = reader.next_frame(&mut buf).unwrap_or_else(|e| panic!("bad PNG {path}: {e}"));
assert_eq!(info.color_type, png::ColorType::Rgba, "{path}: expected RGBA after expansion");
let image_type = data[2];
let width = u16::from_le_bytes([data[12], data[13]]) as u32;
let height = u16::from_le_bytes([data[14], data[15]]) as u32;
let colormap_entries = u16::from_le_bytes([data[5], data[6]]) as usize;
let colormap_entry_bytes = data[7] as usize / 8;
let pixel_offset = 18 + colormap_entries * colormap_entry_bytes;
let pixel_count = (width * height) as usize;
let mut pixels = vec![0u8; pixel_count];
match image_type {
1 => pixels.copy_from_slice(&data[pixel_offset..pixel_offset + pixel_count]),
9 => decode_rle(&data[pixel_offset..], &mut pixels),
_ => panic!("unsupported TGA image type: {}", image_type),
let count = (info.width * info.height) as usize;
let mut pixels = Vec::with_capacity(count);
let mut opaque = Vec::with_capacity(count);
for p in buf[..count * 4].chunks_exact(4) {
pixels.push(pbio::rgb565(p[0], p[1], p[2]));
opaque.push(p[3] >= 128);
}
Image { width, height, pixels }
Image { width: info.width, height: info.height, pixels, opaque }
}
/// Split this image into 8×8 tiles, row-major. Tile ID = flat index into the Vec.
pub fn to_tileset(&self) -> Vec<[u8; 64]> {
pub fn to_tileset(&self) -> Vec<Tile> {
let w = self.width as usize;
let cols = w / 8;
let rows = self.height as usize / 8;
let mut tiles = Vec::with_capacity(rows * cols);
for row in 0..rows {
for col in 0..cols {
let mut tile = [0u8; 64];
let mut tile = Tile { px: [0; 64], mask: 0 };
for tr in 0..8 {
for tc in 0..8 {
tile[tr * 8 + tc] = self.pixels[w * (row * 8 + tr) + col * 8 + tc];
let i = w * (row * 8 + tr) + col * 8 + tc;
tile.px[tr * 8 + tc] = self.pixels[i];
if self.opaque[i] {
tile.mask |= 1 << (tr * 8 + tc);
}
}
}
tiles.push(tile);
@@ -52,21 +65,3 @@ impl Image {
tiles
}
}
fn decode_rle(src: &[u8], dst: &mut [u8]) {
let mut si = 0;
let mut di = 0;
while di < dst.len() {
let header = src[si];
si += 1;
let count = (header & 0x7f) as usize + 1;
if header & 0x80 != 0 {
dst[di..di + count].fill(src[si]);
si += 1;
} else {
dst[di..di + count].copy_from_slice(&src[si..si + count]);
si += count;
}
di += count;
}
}
+40 -11
View File
@@ -3,7 +3,7 @@ mod pixelhelper;
use std::collections::{HashMap, VecDeque};
use crate::assets::Image;
use crate::assets::{Image, Tile};
use crate::input::{GameAction, InputState};
use sim::entity::Entity;
use sim::map::TileMap;
@@ -25,8 +25,11 @@ const MOVE_INTERVAL_MS: usize = (TICK_MS * TICKS_PER_MOVE as f32) as usize;
/// drag) is dropped instead of spiralling into a burst of ticks.
const MAX_FRAME_MS: f32 = 250.0;
/// Palette index for the planned route. Dim blue in RGB332 (r=0, g=0, b=2).
const ROUTE_COLOR: u8 = 0b000_000_10;
/// Color of the planned route: dim blue.
const ROUTE_COLOR: u16 = pbio::rgb565(0, 0, 170);
/// Fill for tiles outside the tileset / world.
const VOID_COLOR: u16 = pbio::rgb565(0, 0, 0);
/// Viewport geometry: 30×30 tiles of 8 px, top-left of the framebuffer.
const VIEW_TILES: i32 = 30;
@@ -50,8 +53,8 @@ struct EntityLerp {
}
pub struct Game {
tileset: Vec<[u8; 64]>,
entity_tileset: Vec<[u8; 64]>,
tileset: Vec<Tile>,
entity_tileset: Vec<Tile>,
sim: Sim,
player_id: u32,
/// Frame time not yet consumed by whole ticks.
@@ -74,8 +77,8 @@ pub struct Game {
impl Game {
pub fn start() -> Self {
let tileset = Image::from_tga("assets/tilesets/overworld.tga").to_tileset();
let entity_tileset = Image::from_tga("assets/tilesets/entities.tga").to_tileset();
let tileset = Image::from_png("assets/tilesets/overworld.png").to_tileset();
let entity_tileset = Image::from_png("assets/tilesets/entities.png").to_tileset();
let map = TileMap::from_csv("assets/map_test");
println!("loaded map {}×{} ({} tiles)", map.width, map.height, map.tiles.len());
@@ -109,7 +112,7 @@ impl Game {
self.sim.world.tile_flags(wx as i16, wy as i16).map_or(true, |f| f.collidable())
}
pub fn update(&mut self, render_frame: &mut [u8], dt: usize, input: &InputState)
pub fn update(&mut self, render_frame: &mut [u16], dt: usize, input: &InputState)
-> Option<GameSignal>
{
if input.mouse_clicked() || input.mouse_held() {
@@ -315,8 +318,7 @@ impl Game {
}
}
fn render_viewport(&self, frame: &mut [u8]) {
const WALL: u8 = 0x00;
fn render_viewport(&self, frame: &mut [u16]) {
const W: usize = 320;
frame.fill(0);
@@ -345,7 +347,7 @@ impl Game {
} else {
for dy in 0..8 {
for dx in 0..8 {
pixelhelper::set_pixel(frame, W, px + dx, py + dy, WALL);
pixelhelper::set_pixel(frame, W, px + dx, py + dy, VOID_COLOR);
}
}
}
@@ -383,3 +385,30 @@ impl Game {
}
}
}
#[cfg(test)]
mod snapshot {
/// Renders the first frame headlessly to the PNG named by `$SNAPSHOT` — a way to
/// eyeball the renderer without a window:
/// `SNAPSHOT=/tmp/frame.png cargo test -p game snapshot -- --ignored`
#[test]
#[ignore]
fn dump_frame() {
std::env::set_current_dir("..").unwrap();
let mut g = super::Game::start();
let input = crate::input::InputState::new();
let mut frame = vec![0u16; 320 * 240];
g.update(&mut frame, 0, &input);
let out = std::env::var("SNAPSHOT").unwrap();
let mut rgb = Vec::with_capacity(320 * 240 * 3);
for p in &frame {
rgb.push(((p >> 11) as u8) << 3);
rgb.push((((p >> 5) & 63) as u8) << 2);
rgb.push(((p & 31) as u8) << 3);
}
let f = std::fs::File::create(out).unwrap();
let mut enc = png::Encoder::new(std::io::BufWriter::new(f), 320, 240);
enc.set_color(png::ColorType::Rgb);
enc.write_header().unwrap().write_image_data(&rgb).unwrap();
}
}
+14 -12
View File
@@ -1,6 +1,7 @@
/// Write a single palette index. Out-of-bounds coordinates are silently ignored.
#[allow(dead_code)]
pub fn set_pixel(dst: &mut [u8], dst_w: usize, x: i32, y: i32, color: u8) {
use crate::assets::Tile;
/// Write a single RGB565 pixel. Out-of-bounds coordinates are silently ignored.
pub fn set_pixel(dst: &mut [u16], dst_w: usize, x: i32, y: i32, color: u16) {
let dst_h = (dst.len() / dst_w) as i32;
if x < 0 || x >= dst_w as i32 || y < 0 || y >= dst_h {
return;
@@ -10,8 +11,8 @@ pub fn set_pixel(dst: &mut [u8], dst_w: usize, x: i32, y: i32, color: u8) {
/// Copy a rectangle out of `src` into a new Vec (for building sprites/glyphs).
#[allow(dead_code)]
pub fn extract(src: &[u8], src_w: usize, x: usize, y: usize, w: usize, h: usize) -> Vec<u8> {
let mut out = vec![0u8; w * h];
pub fn extract(src: &[u16], src_w: usize, x: usize, y: usize, w: usize, h: usize) -> Vec<u16> {
let mut out = vec![0u16; w * h];
for row in 0..h {
let s = src_w * (y + row) + x;
let d = w * row;
@@ -21,9 +22,10 @@ pub fn extract(src: &[u8], src_w: usize, x: usize, y: usize, w: usize, h: usize)
}
/// Blit `src` (width `src_w`, height derived from src.len()) into `dst` at (dx, dy).
/// Clips against all four edges of `dst`; the destination position may be negative.
/// Opaque copy, no transparency. Clips against all four edges of `dst`; the
/// destination position may be negative.
#[allow(dead_code)]
pub fn blit(dst: &mut [u8], dst_w: usize, dx: i32, dy: i32, src: &[u8], src_w: usize) {
pub fn blit(dst: &mut [u16], dst_w: usize, dx: i32, dy: i32, src: &[u16], src_w: usize) {
let dst_w_i = dst_w as i32;
let dst_h = (dst.len() / dst_w) as i32;
let src_h = (src.len() / src_w) as i32;
@@ -46,9 +48,9 @@ pub fn blit(dst: &mut [u8], dst_w: usize, dx: i32, dy: i32, src: &[u8], src_w: u
}
}
/// Blit an 8×8 tile with transparency: index 0 is skipped.
/// Blit an 8×8 tile with transparency: pixels whose mask bit is clear are skipped.
/// Clips against all four edges of `dst`; the destination position may be negative.
pub fn blit_tile(dst: &mut [u8], dst_w: usize, dx: i32, dy: i32, tile: &[u8; 64]) {
pub fn blit_tile(dst: &mut [u16], dst_w: usize, dx: i32, dy: i32, tile: &Tile) {
let dst_w_i = dst_w as i32;
let dst_h = (dst.len() / dst_w) as i32;
@@ -62,9 +64,9 @@ pub fn blit_tile(dst: &mut [u8], dst_w: usize, dx: i32, dy: i32, tile: &[u8; 64]
if x < 0 || x >= dst_w_i {
continue;
}
let px = tile[(row * 8 + col) as usize];
if px != 0 {
dst[dst_w * y as usize + x as usize] = px;
let i = (row * 8 + col) as usize;
if tile.mask & (1 << i) != 0 {
dst[dst_w * y as usize + x as usize] = tile.px[i];
}
}
}
-1
View File
@@ -11,7 +11,6 @@ fn main() {
window_size: (800, 600),
framebuffer_size: (320, 240),
aspect_ratio: Some(4.0 / 3.0),
palette: pbio::palette::RGB332,
vsync: true,
mouse_capture: false,
mouse_visible: true,