//! 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 { 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(); } }