makepad/draw/tests/text_clip.rs
Admin 4945f1873b platform: rename the CPU simulated-GPU backend from headless to gpusim
Squash of 1 work commits (Sep 12–12):
  e74b919  platform: the CPU simulated-GPU backend is `gpusim` — the word "headless" now means only window-less

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 13:40:28 +02:00

394 lines
16 KiB
Rust

//! PNG regression for retained text and quad clipping. Run with:
//! MAKEPAD=gpusim RUSTFLAGS='--cfg gpusim' cargo test --release
//! -p makepad-draw --test text_clip -- --ignored --nocapture
//! MAKEPAD_GPUSIM_OUT_DIR optionally retains the reference and clipped PNGs.
use makepad_draw::makepad_script::{
shader::{ShaderFnCompiler, ShaderMode, ShaderOutput, ShaderType},
shader_backend::ShaderBackend,
trap::NoTrap,
};
use makepad_draw::text::{
geom::{Point, Rect as TextRect},
image::Image,
msdfer::Msdfer,
rasterizer::{AtlasKind, CompletedMsdfJob, OutlineRasterizationMode},
};
use makepad_draw::*;
use std::{cell::RefCell, path::Path, rc::Rc};
const WIDTH: usize = 320;
const HEIGHT: usize = 240;
const CLIP: [usize; 4] = [96, 72, 224, 168];
#[derive(Clone, Copy, Debug)]
enum GlyphPath {
Sdf,
Msdf,
Slug,
Emoji,
}
#[derive(Clone, Copy)]
struct Scene {
glyph_path: GlyphPath,
translation: Vec2d,
shift: Vec2d,
list_clip: Option<[usize; 4]>,
instance_clip: bool,
}
fn render(scene: Scene, directory: &Path) -> Vec<u8> {
std::fs::create_dir_all(directory).unwrap();
std::env::set_var("MAKEPAD_GPUSIM_OUT_DIR", directory);
// Another explicit renderer gate in the same test binary may disable PNGs.
std::env::set_var("MAKEPAD_GPUSIM_FRAMES", "on");
std::env::set_var("MAKEPAD_GPUSIM_DPI", "1");
let mut state = None;
let cx = Rc::new(RefCell::new(Cx::new(Box::new(move |cx, event| {
if matches!(event, Event::Startup) {
let (text, quad) = cx.with_vm(|vm| {
makepad_platform::script::script_mod(vm);
makepad_draw::script_mod(vm);
let font = if matches!(scene.glyph_path, GlyphPath::Emoji) {
"self:../../widgets/resources/NotoColorEmoji.ttf"
} else {
"self:../../widgets/resources/IBMPlexSans-Text.ttf"
};
let source = script! {
use mod.draw.*
use mod.text.*
use mod.res.*
DrawText{
color: #fff
text_style: TextStyle{
font_size: 48
font_family: FontFamily{
regular := FontMember{res: crate_resource(#(font))}
}
}
}
};
let value = vm.eval(source);
if scene.list_clip.is_none() && scene.translation == dvec2(0.0, 0.0) {
let io_self = value.as_object().unwrap();
for backend in [
ShaderBackend::Metal,
ShaderBackend::Glsl,
ShaderBackend::Hlsl,
ShaderBackend::Wgsl,
ShaderBackend::Rust,
] {
let mut output = ShaderOutput::default();
output.backend = backend;
output.const_table = false;
output.pre_collect_rust_instance_io(vm, io_self);
output.pre_collect_shader_io(vm, io_self);
let vertex = vm
.bx
.heap
.object_method(io_self, live_id!(vertex).into(), NoTrap)
.as_object()
.unwrap();
output.mode = ShaderMode::Vertex;
ShaderFnCompiler::compile_shader_def(
vm,
&mut output,
NoTrap,
live_id!(vertex),
vertex,
ShaderType::IoSelf(io_self),
vec![],
);
assert!(
!output.has_errors,
"text vertex compilation failed for {backend:?}"
);
}
println!(
"{:?} text vertex backends: Metal GL/WebGL D3D11 WGSL gpusim OK",
scene.glyph_path
);
}
let text = DrawText::script_from_value(vm, value);
let mut quad = DrawColor::script_new_with_default(vm);
quad.color = vec4(1.0, 0.0, 0.0, 1.0);
(text, quad)
});
let mut window = WindowHandle::new(cx);
window.configure_window(
cx,
dvec2(WIDTH as f64, HEIGHT as f64),
dvec2(0.0, 0.0),
false,
"Text clip regression".into(),
);
let pass = DrawPass::new(cx);
window.set_pass(cx, &pass);
pass.set_window_clear_color(cx, vec4(0.0, 0.0, 0.0, 1.0));
state = Some((
window,
pass,
DrawList2d::new(cx),
DrawList2d::new(cx),
text,
quad,
));
cx.redraw_all();
}
if let Event::Draw(event) = event {
let (_, pass, root, list, text, quad) = state.as_mut().unwrap();
let mut draw = CxDraw::new(cx, event);
let mut cx = Cx2d::new(&mut draw);
cx.begin_pass(pass, None);
root.begin_always(&mut cx);
cx.begin_root_turtle(dvec2(WIDTH as f64, HEIGHT as f64), Layout::flow_overlay());
list.begin_always(&mut cx);
if scene.instance_clip {
cx.push_clip_rect(Rect {
pos: dvec2(90.0, 50.0),
size: dvec2(110.0, 100.0),
});
}
quad.draw_abs(
&mut cx,
Rect {
pos: dvec2(12.0, 12.0),
size: dvec2(296.0, 216.0),
},
);
let run = if matches!(scene.glyph_path, GlyphPath::Emoji) {
"😀😀😀😀😀"
} else {
"HHHHHHHH"
};
cx.fonts.borrow_mut().set_outline_rasterization_mode(
if matches!(scene.glyph_path, GlyphPath::Msdf) {
OutlineRasterizationMode::Msdf
} else {
OutlineRasterizationMode::Sdf
},
);
for y in [40.0, 120.0] {
if matches!(scene.glyph_path, GlyphPath::Slug) {
text.draw_abs(&mut cx, dvec2(24.0, y), run);
assert_eq!(text.texture_index, 3.0, "must exercise vector glyphs");
} else {
// Explicit raster API also exercises SDF/MSDF on hosts that
// normally choose vector outlines at every font size.
let layout = text.layout(&mut cx, 0.0, 0.0, None, false, Align::default(), run);
let mut glyphs = Vec::new();
for row in &layout.rows {
for glyph in &row.glyphs {
let mut raster = glyph.rasterize(glyph.font_size_in_lpxs).unwrap();
if matches!(scene.glyph_path, GlyphPath::Msdf) {
let rasterizer = cx.fonts.borrow().rasterizer().clone();
let request_size = glyph.font_size_in_lpxs.max(
rasterizer
.borrow()
.msdf_resolution()
.min_request_dpxs_per_em
+ 1.0,
);
glyph.rasterize(request_size).unwrap();
// Finish queued MSDF work deterministically before the
// single capture, using the production rasterizer.
let mut rasterizer = rasterizer.borrow_mut();
let mut msdfer = Msdfer::new(rasterizer.msdfer().settings());
for job in rasterizer.take_queued_msdf_jobs() {
let mut image = Image::new(job.key.size);
msdfer.outline_to_msdf(
&job.outline,
job.dpxs_per_em,
&mut image.subimage_mut(TextRect::from(job.key.size)),
);
rasterizer.apply_completed_msdf_job(CompletedMsdfJob {
key: job.key,
pixels: image.into_pixels(),
epoch: job.epoch,
});
}
drop(rasterizer);
raster = glyph.rasterize(request_size).unwrap();
}
let expected = match scene.glyph_path {
GlyphPath::Sdf => AtlasKind::Grayscale,
GlyphPath::Msdf => AtlasKind::Msdf,
GlyphPath::Emoji => AtlasKind::Color,
GlyphPath::Slug => unreachable!(),
};
assert_eq!(raster.atlas_kind, expected);
glyphs.push((
Point::new(
24.0 + row.origin_in_lpxs.x + glyph.origin_in_lpxs.x,
y as f32 + row.origin_in_lpxs.y + glyph.origin_in_lpxs.y,
),
glyph.font_size_in_lpxs,
raster,
));
}
}
text.draw_rasterized_glyphs_abs(&mut cx, &glyphs, vec4(1.0, 1.0, 1.0, 1.0));
}
}
if scene.instance_clip {
cx.pop_clip_rect();
}
list.end(&mut cx);
cx.end_pass_sized_turtle();
root.end(&mut cx);
cx.end_pass(pass);
// Retain the emitted instances: only list uniforms change. Quads
// expect view_clip in list space, so inverse-translate the desired
// screen rectangle, just as Atlas does for its retained scene.
let mut transform = Mat4f::identity();
transform.v[12] = scene.translation.x as f32;
transform.v[13] = scene.translation.y as f32;
let uniforms = &mut cx.draw_lists[list.id()].draw_list_uniforms;
uniforms.view_shift = scene.shift.into();
if let Some([left, top, right, bottom]) = scene.list_clip {
uniforms.view_clip = vec4(
left as f32 - scene.translation.x as f32,
top as f32 - scene.translation.y as f32,
right as f32 - scene.translation.x as f32,
bottom as f32 - scene.translation.y as f32,
);
}
list.set_view_transform(&mut cx, &transform);
}
}))));
cx.borrow_mut().init_cx_os();
// The ordinary gpusim loop captures this app's drawable and returns
// after one frame; no visible window or persistent process is launched.
Cx::event_loop(cx);
let bytes = std::fs::read(directory.join("window_0_frame_000000.png")).unwrap();
let mut png = makepad_zune_png::PngDecoder::new(
makepad_zune_png::makepad_zune_core::bytestream::ZCursor::new(bytes),
);
let pixels = png.decode_raw().unwrap();
assert_eq!(png.dimensions().unwrap(), (WIDTH, HEIGHT));
assert_eq!(pixels.len(), WIDTH * HEIGHT * 4);
pixels
}
fn diff(a: &[u8], b: &[u8]) -> usize {
a.chunks_exact(4)
.zip(b.chunks_exact(4))
.filter(|(a, b)| a != b)
.count()
}
#[test]
#[ignore = "requires a MAKEPAD=gpusim release build"]
fn text_clips_like_quads_after_view_transform() {
assert!(std::env::var("MAKEPAD")
.unwrap_or_default()
.contains("gpusim"));
std::env::set_var("MAKEPAD_GPUSIM_DPI", "1");
let keep = std::env::var_os("MAKEPAD_GPUSIM_OUT_DIR");
let directory = keep
.as_ref()
.map(std::path::PathBuf::from)
.unwrap_or_else(|| {
std::env::temp_dir().join(format!("makepad-text-clip-{}", std::process::id()))
});
for glyph_path in [
GlyphPath::Sdf,
GlyphPath::Msdf,
GlyphPath::Slug,
GlyphPath::Emoji,
] {
let name = format!("{glyph_path:?}");
let base = Scene {
glyph_path,
translation: dvec2(0.0, 0.0),
shift: dvec2(0.0, 0.0),
list_clip: None,
instance_clip: false,
};
let baseline = render(base, &directory.join(format!("{name}_baseline")));
let control = render(
Scene {
list_clip: Some([0, 0, WIDTH, HEIGHT]),
..base
},
&directory.join(format!("{name}_control")),
);
let changed = diff(&baseline, &control);
println!(
"{name} identity pixel diff: {changed}/{} ({:.6}%)",
WIDTH * HEIGHT,
changed as f64 * 100.0 / (WIDTH * HEIGHT) as f64
);
assert!(changed as f64 / (WIDTH * HEIGHT) as f64 <= 0.005);
for (case, shift, instance_clip) in [
("translated", dvec2(0.0, 0.0), false),
("shifted_intersection", dvec2(11.0, -9.0), true),
] {
let scene = Scene {
translation: dvec2(25.0, 17.0),
shift,
instance_clip,
..base
};
let reference = render(scene, &directory.join(format!("{name}_{case}_reference")));
let clipped = render(
Scene {
list_clip: Some(CLIP),
..scene
},
&directory.join(format!("{name}_{case}_clipped")),
);
let [left, top, right, bottom] = CLIP;
let mut expected = reference.clone();
let mut outside_ink = 0;
let mut outside_pixels = 0;
let mut inside_ink = 0;
let mut removed_ink = 0;
let mut quad_boundary = 0;
for y in 0..HEIGHT {
for x in 0..WIDTH {
let offset = (y * WIDTH + x) * 4;
let pixel = &clipped[offset..offset + 4];
let original = &reference[offset..offset + 4];
let ink = pixel[1] > 8 || pixel[2] > 8;
if x < left || x >= right || y < top || y >= bottom {
outside_ink += usize::from(pixel[1] != 0 || pixel[2] != 0);
outside_pixels += usize::from(pixel[..3] != [0, 0, 0]);
removed_ink += usize::from(original[1] > 8 || original[2] > 8);
expected[offset..offset + 4].copy_from_slice(&[0, 0, 0, 255]);
} else {
inside_ink += usize::from(ink);
if (x == left || x == right - 1) && pixel == [255, 0, 0, 255] {
quad_boundary += 1;
}
}
}
}
let changed = diff(&expected, &clipped);
println!("{name} {case} pixels: outside_glyph={outside_ink} outside_clip={outside_pixels} inside_glyph={inside_ink} removed_glyph={removed_ink} quad_boundary={quad_boundary} crop_diff={changed}/{}", WIDTH * HEIGHT);
assert_eq!(
outside_ink, 0,
"{name} {case}: glyph leaked outside list clip"
);
assert_eq!(
outside_pixels, 0,
"{name} {case}: pixels leaked outside list clip"
);
assert!(
inside_ink > 100 && removed_ink > 100,
"must visibly cut glyphs"
);
assert!(quad_boundary > 10, "quad must reach the same clip boundary");
assert!(
changed as f64 / (WIDTH * HEIGHT) as f64 <= 0.005,
"{name} {case}: clipping changed glyph sampling"
);
}
}
if keep.is_none() {
std::fs::remove_dir_all(directory).unwrap();
}
}