Files
iris/tests/mask_sdf.rs
T
irisandClaude Opus 5 227f5e1295 Delete the decisions and design logs; scripts, rigs and xtask off the root
Iris: "remove both decisions and iris.md. I've decided to instead make
decisions when planning with agents rather than after they do things, and
they're both too long for me to wanna read, + don't cover all the
decisions I'll wanna make about the code anyways. I'll just naturally run
into things for now. Todo is important though."

So docs/DECISIONS.md (850 lines) and docs/IRIS.md (1,986) are gone, and
AGENTS.md now says not to start another: raise a choice while planning it
with her, otherwise decide it and put the reasoning at the code it
governs. The TODO lists stay. docs/SUBAGENTS_DECISIONS.md went with them
-- same artefact, same reasoning, and she did not name it, so its six
decisions were folded into docs/SUBAGENTS.md rather than deleted.

Deleting the logs left ~30 citations dangling in code comments and docs.
Each states its reason inline and cited the file only for provenance, so
they now read "decided 2026-09-07" or name the module doc that carries
the reasoning.

The root had six things that were not a program or a document. Moved,
per "I only meant top level sh files":

  run-tests.sh, test-wg-tunnel.sh, wg-setup-host.sh  -> scripts/
  rigs/                                              -> scripts/rigs/
  xtask/                                             -> scripts/xtask/

A project's own scripts stayed with the project: app/*.sh, app-rust/*.sh,
iris/*.sh and server/enroll-link.sh did not move.

`target/` at the root is deleted and cannot come back: there was never a
workspace there, and the 29 MB was only xtask's scratch space, now in
scripts/xtask/target/. `cargo xtask apk` still runs from the repo root
and now publishes to scripts/build/outputs/apk/<mode>/ -- one directory
deep, because that is what Dev Updater's `*/build/outputs/apk/*/*.apk`
discovery pattern needs, and scripts/xtask/build would have been two.

Verified: ./scripts/run-tests.sh and `cd iris && cargo test` green, clippy
and fmt clean everywhere, `cargo xtask apk debug --abi x86_64` builds and
signs an APK carrying lib/x86_64/libai_app.so at the new publish path, and
the repo root is now eleven entries with no build output among them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 00:16:24 -04:00

408 lines
16 KiB
Rust

//! The CPU rounded-rect SDF and the shader's own must agree.
//!
//! LAYOUT.md's "Masks with a shape" turns on it: the fragment stage clips
//! a masked subtree with `shader.wgsl`'s `rounded_rect_coverage`, and the
//! hit test (`UiRenderState::mask_admits`) clips the *same* subtree with
//! `iris_core::rounded_rect_coverage`, so a corner that cannot be tapped
//! and a corner that is not drawn are the same corner only while the two
//! functions answer the same. Nothing else checks that: both sides are
//! individually plausible and drift shows up as a control that is a pixel
//! or two off, which is exactly what nobody notices.
//!
//! So this runs **the real shader text**, lifted out of
//! `iris_core::SHAPE_SHADER` by name rather than copied here, over a grid
//! of points, and compares what came back with the Rust function at the
//! same points. This is the only test in the workspace that needs a GPU;
//! everything else about masks is layer 1 (docs/RUST.md's "Three test
//! layers"). It fails rather than skips when there is no adapter, because
//! a check that quietly did not run reads exactly like a check that
//! passed.
//!
//! **It is a render pass, and it asks for `iris_core::device_limits()`,
//! because those are the two things iris itself does.** The first version
//! of this test was a compute pass, which meant asking for compute limits
//! that `device_limits()` deliberately zeroes -- docs/RUST.md, 2026-09-05:
//! nothing in `iris`/`iris-core` creates a `ComputePipeline` or writes a
//! `@compute` stage, so the limits stopped being requested rather than a
//! fallback being built for a capability nothing uses. A test that needs
//! a capability the thing under test has never needed is testing the
//! wrong device, which is reason enough.
//!
//! It is **not** why that version crashed; see [`vulkan_instance`] for
//! what that crash actually was and why nothing here has to work around
//! it any more.
// `OnceLock<wgpu::Instance>` needs `Instance: Sync`, and wgpu's type
// graph is deep enough that proving it overflows rustc's default trait
// recursion limit of 128. Nothing here is recursive; the limit is a
// compile-time budget, and this is the documented way to raise it.
#![recursion_limit = "256"]
use std::sync::OnceLock;
use iris_core::{SHAPE_SHADER, rounded_rect_coverage, util::Vec2};
use pollster::FutureExt;
use wgpu::util::DeviceExt;
/// The rect the grid is sampled against, in window pixels. Deliberately
/// off the whole-pixel grid: the shader floors a primitive's corners, but
/// `rounded_rect_coverage` is handed pixels either side of that and has to
/// agree at fractional positions too -- the phone's 2.55 density puts
/// nothing on a whole pixel.
const TOP_LEFT: Vec2 = Vec2::new(10.5, 20.25);
const BOT_RIGHT: Vec2 = Vec2::new(170.75, 90.0);
/// Radii spanning what the widgets actually ask for, plus the two edges of
/// the function's own domain: a square corner, and one large enough that
/// `min(edge, radius)` stops mattering.
const RADII: [f32; 5] = [0.0, 0.75, 8.0, 20.0, 34.0];
/// The grid, as an attachment: one texel per probe point. `GRID_W` is a
/// multiple of 64 so that a row of `R32Float` is 256-byte aligned, which
/// is what `copy_texture_to_buffer` requires; at `STEP` this spans the
/// rect above and about four pixels of margin on every side, so the
/// feather is sampled rather than stepped over.
const GRID_W: u32 = 384;
const GRID_H: u32 = 192;
const STEP: f32 = 0.5;
const ORIGIN: Vec2 = Vec2::new(TOP_LEFT.x - 4.0, TOP_LEFT.y - 4.0);
/// f32 arithmetic in two compilers, not one: `length`/`sqrt` and
/// `smoothstep` are each allowed a unit or two in the last place, and the
/// GPU may contract a multiply-add the CPU does not. A coverage is in
/// [0, 1], so this is about six decimal digits -- four orders of magnitude
/// tighter than the half-pixel feather the hit test reads, which is what
/// the agreement is actually for.
const TOLERANCE: f32 = 1e-5;
#[test]
fn mask_sdf_matches_the_shader() {
let gpu = Gpu::open();
let mut worst = 0.0f32;
let mut worst_at = (Vec2::new(0.0, 0.0), 0.0f32, 0.0f32, 0.0f32);
let (mut inside, mut feather, mut outside) = (0u32, 0u32, 0u32);
for radius in RADII {
let coverage = run_shader(&gpu, radius);
for y in 0..GRID_H {
for x in 0..GRID_W {
let pos = probe_at(x, y);
let got = coverage[(y * GRID_W + x) as usize];
let want = rounded_rect_coverage(pos, TOP_LEFT, BOT_RIGHT, radius);
let diff = (got - want).abs();
if diff > worst {
worst = diff;
worst_at = (pos, radius, want, got);
}
if got > 0.999 {
inside += 1;
} else if got > 0.001 {
feather += 1;
} else {
outside += 1;
}
}
}
}
let (pos, radius, want, got) = worst_at;
assert!(
worst <= TOLERANCE,
"shader.wgsl's rounded_rect_coverage and iris_core's disagree by {worst} at {pos:?} \
(radius {radius}): the CPU says {want}, the GPU {got}. One of the two was edited \
without the other -- they are transliterations and have to stay so, or a masked \
corner stops being tappable where it is drawn.",
);
// The half that would pass on a function returning a constant.
assert!(
inside > 0 && feather > 0 && outside > 0,
"the grid never crossed an edge ({inside} in, {feather} on the feather, {outside} out), \
so agreeing proved nothing",
);
}
/// The probe position of texel `(x, y)` -- the one place the mapping
/// lives, so the CPU side and the fragment stage cannot walk different
/// grids.
fn probe_at(x: u32, y: u32) -> Vec2 {
Vec2::new(ORIGIN.x + x as f32 * STEP, ORIGIN.y + y as f32 * STEP)
}
/// `shader.wgsl`'s own `rounded_rect_coverage`, evaluated at every texel
/// of an `R32Float` attachment: one fragment per grid point, read back
/// whole. A fragment stage because that is the stage the function is
/// really called from, so what this compares is the code path that draws
/// rather than a second one built to be measurable.
fn run_shader(gpu: &Gpu, radius: f32) -> Vec<f32> {
let Gpu { device, queue, .. } = gpu;
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("mask sdf probe"),
source: wgpu::ShaderSource::Wgsl(probe_source().into()),
});
// R32Float, not an 8-bit colour format: a coverage quantised to 1/255
// could not be compared against the CPU's at anything like TOLERANCE,
// and the comparison would then be measuring the texture rather than
// the two functions.
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("mask sdf coverage"),
size: wgpu::Extent3d {
width: GRID_W,
height: GRID_H,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::R32Float,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = texture.create_view(&Default::default());
let probe = Probe {
top_left: [TOP_LEFT.x, TOP_LEFT.y],
bot_right: [BOT_RIGHT.x, BOT_RIGHT.y],
origin: [ORIGIN.x, ORIGIN.y],
step: [STEP, STEP],
radius,
_pad: [0.0; 3],
};
let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("mask sdf probe"),
contents: bytemuck::bytes_of(&probe),
usage: wgpu::BufferUsages::UNIFORM,
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("mask sdf probe"),
layout: None,
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("probe_vs"),
compilation_options: Default::default(),
buffers: &[],
},
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("probe_fs"),
compilation_options: Default::default(),
targets: &[Some(wgpu::TextureFormat::R32Float.into())],
}),
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
multiview_mask: None,
cache: None,
});
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("mask sdf probe"),
layout: &pipeline.get_bind_group_layout(0),
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: uniform.as_entire_binding(),
}],
});
// `copy_texture_to_buffer` wants each row 256-byte aligned; GRID_W is
// chosen so that it already is, rather than padding and unpicking the
// padding on the way out.
let row_bytes = GRID_W * 4;
assert_eq!(row_bytes % 256, 0, "GRID_W must keep rows 256-byte aligned");
let out_size = u64::from(row_bytes) * u64::from(GRID_H);
let read_buf = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("mask sdf readback"),
size: out_size,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut enc = device.create_command_encoder(&Default::default());
{
let mut pass = enc.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("mask sdf probe"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
multiview_mask: None,
timestamp_writes: None,
occlusion_query_set: None,
});
pass.set_pipeline(&pipeline);
pass.set_bind_group(0, &bind_group, &[]);
pass.draw(0..3, 0..1);
}
enc.copy_texture_to_buffer(
texture.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &read_buf,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(row_bytes),
rows_per_image: Some(GRID_H),
},
},
wgpu::Extent3d {
width: GRID_W,
height: GRID_H,
depth_or_array_layers: 1,
},
);
queue.submit([enc.finish()]);
let slice = read_buf.slice(..);
slice.map_async(wgpu::MapMode::Read, |r| r.expect("mapping the readback"));
device
.poll(wgpu::PollType::wait_indefinitely())
.expect("waiting for the probe");
let mapped = slice
.get_mapped_range()
.expect("reading back the mapped probe buffer");
let coverage = bytemuck::cast_slice::<u8, f32>(&mapped).to_vec();
drop(mapped);
read_buf.unmap();
coverage
}
/// One `wgpu::Instance` for the process, created on first use and never
/// destroyed.
///
/// **Why it is a static rather than a value the test owns.** Destroying
/// the last `VkInstance` makes the Vulkan loader `dlclose` the ICD, and
/// Mesa's ICD here registers a `pthread_key_create` destructor pointing
/// into its own text without being linked `-z nodelete`. glibc then calls
/// that destructor when the thread exits -- through an address that is no
/// longer mapped. libtest runs every `#[test]` on a spawned thread, so a
/// test that opens and closes an instance segfaults *after* printing its
/// result, which reads exactly like the test failing. Measured
/// 2026-09-08 with `scripts/rigs/gpu-probe`'s `teardown` bin: it
/// needs no wgpu (raw `ash` does it too), no GPU work, and no device --
/// an instance created and destroyed on a spawned thread is enough, and
/// keeping any one instance alive is enough to prevent it.
///
/// Devices, queues and everything else drop normally; only the instance
/// is held, which is what wgpu asks for anyway (one instance per
/// process). So this costs one instance for the length of a test binary
/// and buys ordinary drops everywhere else.
fn vulkan_instance() -> &'static wgpu::Instance {
static INSTANCE: OnceLock<wgpu::Instance> = OnceLock::new();
INSTANCE.get_or_init(wgpu::Instance::default)
}
/// The device this test draws with.
struct Gpu {
device: wgpu::Device,
queue: wgpu::Queue,
}
impl Gpu {
/// Opens the device this test draws with, and reports which adapter
/// answered, because that is not a detail here: a run on llvmpipe and
/// a run on the host's GPU are otherwise indistinguishable in the
/// log, and only one of them is a check of what the phone will do.
fn open() -> Self {
let instance = vulkan_instance();
let adapter = instance
.request_adapter(&wgpu::RequestAdapterOptions::default())
.block_on()
.expect(
"no wgpu adapter on this machine, so the CPU/shader SDF agreement went \
unchecked. This VM has a virtio-gpu render node (the `this-machine-graphics` \
skill says what it is and how it fails); if that is gone, fix it rather \
than deleting this test.",
);
let info = adapter.get_info();
eprintln!(
"mask_sdf: {} ({:?}, {})",
info.name, info.backend, info.driver
);
let (device, queue) = adapter
.request_device(&wgpu::DeviceDescriptor {
// What iris itself asks for -- see this file's header.
required_limits: iris_core::device_limits(),
..Default::default()
})
.block_on()
.expect("could not get a device from the adapter");
Self { device, queue }
}
}
/// What the fragment stage needs to turn its own texel into a probe
/// position: the rect being sampled, and where texel (0, 0) sits.
#[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
struct Probe {
top_left: [f32; 2],
bot_right: [f32; 2],
origin: [f32; 2],
step: [f32; 2],
radius: f32,
_pad: [f32; 3],
}
/// The probe module: the two functions **lifted from `shader.wgsl`
/// itself**, plus an entry point that calls the outer one. Lifted rather
/// than copied so there is nothing to keep in step -- an edit to the
/// shader is what this test is for, and a copy here would be edited along
/// with it.
fn probe_source() -> String {
format!(
"{}\n{}\n\
struct Probe {{\n\
top_left: vec2<f32>,\n\
bot_right: vec2<f32>,\n\
origin: vec2<f32>,\n\
step: vec2<f32>,\n\
radius: f32,\n\
}}\n\
@group(0) @binding(0) var<uniform> probe: Probe;\n\
@vertex\n\
fn probe_vs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4<f32> {{\n\
var xy = array(vec2(-1.0, -1.0), vec2(3.0, -1.0), vec2(-1.0, 3.0));\n\
return vec4(xy[vi], 0.0, 1.0);\n\
}}\n\
@fragment\n\
fn probe_fs(@builtin(position) pos: vec4<f32>) -> @location(0) f32 {{\n\
let at = probe.origin + floor(pos.xy) * probe.step;\n\
return rounded_rect_coverage(at, probe.top_left, probe.bot_right, probe.radius);\n\
}}\n",
wgsl_fn("distance_from_rect"),
wgsl_fn("rounded_rect_coverage"),
)
}
/// One WGSL function's whole text, from its `fn` keyword to the `}` that
/// closes its body, found by matching braces. Panics by name when the
/// function is not there, which is what a rename looks like from here.
fn wgsl_fn(name: &str) -> &'static str {
let start = SHAPE_SHADER
.find(&format!("fn {name}("))
.unwrap_or_else(|| panic!("shader.wgsl has no `fn {name}(` -- renamed, or gone"));
let body = SHAPE_SHADER[start..]
.find('{')
.expect("a wgsl fn signature is followed by its body");
let mut depth = 0usize;
for (i, c) in SHAPE_SHADER[start + body..].char_indices() {
match c {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
return &SHAPE_SHADER[start..start + body + i + 1];
}
}
_ => {}
}
}
panic!("`fn {name}`'s body in shader.wgsl is never closed");
}