feat(video): render regular and affine sprites
Add OAM sampling, affine canvases, tile mapping, priority metadata, OBJ windows, and mosaic composition.
This commit is contained in:
parent
45b45e5c52
commit
1cba1510d0
3 changed files with 868 additions and 0 deletions
213
src/video/sprites.rs
Normal file
213
src/video/sprites.rs
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
//! Regular and affine OBJ sampling.
|
||||
//! Individual fetch timing and OAM contention are not modeled.
|
||||
|
||||
use super::{halfword, Pixel, VideoError, WIDTH};
|
||||
|
||||
const SIZES: [[(usize, usize); 4]; 3] = [
|
||||
[(8, 8), (16, 16), (32, 32), (64, 64)],
|
||||
[(16, 8), (32, 8), (32, 16), (64, 32)],
|
||||
[(8, 16), (8, 32), (16, 32), (32, 64)],
|
||||
];
|
||||
|
||||
pub(super) struct Objects {
|
||||
pub pixels: Vec<Option<Pixel>>,
|
||||
pub window: Vec<bool>,
|
||||
}
|
||||
|
||||
pub(crate) struct PreparedObjects {
|
||||
samples: Vec<Sample>,
|
||||
window: Vec<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct ObjectPixel {
|
||||
palette_index: u16,
|
||||
semi_transparent: bool,
|
||||
}
|
||||
|
||||
// Transparent texels can change priority and mosaic without replacing color.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
struct Sample {
|
||||
pixel: Option<ObjectPixel>,
|
||||
priority: u8,
|
||||
mosaic: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn prepare(
|
||||
control: u16,
|
||||
mosaic: u16,
|
||||
vertical_counter: Option<u8>,
|
||||
oam: &[u8],
|
||||
vram: &[u8],
|
||||
rows: std::ops::Range<usize>,
|
||||
) -> Result<PreparedObjects, VideoError> {
|
||||
// Lower priority numbers win; equal priorities retain the earlier OAM entry.
|
||||
// Color, priority, and mosaic metadata are tracked separately.
|
||||
let mut output = PreparedObjects {
|
||||
samples: vec![Sample::default(); WIDTH * rows.len()],
|
||||
window: vec![false; WIDTH * rows.len()],
|
||||
};
|
||||
if control & 0x1000 == 0 {
|
||||
return Ok(output);
|
||||
}
|
||||
let samples = &mut output.samples;
|
||||
let mosaic_height = usize::from(mosaic >> 12) + 1;
|
||||
let one_dimensional = control & 0x40 != 0;
|
||||
for index in 0..128 {
|
||||
let a = usize::from(halfword(oam, index * 8));
|
||||
let b = usize::from(halfword(oam, index * 8 + 2));
|
||||
let c = usize::from(halfword(oam, index * 8 + 4));
|
||||
if a & 0x300 == 0x200 {
|
||||
continue; // Disabled regular OBJ.
|
||||
}
|
||||
let reason = if a & 0xc00 == 0xc00 {
|
||||
Some("prohibited OBJ mode")
|
||||
} else if a >> 14 == 3 {
|
||||
Some("prohibited OBJ shape")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(reason) = reason {
|
||||
return Err(VideoError::UnsupportedObject { index, reason });
|
||||
}
|
||||
let window = a & 0xc00 == 0x800;
|
||||
// Window coverage is prepared independently of its later display mask.
|
||||
// OBJ-window coverage does not use either mosaic dimension.
|
||||
let mosaic_enabled = a & 0x1000 != 0 && !window;
|
||||
let (width, height) = SIZES[a >> 14][b >> 14];
|
||||
let affine = a & 0x100 != 0;
|
||||
let doubled = affine && a & 0x200 != 0;
|
||||
let canvas_width = width << usize::from(doubled);
|
||||
let canvas_height = height << usize::from(doubled);
|
||||
let matrix = if affine {
|
||||
let base = ((b >> 9) & 31) * 32 + 6;
|
||||
[0, 8, 16, 24].map(|offset| i32::from(halfword(oam, base + offset) as i16))
|
||||
} else {
|
||||
[0; 4]
|
||||
};
|
||||
let eight_bit = a & 0x2000 != 0;
|
||||
let mut tile = c & 0x3ff;
|
||||
if control & 7 >= 3 && tile < 512 {
|
||||
continue; // Bitmap modes reserve the first half of OBJ tile memory.
|
||||
}
|
||||
if eight_bit && !one_dimensional {
|
||||
tile &= !1; // 2D 8bpp tiles are aligned to pairs of 32-byte slots.
|
||||
}
|
||||
let slot_width = if eight_bit { 2 } else { 1 };
|
||||
let priority = ((c >> 10) & 3) as u8;
|
||||
for y in rows.clone() {
|
||||
let sy = (y + 256 - (a & 255)) & 255;
|
||||
if sy >= canvas_height {
|
||||
continue;
|
||||
}
|
||||
// Preparation latches the ahead-of-display OBJ counter; snapshots
|
||||
// use the screen grid.
|
||||
// Clamp partial first blocks to the object's first row, without
|
||||
// extending its canvas. OBJ-window sampling bypasses this counter.
|
||||
let sample_y = if mosaic_enabled {
|
||||
sy.saturating_sub(vertical_counter.map_or(y % mosaic_height, usize::from))
|
||||
} else {
|
||||
sy
|
||||
};
|
||||
for sx in 0..canvas_width {
|
||||
let x = ((b & 511) + sx) & 511;
|
||||
if x >= WIDTH {
|
||||
continue;
|
||||
}
|
||||
let offset = (y - rows.start) * WIDTH + x;
|
||||
let (tx, ty) = if affine {
|
||||
// Signed 8.8 inverse mapping from canvas center to texture center.
|
||||
// Keep fractional products until the final arithmetic shift. Negative
|
||||
// coordinates round down, not toward zero. Outside texels are transparent.
|
||||
let dx = sx as i32 - canvas_width as i32 / 2;
|
||||
let dy = sample_y as i32 - canvas_height as i32 / 2;
|
||||
let tx = (matrix[0] * dx + matrix[1] * dy + ((width as i32) << 7)) >> 8;
|
||||
let ty = (matrix[2] * dx + matrix[3] * dy + ((height as i32) << 7)) >> 8;
|
||||
if tx < 0 || ty < 0 || tx >= width as i32 || ty >= height as i32 {
|
||||
continue;
|
||||
}
|
||||
(tx as usize, ty as usize)
|
||||
} else {
|
||||
(
|
||||
if b & 0x1000 != 0 { width - 1 - sx } else { sx },
|
||||
if b & 0x2000 != 0 {
|
||||
height - 1 - sample_y
|
||||
} else {
|
||||
sample_y
|
||||
},
|
||||
)
|
||||
};
|
||||
let slot = if one_dimensional {
|
||||
tile + (ty / 8 * (width / 8) + tx / 8) * slot_width
|
||||
} else {
|
||||
// A 2D row is 32 slots wide. Horizontal overflow wraps within
|
||||
// that row; vertical overflow wraps within 32 KiB OBJ VRAM.
|
||||
(tile & !31) + (ty / 8) * 32 + ((tile + tx / 8 * slot_width) & 31)
|
||||
};
|
||||
let pixel = (ty % 8) * 8 + tx % 8;
|
||||
let address =
|
||||
0x10000 + ((slot * 32 + if eight_bit { pixel } else { pixel / 2 }) & 0x7fff);
|
||||
let color_index = if eight_bit {
|
||||
usize::from(vram[address])
|
||||
} else {
|
||||
usize::from(vram[address] >> ((pixel & 1) * 4) & 15)
|
||||
};
|
||||
if window && color_index != 0 {
|
||||
output.window[offset] = true;
|
||||
} else {
|
||||
let sample = &mut samples[offset];
|
||||
if sample.pixel.is_none() || priority < sample.priority {
|
||||
if color_index != 0 {
|
||||
let bank = if eight_bit { 0 } else { (c >> 12) * 16 };
|
||||
sample.pixel = Some(ObjectPixel {
|
||||
palette_index: (bank + color_index) as u16,
|
||||
semi_transparent: a & 0xc00 == 0x400,
|
||||
});
|
||||
}
|
||||
sample.priority = priority;
|
||||
sample.mosaic = mosaic_enabled;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
impl PreparedObjects {
|
||||
/// Resolve buffered indices through the current palette. Horizontal mosaic
|
||||
/// belongs to composition, not the previous line's OAM/VRAM preparation.
|
||||
pub(super) fn compose(&self, mosaic: u16, palette: &[u8]) -> Objects {
|
||||
let mut output = Objects {
|
||||
pixels: vec![None; self.samples.len()],
|
||||
window: self.window.clone(),
|
||||
};
|
||||
let mosaic_width = usize::from((mosaic >> 8) & 15) + 1;
|
||||
// Horizontal OBJ mosaic latches the selected object sample, not each
|
||||
// object's texture coordinate. Reset at each scanline and at transitions
|
||||
// to/from non-mosaic samples or a numerically lower priority.
|
||||
for (source, destination) in self
|
||||
.samples
|
||||
.chunks_exact(WIDTH)
|
||||
.zip(output.pixels.chunks_exact_mut(WIDTH))
|
||||
{
|
||||
let mut latched = Sample::default();
|
||||
for (x, (¤t, pixel)) in source.iter().zip(destination).enumerate() {
|
||||
if x % mosaic_width == 0
|
||||
|| !current.mosaic
|
||||
|| !latched.mosaic
|
||||
|| current.priority < latched.priority
|
||||
{
|
||||
latched = current;
|
||||
}
|
||||
*pixel = latched.pixel.map(|p| Pixel {
|
||||
color: halfword(palette, 0x200 + usize::from(p.palette_index) * 2),
|
||||
layer: 4,
|
||||
priority: latched.priority,
|
||||
semi_transparent: p.semi_transparent,
|
||||
});
|
||||
}
|
||||
}
|
||||
output
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue