use crate::framebuffer::Framebuffer;
use oxiui_core::Color;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
pub enum FillRule {
EvenOdd,
#[default]
NonZero,
}
#[derive(Clone, Debug)]
pub(crate) struct Edge {
x: f32,
dx: f32,
y_max: i32,
winding: i32,
}
pub struct RasterizerScratch {
pub(crate) global_edges: Vec<(i32, Edge)>,
pub(crate) active: Vec<Edge>,
}
impl RasterizerScratch {
pub fn new() -> Self {
Self {
global_edges: Vec::new(),
active: Vec::new(),
}
}
pub fn clear(&mut self) {
self.global_edges.clear();
self.active.clear();
}
}
impl Default for RasterizerScratch {
fn default() -> Self {
Self::new()
}
}
pub struct Rasterizer {
scratch: RasterizerScratch,
}
impl Rasterizer {
pub fn new() -> Self {
Self {
scratch: RasterizerScratch::new(),
}
}
pub fn fill_polygon(
&mut self,
fb: &mut Framebuffer,
points: &[(f32, f32)],
color: Color,
fill_rule: FillRule,
aa: bool,
) {
fill_polygon_with_scratch(fb, points, color, fill_rule, aa, &mut self.scratch);
}
pub fn fill_polygon_clipped(
&mut self,
fb: &mut Framebuffer,
points: &[(f32, f32)],
color: Color,
fill_rule: FillRule,
aa: bool,
clip: crate::clip::ClipRect,
) {
fill_polygon_clipped_with_scratch(
fb,
points,
color,
fill_rule,
aa,
clip,
&mut self.scratch,
);
}
}
impl Default for Rasterizer {
fn default() -> Self {
Self::new()
}
}
fn build_edges_into(points: &[(f32, f32)], out: &mut Vec<(i32, Edge)>) {
let n = points.len();
if n < 2 {
return;
}
out.reserve(n);
for i in 0..n {
let (x0, y0) = points[i];
let (x1, y1) = points[(i + 1) % n];
if (y0 - y1).abs() < f32::EPSILON {
continue;
}
let (top_y, bot_y, top_x, bot_x, winding) = if y0 < y1 {
(y0, y1, x0, x1, 1i32)
} else {
(y1, y0, x1, x0, -1i32)
};
let dy = bot_y - top_y;
let dx = (bot_x - top_x) / dy;
let y_start = top_y.ceil() as i32;
let x_at_start = top_x + dx * (y_start as f32 - top_y);
let y_max = bot_y.ceil() as i32;
out.push((
y_start,
Edge {
x: x_at_start,
dx,
y_max,
winding,
},
));
}
}
#[inline]
fn span_coverage(left: f32, right: f32, px: f32) -> f32 {
let pl = px;
let pr = px + 1.0;
if right <= pl || left >= pr {
return 0.0;
}
let cl = left.max(pl);
let cr = right.min(pr);
(cr - cl).clamp(0.0, 1.0)
}
fn fill_polygon_with_scratch(
fb: &mut Framebuffer,
points: &[(f32, f32)],
color: Color,
fill_rule: FillRule,
aa: bool,
scratch: &mut RasterizerScratch,
) {
if points.len() < 3 {
return;
}
let (mut min_y, mut max_y) = (f32::INFINITY, f32::NEG_INFINITY);
for &(_, y) in points {
if y < min_y {
min_y = y;
}
if y > max_y {
max_y = y;
}
}
let raw_y_start = min_y.floor() as i32;
let raw_y_end = max_y.ceil() as i32;
let height_i32 = fb.height().min(i32::MAX as u32) as i32;
let y_start = raw_y_start.clamp(0, height_i32);
let y_end = raw_y_end.clamp(y_start, height_i32);
scratch.clear();
build_edges_into(points, &mut scratch.global_edges);
scratch.global_edges.sort_by(|a, b| {
a.0.cmp(&b.0).then(
a.1.x
.partial_cmp(&b.1.x)
.unwrap_or(core::cmp::Ordering::Equal),
)
});
let mut gi = 0usize;
while gi < scratch.global_edges.len() && scratch.global_edges[gi].0 < y_start {
let (edge_y_start, edge) = &scratch.global_edges[gi];
if edge.y_max > y_start {
let mut e = edge.clone();
e.x += e.dx * (y_start - edge_y_start) as f32;
scratch.active.push(e);
}
gi += 1;
}
for y in y_start..y_end {
while gi < scratch.global_edges.len() && scratch.global_edges[gi].0 == y {
scratch.active.push(scratch.global_edges[gi].1.clone());
gi += 1;
}
scratch.active.retain(|e| e.y_max > y);
scratch
.active
.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap_or(core::cmp::Ordering::Equal));
fill_spans(
fb,
&scratch.active,
y as u32,
y as f32,
color,
fill_rule,
aa,
);
for e in &mut scratch.active {
e.x += e.dx;
}
}
}
pub fn fill_polygon(
fb: &mut Framebuffer,
points: &[(f32, f32)],
color: Color,
fill_rule: FillRule,
aa: bool,
) {
let mut scratch = RasterizerScratch::new();
fill_polygon_with_scratch(fb, points, color, fill_rule, aa, &mut scratch);
}
fn fill_spans(
fb: &mut Framebuffer,
active: &[Edge],
y: u32,
_y_float: f32,
color: Color,
fill_rule: FillRule,
aa: bool,
) {
if y >= fb.height() || active.is_empty() {
return;
}
match fill_rule {
FillRule::EvenOdd => fill_even_odd(fb, active, y, color, aa),
FillRule::NonZero => fill_non_zero(fb, active, y, color, aa),
}
}
fn fill_even_odd(fb: &mut Framebuffer, active: &[Edge], y: u32, color: Color, aa: bool) {
let mut i = 0;
while i + 1 < active.len() {
let left = active[i].x;
let right = active[i + 1].x;
if right > left {
paint_span(fb, left, right, y, color, aa);
}
i += 2;
}
}
fn fill_non_zero(fb: &mut Framebuffer, active: &[Edge], y: u32, color: Color, aa: bool) {
let mut winding = 0i32;
let mut fill_start: Option<f32> = None;
for edge in active {
let prev_winding = winding;
winding += edge.winding;
if prev_winding == 0 && winding != 0 {
fill_start = Some(edge.x);
} else if prev_winding != 0 && winding == 0 {
if let Some(start) = fill_start.take() {
let end = edge.x;
if end > start {
paint_span(fb, start, end, y, color, aa);
}
}
}
}
}
fn paint_span(fb: &mut Framebuffer, left: f32, right: f32, y: u32, color: Color, aa: bool) {
if y >= fb.height() {
return;
}
let width_i32 = fb.width().min(i32::MAX as u32) as i32;
let x0 = (left.floor() as i32).clamp(0, width_i32);
let x1 = (right.ceil() as i32).clamp(x0, width_i32);
let Color(cr, cg, cb, ca) = color;
for px in x0..x1 {
let coverage = if aa {
span_coverage(left, right, px as f32)
} else {
let centre = px as f32 + 0.5;
if centre >= left && centre < right {
1.0
} else {
0.0
}
};
if coverage <= 0.0 {
continue;
}
let alpha = (ca as f32 * coverage).round() as u8;
fb.blend(
px as u32,
y,
crate::framebuffer::pack_rgba(cr, cg, cb, alpha),
);
}
}
fn fill_polygon_clipped_with_scratch(
fb: &mut Framebuffer,
points: &[(f32, f32)],
color: Color,
fill_rule: FillRule,
aa: bool,
clip: crate::clip::ClipRect,
scratch: &mut RasterizerScratch,
) {
if points.len() < 3 {
return;
}
let (mut min_y, mut max_y) = (f32::INFINITY, f32::NEG_INFINITY);
for &(_, y) in points {
if y < min_y {
min_y = y;
}
if y > max_y {
max_y = y;
}
}
let raw_y_start = min_y.floor() as i32;
let raw_y_end = max_y.ceil() as i32;
let height_i32 = fb.height().min(i32::MAX as u32) as i32;
let clip_y0 = clip.y0.clamp(0, height_i32 as i64) as i32;
let clip_y1 = clip.y1.clamp(0, height_i32 as i64) as i32;
let y_clip_start = raw_y_start.max(clip_y0);
let y_end = raw_y_end.min(clip_y1);
scratch.clear();
build_edges_into(points, &mut scratch.global_edges);
scratch.global_edges.sort_by(|a, b| {
a.0.cmp(&b.0).then(
a.1.x
.partial_cmp(&b.1.x)
.unwrap_or(core::cmp::Ordering::Equal),
)
});
let mut gi = 0usize;
while gi < scratch.global_edges.len() && scratch.global_edges[gi].0 < y_clip_start {
let (edge_y_start, edge) = &scratch.global_edges[gi];
if edge.y_max > y_clip_start {
let mut e = edge.clone();
e.x += e.dx * (y_clip_start - edge_y_start) as f32;
scratch.active.push(e);
}
gi += 1;
}
for y in y_clip_start..y_end {
while gi < scratch.global_edges.len() && scratch.global_edges[gi].0 == y {
scratch.active.push(scratch.global_edges[gi].1.clone());
gi += 1;
}
scratch.active.retain(|e| e.y_max > y);
scratch
.active
.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap_or(core::cmp::Ordering::Equal));
fill_spans_clipped(fb, &scratch.active, y as u32, color, fill_rule, aa, &clip);
for e in &mut scratch.active {
e.x += e.dx;
}
}
}
pub fn fill_polygon_clipped(
fb: &mut Framebuffer,
points: &[(f32, f32)],
color: Color,
fill_rule: FillRule,
aa: bool,
clip: crate::clip::ClipRect,
) {
let mut scratch = RasterizerScratch::new();
fill_polygon_clipped_with_scratch(fb, points, color, fill_rule, aa, clip, &mut scratch);
}
fn fill_spans_clipped(
fb: &mut Framebuffer,
active: &[Edge],
y: u32,
color: Color,
fill_rule: FillRule,
aa: bool,
clip: &crate::clip::ClipRect,
) {
if y >= fb.height() || (y as i64) < clip.y0 || (y as i64) >= clip.y1 || active.is_empty() {
return;
}
match fill_rule {
FillRule::EvenOdd => {
let mut i = 0;
while i + 1 < active.len() {
let left = active[i].x.max(clip.x0 as f32);
let right = active[i + 1].x.min(clip.x1 as f32);
if right > left {
paint_span(fb, left, right, y, color, aa);
}
i += 2;
}
}
FillRule::NonZero => {
let mut winding = 0i32;
let mut fill_start: Option<f32> = None;
for edge in active {
let prev_winding = winding;
winding += edge.winding;
if prev_winding == 0 && winding != 0 {
fill_start = Some(edge.x.max(clip.x0 as f32));
} else if prev_winding != 0 && winding == 0 {
if let Some(start) = fill_start.take() {
let end = edge.x.min(clip.x1 as f32);
if end > start {
paint_span(fb, start, end, y, color, aa);
}
}
}
}
}
}
}
pub fn fill_triangle(
fb: &mut Framebuffer,
p0: (f32, f32),
p1: (f32, f32),
p2: (f32, f32),
color: Color,
) {
fill_polygon(fb, &[p0, p1, p2], color, FillRule::NonZero, true);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::framebuffer::Framebuffer;
fn fresh(w: u32, h: u32) -> Framebuffer {
Framebuffer::with_fill(w, h, Color(0, 0, 0, 255))
}
#[test]
fn triangle_fill_coverage() {
let mut fb = fresh(20, 20);
fill_triangle(
&mut fb,
(0.0, 0.0),
(20.0, 0.0),
(10.0, 20.0),
Color(255, 255, 255, 255),
);
let mut count = 0u32;
for y in 0..20 {
for x in 0..20 {
let (r, _, _, _) = fb.get_rgba(x, y).unwrap_or((0, 0, 0, 0));
if r > 0 {
count += 1;
}
}
}
assert!(count >= 50, "expected >= 50 filled pixels, got {count}");
}
#[test]
fn polygon_fill_even_odd() {
let cx = 35.0f32;
let cy = 35.0f32;
let r = 30.0f32;
let star: Vec<(f32, f32)> = (0..5)
.map(|i| {
let angle = std::f32::consts::PI * (2.0 * (2 * i) as f32 / 5.0 - 0.5);
(cx + r * angle.cos(), cy + r * angle.sin())
})
.collect();
let mut fb_eo = fresh(70, 70);
let mut fb_nz = fresh(70, 70);
fill_polygon(
&mut fb_eo,
&star,
Color(255, 0, 0, 255),
FillRule::EvenOdd,
false,
);
fill_polygon(
&mut fb_nz,
&star,
Color(255, 0, 0, 255),
FillRule::NonZero,
false,
);
let (r_eo_tip, _, _, _) = fb_eo.get_rgba(35, 8).unwrap_or((0, 0, 0, 0));
let (r_nz_tip, _, _, _) = fb_nz.get_rgba(35, 8).unwrap_or((0, 0, 0, 0));
assert!(r_eo_tip > 0, "EvenOdd: star arm should be painted");
assert!(r_nz_tip > 0, "NonZero: star arm should be painted");
let (r_nz_ctr, _, _, _) = fb_nz.get_rgba(35, 35).unwrap_or((0, 0, 0, 0));
assert!(r_nz_ctr > 0, "NonZero: star center must be filled");
let (r_eo_ctr, _, _, _) = fb_eo.get_rgba(35, 35).unwrap_or((0, 0, 0, 0));
assert_eq!(
r_eo_ctr, 0,
"EvenOdd: star center should be a hole (r={r_eo_ctr})"
);
}
#[test]
fn fill_rect_via_polygon() {
let mut fb = fresh(10, 10);
let pts = [(2.0f32, 2.0), (8.0, 2.0), (8.0, 8.0), (2.0, 8.0)];
fill_polygon(
&mut fb,
&pts,
Color(0, 255, 0, 255),
FillRule::NonZero,
false,
);
assert_eq!(fb.get_rgba(5, 5), Some((0, 255, 0, 255)));
assert_eq!(fb.get_rgba(0, 0), Some((0, 0, 0, 255)));
}
#[test]
fn fill_rule_noop_on_empty_polygon() {
let mut fb = fresh(5, 5);
fill_polygon(
&mut fb,
&[],
Color(255, 0, 0, 255),
FillRule::NonZero,
false,
);
assert_eq!(fb.get_rgba(2, 2), Some((0, 0, 0, 255)));
}
#[test]
fn test_scanline_reuse_output_identical() {
let triangle: &[(f32, f32)] = &[(10.0, 90.0), (50.0, 10.0), (90.0, 90.0)];
let color = Color(200, 100, 50, 255);
let mut fb1 = fresh(100, 100);
let mut ras = Rasterizer::new();
ras.fill_polygon(&mut fb1, triangle, color, FillRule::NonZero, true);
let mut fb2 = fresh(100, 100);
ras.fill_polygon(&mut fb2, triangle, color, FillRule::NonZero, true);
for y in 0..100 {
for x in 0..100 {
let p1 = fb1.get_rgba(x, y);
let p2 = fb2.get_rgba(x, y);
assert_eq!(
p1, p2,
"pixel ({x},{y}) differs: first={p1:?} second={p2:?}"
);
}
}
let mut fb3 = fresh(100, 100);
fill_polygon(&mut fb3, triangle, color, FillRule::NonZero, true);
for y in 0..100 {
for x in 0..100 {
let p1 = fb1.get_rgba(x, y);
let p3 = fb3.get_rgba(x, y);
assert_eq!(
p1, p3,
"pixel ({x},{y}): Rasterizer={p1:?} vs free fn={p3:?}"
);
}
}
}
#[test]
fn fill_polygon_far_out_of_bounds_is_clamped_and_fast() {
let mut fb = fresh(10, 10);
let huge = 1.0e9_f32;
let pts = [(-huge, -huge), (huge, -huge), (huge, huge), (-huge, huge)];
let start = std::time::Instant::now();
fill_polygon(
&mut fb,
&pts,
Color(255, 0, 0, 255),
FillRule::NonZero,
false,
);
let elapsed = start.elapsed();
assert!(
elapsed < std::time::Duration::from_secs(2),
"fill_polygon took {elapsed:?} for far-out-of-bounds coordinates; \
the vertical/horizontal span is not being clamped to the framebuffer"
);
for y in 0..10 {
for x in 0..10 {
assert_eq!(
fb.get_rgba(x, y),
Some((255, 0, 0, 255)),
"pixel ({x},{y}) should be filled"
);
}
}
}
#[test]
fn fill_polygon_clipped_far_out_of_bounds_is_clamped_and_fast() {
let mut fb = fresh(10, 10);
let huge = 1.0e9_f32;
let pts = [(-huge, -huge), (huge, -huge), (huge, huge), (-huge, huge)];
let clip = crate::clip::ClipRect::full(10, 10);
let start = std::time::Instant::now();
fill_polygon_clipped(
&mut fb,
&pts,
Color(0, 0, 255, 255),
FillRule::NonZero,
false,
clip,
);
let elapsed = start.elapsed();
assert!(
elapsed < std::time::Duration::from_secs(2),
"fill_polygon_clipped took {elapsed:?} for far-out-of-bounds coordinates"
);
for y in 0..10 {
for x in 0..10 {
assert_eq!(fb.get_rgba(x, y), Some((0, 0, 255, 255)));
}
}
}
#[test]
fn paint_span_far_out_of_bounds_x_is_clamped() {
let mut fb = fresh(8, 8);
let start = std::time::Instant::now();
paint_span(&mut fb, -1.0e9, 1.0e9, 3, Color(10, 20, 30, 255), false);
let elapsed = start.elapsed();
assert!(
elapsed < std::time::Duration::from_secs(2),
"paint_span took {elapsed:?} for a far-out-of-bounds span"
);
for x in 0..8 {
assert_eq!(fb.get_rgba(x, 3), Some((10, 20, 30, 255)));
}
assert_eq!(fb.get_rgba(0, 0), Some((0, 0, 0, 255)));
}
#[test]
fn fill_polygon_partial_offscreen_top_still_paints_visible_rows() {
let mut fb = fresh(10, 10);
let pts = [(2.0f32, -1000.0), (8.0, -1000.0), (8.0, 5.0), (2.0, 5.0)];
fill_polygon(
&mut fb,
&pts,
Color(0, 255, 0, 255),
FillRule::NonZero,
false,
);
for y in 0..5 {
assert_eq!(
fb.get_rgba(5, y),
Some((0, 255, 0, 255)),
"row {y} should be filled by the far-off-screen rectangle"
);
}
assert_eq!(fb.get_rgba(5, 9), Some((0, 0, 0, 255)));
}
}