Skip to main content

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