Files
endorphin-tessera-gui/src/render/software_renderer.rs
T

592 lines
32 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 把 DisplayList 写入 CPU 像素缓冲。裁剪栈限定矩形、文字和 3D 表面的实际写入区域。
//! RenderScope 约束递归深度;累计像素预算限制嵌套 3D 和世界 UI 的中间表面分配。错误向上传递,不把半幅结果当成成功帧呈现。
//!
//! shell::app 的 redraw 在取得 softbuffer 后调用 render,成功后才 present;scene3d::panel 也调用它生成离屏 UI 纹理。
//! 本模块不访问 VM,也不重新运行源 paint,只执行目标像素坐标下的 DrawCommand;文字覆盖回调同样同步写入当前 Canvas。
//! render 原地改 target,不提供失败回滚;调用方必须尊重 Result,在错误时停止呈现或终止纹理生成,不把残留像素当作完整新帧。
use crate::{
display_list::{Color, DisplayList, DrawCommand},
geometry::{Point, Rect},
text::{self, TextStyle},
};
/// 显示列表的 CPU 执行入口,错误在原生 present 前向上传递。
// shell 的窗口呈现和 scene3d::panel 的离屏纹理都使用同一执行器,输入是已转换到目标像素空间的 DisplayList。
pub struct SoftwareRenderer;
// 预算按渲染线程维护,递归 3D/面板渲染共用同一组计数;不同线程之间不共享额度。
thread_local! {
static RENDER_DEPTH:std::cell::Cell<usize>=const { std::cell::Cell::new(0) };
static PIXEL_UNITS:std::cell::Cell<usize>=const { std::cell::Cell::new(0) };
}
/// 递归渲染的 RAII 深度保护,最外层还重置累计像素工作预算。
pub(crate) struct RenderScope;
impl RenderScope {
/// 进入一次嵌套渲染并检查深度上限;最外层调用重新开始累计像素预算。
// SoftwareRenderer::render 与 scene3d::render_aspect 每次进入都建立作用域,面板中的嵌套视口继续累计。
pub fn enter() -> Result<Self, &'static str> {
RENDER_DEPTH.with(|depth| {
// 先读取进入前深度;拒绝分支不递增,因此不会产生没有配对 Guard 的计数。
let current = depth.get();
// 拒绝第 19 层进入,不先增加深度;成功返回的 RenderScope 析构时才对应减一。
if current >= 18 {
return Err("render nesting budget exceeded");
}
// 只有最外层渲染开始才清累计像素数;递归进入不能借此重置预算。
if current == 0 {
PIXEL_UNITS.with(|pixels| pixels.set(0));
}
// 仅成功进入后递增;返回的 RenderScope 析构时归还这一层。
depth.set(current + 1);
Ok(Self)
})
}
}
impl Drop for RenderScope {
/// 退出一次像素渲染作用域时归还嵌套深度;像素工作量仍按本帧累计,不因退出内层而返还。
fn drop(&mut self) {
RENDER_DEPTH.with(|depth| depth.set(depth.get() - 1));
}
}
/// 累加本帧中间表面工作量,超过预算立即返回错误。
// 3D 颜色/深度缓冲和 UI 面板纹理分配前调用;普通目标缓冲由外层分配,不在这里自动计入。
pub(crate) fn reserve_pixels(units: usize) -> Result<(), &'static str> {
PIXEL_UNITS.with(|pixels| {
// 累计申请工作量而非当前存活像素;释放一个中间表面不会返还额度,饱和加法避免溢出绕过限制。
let next = pixels.get().saturating_add(units);
// 超额时不提交 next,错误向上传递结束本次离屏绘制;预算不是可释放再循环使用的内存额度。
if next > 16_777_216 {
return Err("nested render pixel budget exceeded");
}
pixels.set(next);
Ok(())
})
}
impl SoftwareRenderer {
/// 顺序执行显示列表并维护裁剪栈;3D 和面板可递归进入同一渲染预算域。
// 外部窗口和离屏面板调用;此函数不重新验证全部命令,也不负责创建或调整 target 长度。
pub fn render(
list: &DisplayList,
target: &mut [u32],
width: u32,
height: u32,
) -> Result<(), &'static str> {
// RAII 保护让成功、提前返回和错误传播都恢复深度;target 长度须由调用方保证至少 width×height。
let _scope = RenderScope::enter()?;
// 借用传入像素缓冲建立当前画布,初始 clip 是整张目标;后续命令只修改画布状态或像素。
let mut canvas = Canvas {
pixels: target,
width,
height,
clip: Rect::new(0.0, 0.0, width as f32, height as f32),
};
// 保存 PushClip 之前的裁剪,PopClip 恢复上一层;栈只在本次 render 内存活。
let mut clips = Vec::new();
// 严格按列表顺序执行,后续不透明命令覆盖已有像素,透明命令与当时像素混合。
for command in list.commands() {
// 图像和 3D 各有专用采样路径,其余变体转交 Canvas;坐标不再经过布局或页面相机。
match command {
DrawCommand::Image {
image,
origin,
rect,
angle,
opacity,
tint,
} => {
// 图像旋转与透明度在 draw_image 中处理;这里仅解引用命令字段,保持共享图像资源。
canvas.draw_image(image, *origin, *rect, *angle, *opacity, *tint);
}
DrawCommand::Scene3D { rect, view } => {
// 先将 3D 矩形与当前裁剪和目标尺寸相交;完全不可见时连中间表面都不生成。
let left = rect.x.max(canvas.clip.x).max(0.).floor() as u32;
let top = rect.y.max(canvas.clip.y).max(0.).floor() as u32;
// 右界同时受命令矩形、当前 clip 和目标缓冲宽度限制,ceil 给出保守的整数扫描末端。
let right = (rect.x + rect.width)
.min(canvas.clip.x + canvas.clip.width)
.min(width as f32)
.ceil()
.max(0.) as u32;
// 底边界采用与右界相同的规则;后面的空交集检查同时防止不可见/退化矩形进入像素循环。
let bottom = (rect.y + rect.height)
.min(canvas.clip.y + canvas.clip.height)
.min(height as f32)
.ceil()
.max(0.) as u32;
// 交集为空或原矩形退化时直接跳过,避免申请无用 3D 表面并发生归一化除零。
if left >= right || top >= bottom || rect.width <= 0. || rect.height <= 0. {
continue;
}
// 3D 中间表面的最长边限制到约 2048 像素,再采样到请求尺寸;相机宽高比仍用原 rect,避免改变透视比例。
let lod = (2048. / rect.width.max(rect.height)).min(1.);
// 按同一 lod 求两个输出轴并向上取整至少一个像素;rect 宽高仍决定相机 aspect。
let w = (rect.width * lod).ceil().max(1.) as u32;
let h = (rect.height * lod).ceil().max(1.) as u32;
// 获取不可变缓存像素,光栅化或预算错误立即传播;已经写入 target 的其他命令不会自动回滚。
let surface = crate::scene3d::surface(view, w, h, rect.width / rect.height)?;
// 只遍历裁剪可见的目标行列,来源坐标按完整 rect 映射而不是按裁剪交集重新拉伸。
for y in top..bottom {
// 目标像素中心在低分辨率 3D 表面上取最近邻,LOD 改变采样密度而非布局位置。
for x in left..right {
// 按目标像素中心反算纹理坐标并钳到有效表面边界;只复制可见区域,不循环整个放大矩形。
let u = (((x as f32 + 0.5 - rect.x) / rect.width) * w as f32)
.clamp(0., (w - 1) as f32)
as u32;
// v 对应源表面的行索引,仍从完整 rect 原点映射,裁剪不会把剩余区域重新拉伸成整张图。
let v = (((y as f32 + 0.5 - rect.y) / rect.height) * h as f32)
.clamp(0., (h - 1) as f32)
as u32;
// 3D 表面是最终 RGB 像素,直接覆盖目标;不走二维 source alpha 混合。
canvas.pixels[y as usize * width as usize + x as usize] =
surface[v as usize * w as usize + u as usize];
}
}
}
DrawCommand::PushClip(rect) => {
// 先保存旧裁剪,再求交;嵌套 PushClip 只能收窄,矩形分离时宽高归零。
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 => {
// 多余 PopClip 在当前实现中被忽略;合法列表仍应由生成层保证配对。
if let Some(clip) = clips.pop() {
canvas.clip = clip;
}
}
// Clear 忽略 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::WrappedText {
rect,
value,
style,
color,
} => canvas.draw_wrapped_text(*rect, value, *style, *color),
DrawCommand::Text {
origin,
value,
style,
color,
} => canvas.draw_text(*origin, value, *style, *color),
}
}
Ok(())
}
}
/// 借用目标像素切片并维护当前裁剪;图形/文字受裁剪,Clear 明确填满整个目标。
struct Canvas<'a> {
// 原地写入的不透明 0xRRGGBB 目标,生命周期只覆盖一次 render 调用。
pixels: &'a mut [u32],
width: u32,
height: u32,
// 当前目标空间矩形裁剪;图形扫描先收窄范围,blend_pixel 再检查最终像素坐标。
clip: Rect,
}
impl Canvas<'_> {
/// Inverse-map only clipped destination pixels. Images use straight RGBA;
/// bilinear interpolation happens in premultiplied alpha to avoid dark halos.
// render 的 Image 分支调用;对目标可见像素做逆旋转映射,避免正向散点产生空洞。
fn draw_image(
&mut self,
image: &crate::render::image::Image,
origin: Point,
rect: Rect,
angle: f32,
opacity: f32,
tint: Option<Color>,
) {
// 退化尺寸或非有限旋转/透明度没有可用映射;直接返回且不改变像素。
if rect.width <= 0. || rect.height <= 0. || !angle.is_finite() || !opacity.is_finite() {
return;
}
// angle 是弧度;四个局部矩形角旋转后求目标包围盒,用于限制扫描范围。
let (sin, cos) = angle.sin_cos();
let corners = [
(rect.x, rect.y),
(rect.x + rect.width, rect.y),
(rect.x, rect.y + rect.height),
(rect.x + rect.width, rect.y + rect.height),
];
// 最小值从正无穷、最大值从负无穷初始化,确保首个角点建立真实范围,不误含原点。
let mut lo = Point::new(f32::INFINITY, f32::INFINITY);
let mut hi = Point::new(f32::NEG_INFINITY, f32::NEG_INFINITY);
// 绕命令 origin 旋转局部角点;rect.x/y 仍是相对该 origin 的偏移。
for (x, y) in corners {
let px = origin.x + cos * x - sin * y;
// 同一旋转矩阵的纵分量,与 px 一起更新包围盒;原图像资源和命令 rect 都保持只读。
let py = origin.y + sin * x + cos * y;
lo.x = lo.x.min(px);
lo.y = lo.y.min(py);
hi.x = hi.x.max(px);
hi.y = hi.y.max(py);
}
// 旋转后的轴对齐包围盒还可能包含矩形外部像素,下面的 u/v 范围判断继续排除。
let (left, top, right, bottom) =
self.clamped_bounds(Rect::new(lo.x, lo.y, hi.x - lo.x, hi.y - lo.y));
// 只扫描包围盒与当前 clip/画布的交集,不按原始图像分辨率遍历。
for y in top..bottom {
for x in left..right {
// 目标像素中心先减 origin,再乘反向旋转矩阵,恢复局部矩形坐标。
let dx = x as f32 + 0.5 - origin.x;
let dy = y as f32 + 0.5 - origin.y;
// 减去局部 rect 原点并除宽高,得到采样函数接受的归一化纹理位置。
let u = (cos * dx + sin * dy - rect.x) / rect.width;
let v = (-sin * dx + cos * dy - rect.y) / rect.height;
// 旋转包围盒内但局部矩形外的点不能采样;右/下边界为开区间。
if (0. ..1.).contains(&u) && (0. ..1.).contains(&v) {
// 图像采样内部在预乘 alpha 空间插值,返回非预乘颜色供 blend_pixel 合成;整体透明度钳到 [0,1]。
let mut sample = image.sample(u, v, opacity.clamp(0., 1.));
// 有 tint 时替换 RGB 而保留采样 alpha,把图像当作覆盖遮罩使用,不逐通道乘原图颜色。
if let Some(tint) = tint {
sample.red = tint.red;
sample.green = tint.green;
sample.blue = tint.blue;
}
self.blend_pixel(x, y, sample);
}
}
}
}
/// 用不透明 RGB 填满目标切片;此命令忽略当前裁剪和输入颜色 alpha。
// render 的 Clear 分支调用;target 若大于 width×height,这里也会填满额外切片,因此调用方应传精确缓冲。
fn clear(&mut self, color: Color) {
// Clear 填整个提供的目标切片,不受当前 clip 约束;pack 忽略 alpha,清屏色作为不透明 RGB。
self.pixels.fill(pack(color));
}
/// 只遍历裁剪后的矩形像素,按圆角覆盖决定填充区域。
// render 的 FillRoundedRect 分支调用;圆角内像素交给 blend_pixel,统一透明度与边界判断。
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);
// bounds 顺序为 left/top/right/bottom,使用左闭右开的整数扫描区间。
for y in bounds.1..bounds.3 {
for x in bounds.0..bounds.2 {
// 只有落在圆角形状内才混合;clip 外的候选即使被保守范围包含也会被 blend_pixel 拒绝。
if rounded_rect_contains(rect, radius, x as f32 + 0.5, y as f32 + 0.5) {
self.blend_pixel(x, y, color);
}
}
}
}
/// 用外轮廓与内轮廓的差绘制边框,避免把边框当成整个矩形填充。
// render 的 StrokeRoundedRect 分支调用;边框占据外圆角形状减去内圆角形状的区域。
fn stroke_rounded_rect(&mut self, rect: Rect, radius: f32, width: f32, color: Color) {
let bounds = self.clamped_bounds(rect);
// 描边请求先至少一单位再限制为半宽/半高;inner 从四边缩入,最终绘制外轮廓减内轮廓。
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),
);
// 内轮廓与缩进矩形配套;宽描边把内区缩到零时,内轮廓判定返回 false。
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);
}
}
}
}
/// 把文字原点转换为局部裁剪区域,交给有界字形绘制,再混合到目标缓冲。
// render 的 Text 分支调用;字形覆盖由 text 模块产生,本模块只处理目标位置、clip 和混合。
fn draw_text(&mut self, origin: Point, value: &str, style: TextStyle, color: Color) {
// 将文字原点对齐整数像素;转换 clip 到字形局部空间,回调再加原点写回目标。
let x = origin.x.round() as i32;
let y = origin.y.round() as i32;
// draw_clipped 接受字形局部坐标的 clip,减去取整后的文字原点,回调再加回同一原点,避免两次转换不一致。
let clip = Rect::new(
self.clip.x - x as f32,
self.clip.y - y as f32,
self.clip.width,
self.clip.height,
);
// None 表示没有段落换行宽度;回调收到字形局部像素,平移后仍经 blend_pixel 检查目标边界。
text::draw_clipped(value, style, color, None, clip, |dx, dy, pixel| {
self.blend_pixel(x + dx, y + dy, pixel)
});
}
/// 把段落矩形与当前裁剪相交,按受限宽度绘制,再恢复外层裁剪。
// render 的 WrappedText 分支调用;与普通文字共用字形路径,但临时加上段落框裁剪。
fn draw_wrapped_text(&mut self, rect: Rect, value: &str, style: TextStyle, color: Color) {
// 空段落框直接跳过,不进入文字排版也不修改当前 clip。
if rect.width <= 0.0 || rect.height <= 0.0 {
return;
}
// 段落文本临时收窄 clip 到文本框;下方调用完成后恢复 old,后续命令不能继承段落私有裁剪。
let old = self.clip;
let x = old.x.max(rect.x);
// x/y 是祖先裁剪与段落框交集的左上角,不替代原段落的排版原点;下面 left/top 仍从 rect 取值。
let y = old.y.max(rect.y);
self.clip = Rect::new(
x,
y,
((old.x + old.width).min(rect.x + rect.width) - x).max(0.0),
((old.y + old.height).min(rect.y + rect.height) - y).max(0.0),
);
// 段落原点对齐整数像素,随后把交集 clip 换到文字局部空间;排版宽度仍保持原框宽。
let left = rect.x.round() as i32;
let top = rect.y.round() as i32;
// 只把交集移到字形局部空间;换行宽度仍在后面单独传 rect.width,不用裁掉后的 clip.width 重排文字。
let clip = Rect::new(
self.clip.x - left as f32,
self.clip.y - top as f32,
self.clip.width,
self.clip.height,
);
text::draw_clipped(
value,
style,
color,
// 换行按完整段落宽度排版,而不是按被祖先裁剪后剩余的可见宽度重新排版。
Some(rect.width),
clip,
|dx, dy, pixel| self.blend_pixel(left + dx, top + dy, pixel),
);
// 文字回调同步执行完成后恢复外层 clip,后续显示命令不继承段落框限制。
self.clip = old;
}
/// 把连续矩形转换成目标缓冲内的整数像素范围,避免在不可见区域进行光栅循环。
// 填充、描边和图像共同使用;完全分离时可产生空的整数区间,调用方 range 循环自然不执行。
fn clamped_bounds(&self, rect: Rect) -> (i32, i32, i32, i32) {
// 左/上 floor,右/下 ceil 形成保守循环范围;blend_pixel 还会按当前 clip 检查实际整数像素。
(
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,
)
}
/// 在画布与当前裁剪范围内进行透明度混合,防止文字/图形写出目标缓冲。
// 文字覆盖回调、矩形和图像最终都进入此处;3D 表面分支已经自行裁剪,直接复制 RGB。
fn blend_pixel(&mut self, x: i32, y: i32, source: Color) {
// 拒绝画布外、裁剪外与全透明像素;只有通过后才计算线性下标,避免负坐标转 usize。
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;
}
// 边界检查通过后才将坐标转 usize,目标下标按 width 逐行计算。
let index = y as usize * self.width as usize + x as usize;
// 全不透明像素可直接打包覆盖,省去目的通道解码与乘除法。
if source.alpha == 255 {
self.pixels[index] = pack(source);
return;
}
// 半透明路径拆开已有 RGB,再用源 alpha 与剩余权重逐通道合成;目的缓冲没有单独 alpha。
let destination = self.pixels[index];
let dst_red = (destination >> 16) & 0xff;
let dst_green = (destination >> 8) & 0xff;
let dst_blue = destination & 0xff;
// 非预乘源颜色按 source*alpha + destination*(1-alpha) 合成,目标不存 alpha,逐通道整除 255。
let alpha = source.alpha as u32;
let inverse = 255 - alpha;
// red/green/blue 各自使用同一 alpha 和余量,计算提升到 u32 后整除,避免 u8 乘法溢出;最终目标仍是不透明 RGB。
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;
}
}
/// 测试一个像素采样点是否位于圆角矩形内,角区域使用圆弧距离。
// fill/stroke 的内部形状测试,参数 x/y 是像素中心;radius<=0 退化为普通矩形判断。
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;
}
// Clamp in local coordinates: adding a large origin to a shrinking tab's
// half-width can otherwise round the lower bound above the upper bound.
let radius = radius.min(rect.width / 2.0).min(rect.height / 2.0);
// 在局部坐标钳到圆角中心可达矩形,距离向量对应最近角圆心;中心带距离为零。
// 先减大原点可降低浮点舍入导致 clamp 上下界反转的风险。
let local_x = x - rect.x;
let local_y = y - rect.y;
// 把局部点投影到圆心可达矩形,差向量长度就是到最近圆角中心/中心带的距离。
let dx = local_x - local_x.clamp(radius, rect.width - radius);
let dy = local_y - local_y.clamp(radius, rect.height - radius);
dx * dx + dy * dy <= radius * radius
}
/// 将颜色通道打包成目标缓冲的整数像素表示。
// clear 和全不透明 blend_pixel 直接使用;半透明路径先完成合成再自行打包。
fn pack(color: Color) -> u32 {
// 像素格式为 0x00RRGGBB,高字节不存透明度;透明度已在混合阶段折算进 RGB。
(color.red as u32) << 16 | (color.green as u32) << 8 | color.blue as u32
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
/// 画长文字进入嵌套裁剪,逐像素检查多行与裁剪交集;PopClip后的蓝矩形验证后续命令不沿用旧clip。
fn wrapped_text_uses_multiple_lines_respects_nested_clips_and_restores_the_clip() {
// 依次构造白底、祖先 clip、换行文字、PopClip、零宽文字与远处蓝框,用不同命令检测裁剪状态。
let mut list = DisplayList::default();
list.push(DrawCommand::Clear(Color::rgb(255, 255, 255)));
list.push(DrawCommand::PushClip(Rect::new(12.0, 5.0, 20.0, 40.0)));
list.push(DrawCommand::WrappedText {
rect: Rect::new(10.0, 5.0, 32.0, 40.0),
value: "MMMMMMMMMMMMMMMM".into(),
style: TextStyle::ui(1),
color: Color::rgb(0, 0, 0),
});
list.push(DrawCommand::PopClip);
list.push(DrawCommand::WrappedText {
rect: Rect::new(60.0, 0.0, 0.0, 40.0),
value: "invisible".into(),
style: TextStyle::ui(1),
color: Color::rgb(0, 0, 0),
});
list.push(DrawCommand::FillRoundedRect {
rect: Rect::new(70.0, 50.0, 2.0, 2.0),
radius: 0.0,
color: Color::rgb(0, 0, 255),
});
// 目标尺寸覆盖文字和蓝框,初始化后由 Clear 建立统一白背景。
let mut pixels = vec![0; 80 * 60];
SoftwareRenderer::render(&list, &mut pixels, 80, 60).unwrap();
// 确认文字确实换成多行且每个非背景文字像素都落在嵌套裁剪交集内,蓝色矩形验证裁剪已恢复。
// 记录是否在第二行高度出现文字像素,不能仅用“有黑点”证明换行生效。
let mut second_line = false;
// 遍历整张目标,跳过白背景和蓝框后,剩余文字像素必须全部位于裁剪交集。
for y in 0..60 {
for x in 0..80 {
// 按目标行宽 80 读取 RGB;此测试不依赖具体字体的逐像素位图。
let color = pixels[y * 80 + x];
// 识别文字及其抗锯齿边缘,检查位置并累计第二行存在性。
if color != 0xffffff && color != 0x0000ff {
assert!((12..32).contains(&x) && (5..45).contains(&y));
second_line |= y >= 25;
}
}
}
assert!(second_line);
// 蓝框在原祖先 clip 外,成功出现说明 PopClip 与段落私有 clip 都已恢复。
assert_eq!(pixels[50 * 80 + 70], 0x0000ff);
}
#[test]
/// 在黑底4x4缓冲画2x2红矩形,核对内部像素变红、外部仍黑,覆盖最小坐标/打包路径。
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),
});
// 4×4 行主序缓冲中下标 5 是 (1,1),下标 0 在矩形外,分别断言覆盖与保留。
let mut pixels = vec![0; 16];
SoftwareRenderer::render(&list, &mut pixels, 4, 4).unwrap();
assert_eq!(pixels[5], 0x00ff0000);
assert_eq!(pixels[0], 0);
}
#[test]
/// 半透明白字叠黑底必须含中间抗锯齿值且通道不超128;alpha=0的文字完全不改像素。
fn text_edges_are_antialiased_and_respect_opacity() {
// 白色字形以 alpha=128 画在黑底,抗锯齿覆盖进一步降低边缘 alpha,输出应包含中间亮度。
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).unwrap();
assert!(pixels.iter().any(|pixel| *pixel > 0));
// 白字 alpha=128 叠黑底,任何通道都不应超过 128;中间覆盖值证明字形边缘仍有抗锯齿。
assert!(pixels.iter().all(|pixel| (pixel & 255) <= 128));
assert!(pixels.iter().any(|pixel| (1..128).contains(&(pixel & 255))));
// 另建全透明文字列表,不依赖上一张已绘制缓冲;零 alpha 应在混合入口直接跳过。
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).unwrap();
assert!(pixels.iter().all(|pixel| *pixel == 0));
}
}