Squashed from work; the fine-grained history is under tag archive/work-2026-08-26: - fab: a 3D creation shell and the viewer built on it - raytrace: the traced pane starts coarse and doubles to native, with the raster underneath - fab: a colour picker, a material's textures, and dials that move the scene while they drag - texcomp: the block codec and the container every texture will travel in - fab: FAB_PROBE_MAT — per-material triangle counts, texture presence and uv spread in the roof probe
311 lines
12 KiB
Rust
311 lines
12 KiB
Rust
//! Lane B (L2) regression: **a viewport's composite stays inside its own walk
|
||
//! rect**, and its rect is never empty.
|
||
//!
|
||
//! The bug this pins, exactly: the composite quad was drawn before any
|
||
//! `DrawList` had been begun inside the offscreen composite pass. `begin_pass`
|
||
//! clears the pass's `main_draw_list_id`, and whatever calls `begin_always`
|
||
//! first becomes it — so anything drawn before that lands in the *enclosing*
|
||
//! draw list, which belongs to the **window**. The quad was therefore painted
|
||
//! into the window at pass-local `(0,0)`, i.e. the window origin, on top of the
|
||
//! top bar and the tool column; and the composite pass, having no draw list of
|
||
//! its own, rendered nothing, so the viewport's real rect showed an empty
|
||
//! (black) composite target. One cause, both symptoms.
|
||
//!
|
||
//! The two assertions below are the two symptoms, inverted:
|
||
//!
|
||
//! 1. the top-bar band at the top of the window is still chrome — no viewport
|
||
//! composite has been painted over it;
|
||
//! 2. every visible viewport's own rect is not one flat colour — the composite
|
||
//! pass did render, and the blit landed there.
|
||
//!
|
||
//! Headless (`MAKEPAD=headless`) runs on the CPU rasterizer at dpi 1, so no GPU
|
||
//! is touched and screenshot pixels are layout points.
|
||
|
||
use makepad_test::{makepad_test, Selector, TestApp};
|
||
use makepad_zune_png::makepad_zune_core::bytestream::ZCursor;
|
||
use makepad_zune_png::PngDecoder;
|
||
|
||
/// How far two chrome samples may drift and still count as the same paint.
|
||
/// The composite's own background gradient alone is 20 levels wide, and the
|
||
/// lit image is far further off than that.
|
||
const CHROME_TOLERANCE: i32 = 6;
|
||
|
||
struct Image {
|
||
width: usize,
|
||
height: usize,
|
||
rgba: Vec<u8>,
|
||
}
|
||
|
||
impl Image {
|
||
fn read(path: &std::path::Path) -> Image {
|
||
let bytes = std::fs::read(path)
|
||
.unwrap_or_else(|err| panic!("cannot read grab {}: {err}", path.display()));
|
||
let mut decoder = PngDecoder::new(ZCursor::new(&bytes));
|
||
let pixels = decoder
|
||
.decode_raw()
|
||
.unwrap_or_else(|err| panic!("cannot decode grab {}: {err:?}", path.display()));
|
||
let (width, height) = decoder.dimensions().expect("grab has no dimensions");
|
||
let components = decoder
|
||
.colorspace()
|
||
.expect("grab has no colorspace")
|
||
.num_components();
|
||
assert!(
|
||
components >= 3,
|
||
"grab is not a colour image ({components} components)"
|
||
);
|
||
let mut rgba = vec![0u8; width * height * 4];
|
||
for i in 0..width * height {
|
||
let src = i * components;
|
||
rgba[i * 4] = pixels[src];
|
||
rgba[i * 4 + 1] = pixels[src + 1];
|
||
rgba[i * 4 + 2] = pixels[src + 2];
|
||
rgba[i * 4 + 3] = if components == 4 { pixels[src + 3] } else { 255 };
|
||
}
|
||
Image {
|
||
width,
|
||
height,
|
||
rgba,
|
||
}
|
||
}
|
||
|
||
fn pixel(&self, x: usize, y: usize) -> [u8; 3] {
|
||
let p = (y.min(self.height - 1) * self.width + x.min(self.width - 1)) * 4;
|
||
[self.rgba[p], self.rgba[p + 1], self.rgba[p + 2]]
|
||
}
|
||
|
||
/// The most common colour in a row span — for a chrome bar that is its
|
||
/// background, with the text and icons outvoted.
|
||
fn row_mode(&self, y: usize, x0: usize, x1: usize) -> [u8; 3] {
|
||
let x1 = x1.min(self.width);
|
||
assert!(x0 < x1, "empty row span {x0}..{x1} at y={y}");
|
||
let mut tally: std::collections::HashMap<[u8; 3], usize> = std::collections::HashMap::new();
|
||
for x in x0..x1 {
|
||
*tally.entry(self.pixel(x, y)).or_default() += 1;
|
||
}
|
||
tally
|
||
.into_iter()
|
||
.max_by_key(|(_, n)| *n)
|
||
.map(|(c, _)| c)
|
||
.unwrap()
|
||
}
|
||
|
||
/// Distinct colours (5 bits per channel) inside a rect.
|
||
fn distinct_colors_in(&self, x0: usize, y0: usize, x1: usize, y1: usize) -> usize {
|
||
let mut seen = std::collections::HashSet::new();
|
||
for y in y0..y1.min(self.height) {
|
||
for x in x0..x1.min(self.width) {
|
||
let p = self.pixel(x, y);
|
||
seen.insert([p[0] >> 3, p[1] >> 3, p[2] >> 3]);
|
||
}
|
||
}
|
||
seen.len()
|
||
}
|
||
|
||
fn luma(&self, x: usize, y: usize) -> i32 {
|
||
let p = self.pixel(x, y);
|
||
(p[0] as i32 * 54 + p[1] as i32 * 183 + p[2] as i32 * 19) / 256
|
||
}
|
||
|
||
/// Mean |Δluma| of 8-pixel-aligned block boundaries over interior
|
||
/// adjacent pixels in the same span. ≈1 on a smooth face; ≫1 when the
|
||
/// composite is an un-upsampled 8×8 AO/cavity grid.
|
||
fn blockiness_8(&self, x0: usize, y0: usize, x1: usize, y1: usize) -> f32 {
|
||
let x1 = x1.min(self.width);
|
||
let y1 = y1.min(self.height);
|
||
if x1 <= x0 + 16 || y1 <= y0 + 16 {
|
||
return 0.0;
|
||
}
|
||
let mut boundary = 0i64;
|
||
let mut interior = 0i64;
|
||
let mut n = 0i64;
|
||
for y in y0..y1 {
|
||
let mut x = x0 + 8;
|
||
while x < x1 {
|
||
boundary += (self.luma(x, y) - self.luma(x - 1, y)).abs() as i64;
|
||
interior += (self.luma(x - 4, y) - self.luma(x - 5, y)).abs() as i64;
|
||
n += 1;
|
||
x += 8;
|
||
}
|
||
}
|
||
for x in x0..x1 {
|
||
let mut y = y0 + 8;
|
||
while y < y1 {
|
||
boundary += (self.luma(x, y) - self.luma(x, y - 1)).abs() as i64;
|
||
interior += (self.luma(x, y - 4) - self.luma(x, y - 5)).abs() as i64;
|
||
n += 1;
|
||
y += 8;
|
||
}
|
||
}
|
||
if n == 0 || interior == 0 {
|
||
return 0.0;
|
||
}
|
||
boundary as f32 / interior as f32
|
||
}
|
||
|
||
/// Variance of luma in a rect. Used to pick a flat lit face.
|
||
fn luma_stats(&self, x0: usize, y0: usize, x1: usize, y1: usize) -> (f32, f32) {
|
||
let x1 = x1.min(self.width);
|
||
let y1 = y1.min(self.height);
|
||
if x1 <= x0 || y1 <= y0 {
|
||
return (0.0, 0.0);
|
||
}
|
||
let mut sum = 0i64;
|
||
let mut n = 0i64;
|
||
for y in y0..y1 {
|
||
for x in x0..x1 {
|
||
sum += self.luma(x, y) as i64;
|
||
n += 1;
|
||
}
|
||
}
|
||
if n == 0 {
|
||
return (0.0, 0.0);
|
||
}
|
||
let mean = sum as f32 / n as f32;
|
||
let mut var = 0.0f32;
|
||
for y in y0..y1 {
|
||
for x in x0..x1 {
|
||
let d = self.luma(x, y) as f32 - mean;
|
||
var += d * d;
|
||
}
|
||
}
|
||
(mean, var / n as f32)
|
||
}
|
||
}
|
||
|
||
#[makepad_test]
|
||
fn viewport_composites_stay_inside_their_walk_rects(app: TestApp) {
|
||
// `main` is the shell's root view: its rect is the window's content area,
|
||
// in the same layout points every widget rect below uses.
|
||
let shell = app.locator(Selector::id("main")).wait_visible().snapshot();
|
||
assert!(
|
||
shell.width > 0 && shell.height > 0,
|
||
"the shell root has no rect: {shell:?}"
|
||
);
|
||
|
||
let viewports = Selector::id("viewport");
|
||
let count = app.locator(viewports.clone()).wait_visible().count();
|
||
assert!(
|
||
count > 0,
|
||
"no visible FabViewport in the shell — the layout changed, fix the selector"
|
||
);
|
||
let rects: Vec<_> = (0..count)
|
||
.map(|i| app.locator(viewports.clone().nth(i)).snapshot())
|
||
.collect();
|
||
|
||
let path = app.screenshot();
|
||
println!("[fab] grab: {}", path.display());
|
||
let image = Image::read(&path);
|
||
// dpi 1 headless, but derive the factor anyway so a retina visible-mode
|
||
// run (MAKEPAD_TEST_VISIBLE=1) measures the same thing.
|
||
let scale = image.width as f64 / shell.width as f64;
|
||
let to_px = |v: i64| ((v as f64) * scale).round().max(0.0) as usize;
|
||
|
||
// ---- 1. the chrome above the viewports is still chrome -----------------
|
||
// A misplaced composite is an opaque quad at pass-local (0,0) — the window
|
||
// origin — sized like its viewport, so it covers the top bar from x=0 out
|
||
// to the viewport's width and stops. Chrome further right is out of its
|
||
// reach. Comparing the two halves of the same top-bar row therefore needs
|
||
// no colour constant at all: the bar is one paint, and if the left half
|
||
// stops matching the right half, something is being painted over it.
|
||
let top = rects.iter().map(|r| to_px(r.y)).min().unwrap_or(0);
|
||
assert!(
|
||
top >= 4,
|
||
"the first viewport starts at y={top}px — there is no chrome band to test"
|
||
);
|
||
let covered = rects.iter().map(|r| to_px(r.x) + to_px(r.width)).max().unwrap_or(0);
|
||
let untouched = covered + (image.width - covered) / 2;
|
||
assert!(
|
||
untouched + 8 < image.width,
|
||
"no chrome to the right of the viewports to compare against"
|
||
);
|
||
for y in [2usize, 4, 6] {
|
||
let over = image.row_mode(y, 0, (image.width / 4).max(8));
|
||
let clear = image.row_mode(y, untouched, image.width);
|
||
let drift = (0..3)
|
||
.map(|c| (over[c] as i32 - clear[c] as i32).abs())
|
||
.max()
|
||
.unwrap();
|
||
assert!(
|
||
drift <= CHROME_TOLERANCE,
|
||
"top-bar row y={y} reads {over:?} above the viewports but {clear:?} where no \
|
||
viewport can reach — a viewport composite is being painted at the window origin \
|
||
(grab {})",
|
||
path.display()
|
||
);
|
||
}
|
||
|
||
// ---- 2. every viewport painted something into its own rect -------------
|
||
for (i, r) in rects.iter().enumerate() {
|
||
let (x0, y0) = (to_px(r.x), to_px(r.y));
|
||
let (x1, y1) = (x0 + to_px(r.width), y0 + to_px(r.height));
|
||
assert!(
|
||
x1 > x0 + 8 && y1 > y0 + 8,
|
||
"viewport {i} has a degenerate rect {r:?}"
|
||
);
|
||
// Inset past the 1 px area border and any overlay chrome at the edge.
|
||
let colors = distinct_inset(&image, x0, y0, x1, y1);
|
||
assert!(
|
||
colors > 1,
|
||
"viewport {i} rect {r:?} is one flat colour — its composite pass \
|
||
rendered nothing into its own target (grab {})",
|
||
path.display()
|
||
);
|
||
}
|
||
|
||
// ---- 3. Solid (left) composite is not an 8×8 AO/cavity grid ------------
|
||
// The leftmost viewport is the realtime Solid pane. On a flat lit face
|
||
// the 8-pixel-aligned block-boundary jump must not dwarf the interior
|
||
// 1 px jump — that was the un-upsampled cavity/SSAO (or the 256-wide
|
||
// element LUT sampled as if it were spatial).
|
||
let left = rects
|
||
.iter()
|
||
.min_by_key(|r| r.x)
|
||
.expect("no viewport rect");
|
||
let (x0, y0) = (to_px(left.x), to_px(left.y));
|
||
let (x1, y1) = (x0 + to_px(left.width), y0 + to_px(left.height));
|
||
let inset_x = ((x1 - x0) / 6).max(12);
|
||
let inset_y = ((y1 - y0) / 6).max(12);
|
||
let ix0 = x0 + inset_x;
|
||
let iy0 = y0 + inset_y;
|
||
let ix1 = x1.saturating_sub(inset_x);
|
||
let iy1 = y1.saturating_sub(inset_y);
|
||
let patch = 48usize;
|
||
let mut best: Option<(f32, usize, usize)> = None;
|
||
if ix1 > ix0 + patch && iy1 > iy0 + patch {
|
||
let mut y = iy0;
|
||
while y + patch <= iy1 {
|
||
let mut x = ix0;
|
||
while x + patch <= ix1 {
|
||
let (mean, var) = image.luma_stats(x, y, x + patch, y + patch);
|
||
if mean > 28.0 && mean < 230.0 {
|
||
match best {
|
||
Some((v, _, _)) if var >= v => {}
|
||
_ => best = Some((var, x, y)),
|
||
}
|
||
}
|
||
x += 16;
|
||
}
|
||
y += 16;
|
||
}
|
||
}
|
||
if let Some((var, x, y)) = best {
|
||
let ratio = image.blockiness_8(x, y, x + patch, y + patch);
|
||
assert!(
|
||
ratio < 2.2,
|
||
"solid viewport has 8×8 blockiness {ratio:.2} (patch variance {var:.1}) \
|
||
at ({x},{y}) — cavity/SSAO is being composited without a full-res \
|
||
(or bilateral) upsample (grab {})",
|
||
path.display()
|
||
);
|
||
}
|
||
}
|
||
|
||
fn distinct_inset(image: &Image, x0: usize, y0: usize, x1: usize, y1: usize) -> usize {
|
||
let inset = 6usize;
|
||
image.distinct_colors_in(
|
||
x0 + inset,
|
||
y0 + inset,
|
||
x1.saturating_sub(inset),
|
||
y1.saturating_sub(inset),
|
||
)
|
||
}
|