Skip to main content

aetna_core/paint/
mod.rs

1//! Paint-stream types and helpers shared by every backend.
2//!
3//! The `QuadInstance` ABI is the cross-backend contract: every
4//! rect-shaped pipeline (stock or custom) reads the same 4 × `vec4<f32>`
5//! layout, so the layout pass's logical-pixel rects compose with each
6//! backend's GPU pipelines without per-backend tweaking. `aetna-wgpu`
7//! and `aetna-vulkano` build different pipelines around it; the bytes
8//! the vertex shader sees are identical.
9//!
10//! `PaintItem` + `InstanceRun` + [`close_run`] are the paint-stream
11//! batching shape: walk the [`crate::DrawOp`] list, pack `Quad`s into
12//! the instance buffer in groups of consecutive same-pipeline +
13//! same-scissor runs, intersperse text layers in their original
14//! z-order. Both backends consume this exactly the same way.
15//!
16//! The one paint concern this module *doesn't* own is `set_scissor` —
17//! that one needs the backend-specific encoder type, so each backend
18//! keeps a thin `set_scissor` of its own.
19//!
20//! Sibling modules:
21//! - [`ir`] — the backend-neutral [`DrawOp`] enum the El tree resolves into.
22//! - [`draw_ops`] — the producer pass that walks the tree + state and emits `DrawOp`s.
23//! - [`shader`] — `ShaderHandle`, `StockShader`, uniform-block types.
24//! - [`surface`] — `AppTexture` / surface-format types for host-owned textures.
25
26pub mod draw_ops;
27pub mod ir;
28pub mod shader;
29pub mod surface;
30
31use bytemuck::{Pod, Zeroable};
32
33use crate::tree::{Color, Rect};
34use crate::vector::IconMaterial;
35use shader::{ShaderHandle, StockShader, UniformBlock, UniformValue};
36
37/// One instance of a rect-shaped shader. Layout is shared between
38/// `stock::rounded_rect` and any custom shader registered via the host's
39/// `register_shader`. The fragment shader interprets the slots however
40/// it wants; the vertex shader uses `rect` to place the unit quad in
41/// pixel space.
42///
43/// `inner_rect` is the original layout rect — equal to `rect` when
44/// `paint_overflow` is zero, smaller (set inside `rect`) when the
45/// element has opted into painting outside its bounds. SDF shaders
46/// anchor their geometry to `inner_rect` so the rounded outline stays
47/// where layout placed it; the overflow band is where focus rings,
48/// drop shadows, and other halos render.
49#[repr(C)]
50#[derive(Copy, Clone, Pod, Zeroable, Debug)]
51pub struct QuadInstance {
52    /// Painted rect — xy = top-left px, zw = size px. Equal to
53    /// `inner_rect` when no `paint_overflow`. Vertex shader reads at
54    /// `@location(1)`.
55    pub rect: [f32; 4],
56    /// `vec_a` slot — for stock::rounded_rect, this is `fill`. Vertex
57    /// shader reads at `@location(2)`.
58    pub slot_a: [f32; 4],
59    /// `vec_b` slot — for stock::rounded_rect, this is `stroke`.
60    /// Vertex shader reads at `@location(3)`.
61    pub slot_b: [f32; 4],
62    /// `vec_c` slot — for stock::rounded_rect, this is
63    /// `(stroke_width, max_radius, shadow, focus_width)`. Positive
64    /// `focus_width` draws outside the layout rect; negative draws inside.
65    /// `max_radius`
66    /// is the largest of the four per-corner radii (in `slot_e`); it
67    /// stays here so custom shaders that read scalar `slot_c.y` as
68    /// the radius keep working when corners are uniform. Vertex
69    /// shader reads at `@location(4)`.
70    pub slot_c: [f32; 4],
71    /// Layout rect (xy = top-left px, zw = size px). SDF shaders use
72    /// this so the rect outline stays anchored to layout bounds even
73    /// when `rect` has been outset for `paint_overflow`. Vertex shader
74    /// reads at `@location(5)` — declared *after* the legacy slots so
75    /// custom shaders that only consume locations 1..=4 keep working
76    /// unchanged.
77    pub inner_rect: [f32; 4],
78    /// `vec_d` slot — for stock::rounded_rect, this is the ring
79    /// color (rgba) with eased alpha already multiplied in. Zero when
80    /// the node isn't focused or isn't focusable. Vertex shader reads
81    /// at `@location(6)`.
82    pub slot_d: [f32; 4],
83    /// `vec_e` slot — for stock::rounded_rect, this is per-corner
84    /// radii in `(tl, tr, br, bl)` order (logical px). Custom shaders
85    /// that don't care about per-corner shapes can ignore this slot.
86    /// Vertex shader reads at `@location(7)`.
87    pub slot_e: [f32; 4],
88}
89
90/// One line-segment primitive in a vector icon. The instance renders a
91/// single antialiased stroke into `rect`; higher-level icon paths are
92/// flattened into runs of these records by the backend recorder.
93#[repr(C)]
94#[derive(Copy, Clone, Pod, Zeroable, Debug)]
95pub struct IconInstance {
96    /// Painted bounds for the segment, outset for stroke width and AA.
97    /// Vertex shader reads at `@location(1)`.
98    pub rect: [f32; 4],
99    /// Segment endpoints in logical px: `(x0, y0, x1, y1)`.
100    /// Fragment shader reads at `@location(2)`.
101    pub line: [f32; 4],
102    /// Linear rgba color. Fragment shader reads at `@location(3)`.
103    pub color: [f32; 4],
104    /// `(stroke_width, reserved, reserved, reserved)`.
105    /// Fragment shader reads at `@location(4)`.
106    pub params: [f32; 4],
107}
108
109/// A contiguous run of instances drawn with the same pipeline + scissor.
110/// Built in tree order so a custom shader sandwiched between two stock
111/// surfaces is drawn at the right z-position.
112#[derive(Clone, Copy)]
113pub struct InstanceRun {
114    pub handle: ShaderHandle,
115    pub scissor: Option<PhysicalScissor>,
116    pub first: u32,
117    pub count: u32,
118}
119
120/// Which icon-draw path a backend uses for this run.
121///
122/// `Tess` runs index into the backend's tessellated vector mesh
123/// (vertex range, expanded triangles). `Msdf` runs index into the
124/// backend's per-instance MSDF buffer (one entry = one icon quad) and
125/// must bind the atlas page identified by `IconRun::page`.
126#[derive(Clone, Copy, Debug, PartialEq, Eq)]
127pub enum IconRunKind {
128    Tess,
129    Msdf,
130}
131
132/// A contiguous run of backend-owned icon draws sharing a scissor.
133///
134/// For `Tess` runs, `first..first+count` is a vertex range in the
135/// backend's vector-mesh buffer and `material` selects the fragment
136/// shader (flat / relief / glass). For `Msdf` runs, `first..first+count`
137/// is an instance range in the backend's MSDF instance buffer; `page`
138/// names the atlas page to bind. `material` is always `Flat` for MSDF
139/// runs — non-flat materials need the per-fragment local view-box
140/// coordinate that the tessellated path provides, so they stay on the
141/// `Tess` route.
142#[derive(Clone, Copy)]
143pub struct IconRun {
144    pub kind: IconRunKind,
145    pub scissor: Option<PhysicalScissor>,
146    pub first: u32,
147    pub count: u32,
148    pub page: u32,
149    pub material: IconMaterial,
150}
151
152/// Scissor in **physical pixels** (host swapchain extent), already
153/// clamped to the surface and snapped to integer pixel boundaries.
154#[derive(Clone, Copy, Debug, PartialEq, Eq)]
155pub struct PhysicalScissor {
156    pub x: u32,
157    pub y: u32,
158    pub w: u32,
159    pub h: u32,
160}
161
162/// Sequencing entry for the recorded paint stream.
163///
164/// - `QuadRun(idx)` — a contiguous instance run (indexed into `runs`).
165/// - `IconRun(idx)` — a vector icon run (backend-owned storage,
166///   indexed by the wgpu icon painter; other backends may keep using
167///   text fallback and never emit this item).
168/// - `Text(idx)` — a glyph layer (indexed into the backend's
169///   `TextLayer` vector).
170/// - `BackdropSnapshot` — a pass boundary. The backend ends the
171///   current render pass, copies the current target into its managed
172///   snapshot texture, and begins a new pass with `LoadOp::Load` so
173///   subsequent quads can sample the snapshot via the `backdrop` bind
174///   group. At most one of these is emitted per frame, inserted by
175///   [`crate::runtime::RunnerCore::prepare_paint`] immediately before
176///   the first quad bound to a `samples_backdrop` shader.
177#[derive(Clone, Copy)]
178pub enum PaintItem {
179    QuadRun(usize),
180    IconRun(usize),
181    Text(usize),
182    /// One raster image draw. Indexes into the backend's
183    /// `ImagePaint`-equivalent storage. Produced by
184    /// [`crate::runtime::TextRecorder::record_image`] from a
185    /// [`crate::ir::DrawOp::Image`].
186    Image(usize),
187    /// One app-owned-texture composite. Indexes into the backend's
188    /// `SurfacePaint`-equivalent storage. Produced by the backend's
189    /// surface recorder from a [`crate::ir::DrawOp::AppTexture`].
190    AppTexture(usize),
191    /// One app-supplied vector draw. Indexes into the backend's vector
192    /// storage; explicit render mode determines whether that storage is
193    /// tessellated geometry or an MSDF atlas entry. Produced from a
194    /// [`crate::ir::DrawOp::Vector`].
195    Vector(usize),
196    BackdropSnapshot,
197}
198
199/// Close the current run and append it to `runs` + `paint_items`. No-op
200/// when `run_key` is `None` or the run is empty.
201pub fn close_run(
202    runs: &mut Vec<InstanceRun>,
203    paint_items: &mut Vec<PaintItem>,
204    run_key: Option<(ShaderHandle, Option<PhysicalScissor>)>,
205    first: u32,
206    end: u32,
207) {
208    if let Some((handle, scissor)) = run_key {
209        let count = end - first;
210        if count > 0 {
211            let index = runs.len();
212            runs.push(InstanceRun {
213                handle,
214                scissor,
215                first,
216                count,
217            });
218            paint_items.push(PaintItem::QuadRun(index));
219        }
220    }
221}
222
223/// Convert a logical-pixel scissor to physical pixels, clamping to the
224/// physical viewport. Returns `None` when the input is `None`.
225pub fn physical_scissor(
226    scissor: Option<Rect>,
227    scale: f32,
228    viewport_px: (u32, u32),
229) -> Option<PhysicalScissor> {
230    let r = scissor?;
231    let x1 = (r.x * scale).floor().clamp(0.0, viewport_px.0 as f32) as u32;
232    let y1 = (r.y * scale).floor().clamp(0.0, viewport_px.1 as f32) as u32;
233    let x2 = (r.right() * scale).ceil().clamp(0.0, viewport_px.0 as f32) as u32;
234    let y2 = (r.bottom() * scale).ceil().clamp(0.0, viewport_px.1 as f32) as u32;
235    Some(PhysicalScissor {
236        x: x1,
237        y: y1,
238        w: x2.saturating_sub(x1),
239        h: y2.saturating_sub(y1),
240    })
241}
242
243/// Pack a quad's uniforms into the shared `QuadInstance` layout. Stock
244/// `rounded_rect` reads its named uniforms; everything else reads the
245/// generic `vec_a`/`vec_b`/`vec_c`/`vec_d` slots. `inner_rect` falls
246/// back to `rect` when the uniform isn't supplied — i.e. when the node
247/// has no `paint_overflow`.
248pub fn pack_instance(rect: Rect, shader: ShaderHandle, uniforms: &UniformBlock) -> QuadInstance {
249    let rect_arr = [rect.x, rect.y, rect.w, rect.h];
250    let inner_rect = uniforms
251        .get("inner_rect")
252        .map(value_to_vec4)
253        .unwrap_or(rect_arr);
254
255    match shader {
256        ShaderHandle::Stock(StockShader::RoundedRect) => {
257            let radii = uniforms.get("radii").map(value_to_vec4);
258            // Fall back to the scalar `radius` uniform when no
259            // per-corner block was inserted (custom callers, focus
260            // ring band, etc.). Either path produces a valid
261            // four-corner instance — callers that only set scalar
262            // `radius` get uniform corners.
263            let scalar_radius = uniforms.get("radius").and_then(as_f32).unwrap_or(0.0);
264            let radii = radii.unwrap_or([scalar_radius; 4]);
265            let max_radius = radii[0].max(radii[1]).max(radii[2]).max(radii[3]);
266            QuadInstance {
267                rect: rect_arr,
268                inner_rect,
269                slot_a: uniforms
270                    .get("fill")
271                    .and_then(as_color)
272                    .map(rgba_f32)
273                    .unwrap_or([0.0; 4]),
274                slot_b: uniforms
275                    .get("stroke")
276                    .and_then(as_color)
277                    .map(rgba_f32)
278                    .unwrap_or([0.0; 4]),
279                slot_c: [
280                    uniforms.get("stroke_width").and_then(as_f32).unwrap_or(0.0),
281                    max_radius,
282                    uniforms.get("shadow").and_then(as_f32).unwrap_or(0.0),
283                    uniforms.get("focus_width").and_then(as_f32).unwrap_or(0.0),
284                ],
285                slot_d: uniforms
286                    .get("focus_color")
287                    .and_then(as_color)
288                    .map(rgba_f32)
289                    .unwrap_or([0.0; 4]),
290                slot_e: radii,
291            }
292        }
293        _ => QuadInstance {
294            rect: rect_arr,
295            inner_rect,
296            slot_a: uniforms.get("vec_a").map(value_to_vec4).unwrap_or([0.0; 4]),
297            slot_b: uniforms.get("vec_b").map(value_to_vec4).unwrap_or([0.0; 4]),
298            slot_c: uniforms.get("vec_c").map(value_to_vec4).unwrap_or([0.0; 4]),
299            slot_d: uniforms.get("vec_d").map(value_to_vec4).unwrap_or([0.0; 4]),
300            slot_e: uniforms.get("vec_e").map(value_to_vec4).unwrap_or([0.0; 4]),
301        },
302    }
303}
304
305fn as_color(v: &UniformValue) -> Option<Color> {
306    match v {
307        UniformValue::Color(c) => Some(*c),
308        _ => None,
309    }
310}
311fn as_f32(v: &UniformValue) -> Option<f32> {
312    match v {
313        UniformValue::F32(f) => Some(*f),
314        _ => None,
315    }
316}
317
318/// Coerce any `UniformValue` into the four floats of a vec4 slot.
319/// Custom-shader authors typically pass `Color` (rgba) or `Vec4`
320/// (arbitrary semantics); `F32` packs into `.x` so a single scalar like
321/// `radius` doesn't need a Vec4 wrapper.
322fn value_to_vec4(v: &UniformValue) -> [f32; 4] {
323    match v {
324        UniformValue::Color(c) => rgba_f32(*c),
325        UniformValue::Vec4(a) => *a,
326        UniformValue::Vec2([x, y]) => [*x, *y, 0.0, 0.0],
327        UniformValue::F32(f) => [*f, 0.0, 0.0, 0.0],
328        UniformValue::Bool(b) => [if *b { 1.0 } else { 0.0 }, 0.0, 0.0, 0.0],
329    }
330}
331
332/// Convert a token sRGB color to the four linear floats the shader
333/// reads. Tokens are authored in sRGB display space; the surface is an
334/// *Srgb format so alpha blending happens in linear space (correct
335/// for color blending, slightly fattens light-on-dark text).
336pub fn rgba_f32(c: Color) -> [f32; 4] {
337    [
338        srgb_to_linear(c.r as f32 / 255.0),
339        srgb_to_linear(c.g as f32 / 255.0),
340        srgb_to_linear(c.b as f32 / 255.0),
341        c.a as f32 / 255.0,
342    ]
343}
344
345fn srgb_to_linear(c: f32) -> f32 {
346    if c <= 0.04045 {
347        c / 12.92
348    } else {
349        ((c + 0.055) / 1.055).powf(2.4)
350    }
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356    use crate::shader::UniformBlock;
357    use crate::tokens;
358
359    #[test]
360    fn focus_uniforms_pack_into_rounded_rect_slots() {
361        // Focus ring rides on the node's own RoundedRect quad: focus_color
362        // packs into slot_d (rgba) and focus_width into slot_c.w (the
363        // params slot's previously-padding lane).
364        let mut uniforms = UniformBlock::new();
365        uniforms.insert("fill", UniformValue::Color(Color::rgba(40, 40, 40, 255)));
366        uniforms.insert("radius", UniformValue::F32(8.0));
367        uniforms.insert("focus_color", UniformValue::Color(tokens::RING));
368        uniforms.insert("focus_width", UniformValue::F32(tokens::RING_WIDTH));
369
370        let inst = pack_instance(
371            Rect::new(1.0, 2.0, 30.0, 40.0),
372            ShaderHandle::Stock(StockShader::RoundedRect),
373            &uniforms,
374        );
375
376        assert_eq!(inst.rect, [1.0, 2.0, 30.0, 40.0]);
377        assert_eq!(
378            inst.inner_rect, inst.rect,
379            "no inner_rect uniform → fall back to painted rect"
380        );
381        assert_eq!(
382            inst.slot_c[1], 8.0,
383            "max corner radius in slot_c.y (uniform corners derived from scalar `radius` uniform)"
384        );
385        assert_eq!(
386            inst.slot_e,
387            [8.0, 8.0, 8.0, 8.0],
388            "scalar `radius` uniform fills all four corners on slot_e"
389        );
390        assert_eq!(
391            inst.slot_c[3],
392            tokens::RING_WIDTH,
393            "focus_width in slot_c.w"
394        );
395        assert!(inst.slot_d[3] > 0.0, "focus_color alpha should be visible");
396    }
397
398    #[test]
399    fn per_corner_radii_uniform_routes_to_slot_e() {
400        // The `radii` uniform overrides the scalar `radius` for the
401        // SDF, while `slot_c.y` carries the max corner so custom
402        // shaders that read scalar `slot_c.y` still see the right
403        // shape silhouette.
404        let mut uniforms = UniformBlock::new();
405        uniforms.insert("fill", UniformValue::Color(Color::rgba(40, 40, 40, 255)));
406        // Top-rounded only — the strip-on-card shape.
407        uniforms.insert("radii", UniformValue::Vec4([12.0, 12.0, 0.0, 0.0]));
408        uniforms.insert("radius", UniformValue::F32(12.0));
409
410        let inst = pack_instance(
411            Rect::new(0.0, 0.0, 100.0, 40.0),
412            ShaderHandle::Stock(StockShader::RoundedRect),
413            &uniforms,
414        );
415
416        assert_eq!(inst.slot_e, [12.0, 12.0, 0.0, 0.0]);
417        assert_eq!(inst.slot_c[1], 12.0, "max corner radius -> slot_c.y");
418    }
419
420    #[test]
421    fn physical_scissor_converts_logical_to_physical_pixels() {
422        let scissor = physical_scissor(Some(Rect::new(10.2, 20.2, 30.2, 40.2)), 2.0, (200, 200))
423            .expect("scissor");
424
425        assert_eq!(
426            scissor,
427            PhysicalScissor {
428                x: 20,
429                y: 40,
430                w: 61,
431                h: 81
432            }
433        );
434    }
435}