Iris: "the organization of the rust rewrite is a mess right now... there shouldn't be anything related to the app inside of iris. Iris is supposed to be the UI framework alone." And, on the crate count: "I'm confused why the app only code needs more than one crate though." Nine cargo workspaces become three, and the port's project code -- which sat in five places, four of them inside the framework -- becomes one crate, `ai-app`, in `app-rust/`: client-core -> app-rust/src/client iris/transcript-ui -> app-rust/src/ui iris/transcript-fixture -> app-rust/src/ui/fixture.rs + tests/ + touch/ iris/desktop-app -> app-rust/src/desktop + src/bin_desktop.rs iris/android-app -> app-rust/src/android + android-project/ android-shell -> app-rust/src/shell iris/ keeps core, macro, the iris crate, tabs-ui and rig-input, and now mentions no session, transcript, setup or server anywhere. Only two of the old splits had a reason that survived reading. event-model stays a crate at the repo root because server/ depends on it too, so a crate is what makes the backend and the app agree by construction. The two Android .so names looked like a hard constraint -- a package produces one library artifact -- until P2 turned out to already plan merging those two Android apps into one; both faces now come out of libai_app.so, picked apart by features so `--no-default-features --features shell` keeps wgpu, parley and iris out of the Compose app's APK. docs/RUST.md's "One app crate" has the rest, including what each remaining feature is for. DECISIONS.md and SUBAGENTS.md move into docs/ with everything else. Verified: ./run-tests.sh and `cd iris && cargo test` green, clippy and fmt clean in all five workspaces, `cargo ndk -t x86_64` links libai_app.so, build-apk.sh produces an APK that installs and launches on this checkout's emulator (Gl ... virgl, as expected), and the phone-sized headless screenshot renders the transcript unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
408 lines
16 KiB
Rust
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 `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");
|
|
}
|