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