Files
endorphin-tessera-gui/src/software_renderer.rs
T
2026-10-20 16:42:12 +08:00

237 lines
8.1 KiB
Rust

use crate::{
display_list::{Color, DisplayList, DrawCommand},
geometry::{Point, Rect},
text::{self, TextStyle},
};
pub struct SoftwareRenderer;
impl SoftwareRenderer {
pub fn render(list: &DisplayList, target: &mut [u32], width: u32, height: u32) {
let mut canvas = Canvas {
pixels: target,
width,
height,
clip: Rect::new(0.0, 0.0, width as f32, height as f32),
};
let mut clips = Vec::new();
for command in list.commands() {
match command {
DrawCommand::PushClip(rect) => {
clips.push(canvas.clip);
let x = canvas.clip.x.max(rect.x);
let y = canvas.clip.y.max(rect.y);
canvas.clip = Rect::new(
x,
y,
(canvas.clip.x + canvas.clip.width)
.min(rect.x + rect.width)
.max(x)
- x,
(canvas.clip.y + canvas.clip.height)
.min(rect.y + rect.height)
.max(y)
- y,
);
}
DrawCommand::PopClip => {
if let Some(clip) = clips.pop() {
canvas.clip = clip;
}
}
DrawCommand::Clear(color) => canvas.clear(*color),
DrawCommand::FillRoundedRect {
rect,
radius,
color,
} => canvas.fill_rounded_rect(*rect, *radius, *color),
DrawCommand::StrokeRoundedRect {
rect,
radius,
width,
color,
} => canvas.stroke_rounded_rect(*rect, *radius, *width, *color),
DrawCommand::Text {
origin,
value,
style,
color,
} => canvas.draw_text(*origin, value, *style, *color),
}
}
}
}
struct Canvas<'a> {
pixels: &'a mut [u32],
width: u32,
height: u32,
clip: Rect,
}
impl Canvas<'_> {
fn clear(&mut self, color: Color) {
self.pixels.fill(pack(color));
}
fn fill_rounded_rect(&mut self, rect: Rect, radius: f32, color: Color) {
let bounds = self.clamped_bounds(rect);
let radius = radius.max(0.0).min(rect.width / 2.0).min(rect.height / 2.0);
for y in bounds.1..bounds.3 {
for x in bounds.0..bounds.2 {
if rounded_rect_contains(rect, radius, x as f32 + 0.5, y as f32 + 0.5) {
self.blend_pixel(x, y, color);
}
}
}
}
fn stroke_rounded_rect(&mut self, rect: Rect, radius: f32, width: f32, color: Color) {
let bounds = self.clamped_bounds(rect);
let width = width.max(1.0).min(rect.width / 2.0).min(rect.height / 2.0);
let outer_radius = radius.max(0.0).min(rect.width / 2.0).min(rect.height / 2.0);
let inner = Rect::new(
rect.x + width,
rect.y + width,
(rect.width - width * 2.0).max(0.0),
(rect.height - width * 2.0).max(0.0),
);
let inner_radius = (outer_radius - width).max(0.0);
for y in bounds.1..bounds.3 {
for x in bounds.0..bounds.2 {
let px = x as f32 + 0.5;
let py = y as f32 + 0.5;
if rounded_rect_contains(rect, outer_radius, px, py)
&& !rounded_rect_contains(inner, inner_radius, px, py)
{
self.blend_pixel(x, y, color);
}
}
}
}
fn draw_text(&mut self, origin: Point, value: &str, style: TextStyle, color: Color) {
let x = origin.x.round() as i32;
let y = origin.y.round() as i32;
text::draw(value, style, color, |dx, dy, pixel| {
self.blend_pixel(x + dx, y + dy, pixel)
});
}
fn clamped_bounds(&self, rect: Rect) -> (i32, i32, i32, i32) {
(
rect.x.max(self.clip.x).max(0.0).floor() as i32,
rect.y.max(self.clip.y).max(0.0).floor() as i32,
(rect.x + rect.width)
.min(self.clip.x + self.clip.width)
.min(self.width as f32)
.ceil() as i32,
(rect.y + rect.height)
.min(self.clip.y + self.clip.height)
.min(self.height as f32)
.ceil() as i32,
)
}
fn blend_pixel(&mut self, x: i32, y: i32, source: Color) {
if x < 0
|| y < 0
|| x >= self.width as i32
|| y >= self.height as i32
|| source.alpha == 0
|| (x as f32) < self.clip.x
|| (y as f32) < self.clip.y
|| (x as f32) >= self.clip.x + self.clip.width
|| (y as f32) >= self.clip.y + self.clip.height
{
return;
}
let index = y as usize * self.width as usize + x as usize;
if source.alpha == 255 {
self.pixels[index] = pack(source);
return;
}
let destination = self.pixels[index];
let dst_red = (destination >> 16) & 0xff;
let dst_green = (destination >> 8) & 0xff;
let dst_blue = destination & 0xff;
let alpha = source.alpha as u32;
let inverse = 255 - alpha;
let red = (source.red as u32 * alpha + dst_red * inverse) / 255;
let green = (source.green as u32 * alpha + dst_green * inverse) / 255;
let blue = (source.blue as u32 * alpha + dst_blue * inverse) / 255;
self.pixels[index] = (red << 16) | (green << 8) | blue;
}
}
fn rounded_rect_contains(rect: Rect, radius: f32, x: f32, y: f32) -> bool {
if rect.width <= 0.0 || rect.height <= 0.0 {
return false;
}
if radius <= 0.0 {
return x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height;
}
let closest_x = x.clamp(rect.x + radius, rect.x + rect.width - radius);
let closest_y = y.clamp(rect.y + radius, rect.y + rect.height - radius);
let dx = x - closest_x;
let dy = y - closest_y;
dx * dx + dy * dy <= radius * radius
}
fn pack(color: Color) -> u32 {
(color.red as u32) << 16 | (color.green as u32) << 8 | color.blue as u32
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn renderer_changes_pixels_inside_a_rectangle() {
let mut list = DisplayList::default();
list.push(DrawCommand::Clear(Color::rgb(0, 0, 0)));
list.push(DrawCommand::FillRoundedRect {
rect: Rect::new(1.0, 1.0, 2.0, 2.0),
radius: 0.0,
color: Color::rgb(255, 0, 0),
});
let mut pixels = vec![0; 16];
SoftwareRenderer::render(&list, &mut pixels, 4, 4);
assert_eq!(pixels[5], 0x00ff0000);
assert_eq!(pixels[0], 0);
}
#[test]
fn text_edges_are_antialiased_and_respect_opacity() {
let mut list = DisplayList::default();
list.push(DrawCommand::Clear(Color::rgb(0, 0, 0)));
list.push(DrawCommand::Text {
origin: Point::new(2.0, 2.0),
value: "Hello 中文".into(),
style: TextStyle::ui(1).scaled(1.25),
color: Color::rgba(255, 255, 255, 128),
});
let mut pixels = vec![0; 256 * 48];
SoftwareRenderer::render(&list, &mut pixels, 256, 48);
assert!(pixels.iter().any(|pixel| *pixel > 0));
assert!(pixels.iter().all(|pixel| (pixel & 255) <= 128));
assert!(pixels.iter().any(|pixel| (1..128).contains(&(pixel & 255))));
let mut invisible = DisplayList::default();
invisible.push(DrawCommand::Text {
origin: Point::new(2.0, 2.0),
value: "Hello 中文".into(),
style: TextStyle::ui(1),
color: Color::rgba(255, 255, 255, 0),
});
let mut pixels = vec![0; 256 * 48];
SoftwareRenderer::render(&invisible, &mut pixels, 256, 48);
assert!(pixels.iter().all(|pixel| *pixel == 0));
}
}