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