Skip to main content

rosace_render/
gpu_shapes.rs

1//! Built-in GPU shape pipelines (D109 / Phase 27 Step 3) — geometry,
2//! uniform layout, and WGSL sources.
3//!
4//! Lives in `rosace-render` (Layer 4) so `SkiaCanvas::play_picture` can
5//! convert shape `DrawCommand`s to GPU quads without an upward dependency;
6//! `rosace-shader::builtin` (Layer 5) wraps these WGSL sources into
7//! `ShaderSpec`s and registers them, and re-exports everything here for
8//! app-facing use.
9//!
10//! The SDF fragment shaders that replace `tiny-skia`'s CPU rasterization
11//! for ROSACE's built-in `DrawCommand` shapes. Five pipelines cover all
12//! eight shape variants:
13//!
14//! | Pipeline        | Serves                                          |
15//! |-----------------|-------------------------------------------------|
16//! | `FILL_RRECT`    | `FillRect` (r=0), `FillRRect`, `FillCircle`     |
17//! | `STROKE_RRECT`  | `StrokeRect` (r=0), `StrokeRRect`               |
18//! | `GRADIENT`      | `FillGradient` (two-stop, axis-aligned, rounded)|
19//! | `ARC`           | `FillArc` (ring segment, round caps)            |
20//! | `SHADOW`        | `DrawShadow` (Gaussian-approx rounded shadow)   |
21//!
22//! Every conversion function here maps a shape in PHYSICAL pixels to
23//! `(quad_rect, uniform_bytes)`: the quad is the shape's bounds inflated by
24//! the AA/blur margin (an SDF's anti-aliasing ramp extends past the exact
25//! bounds; an un-inflated quad would slice the ramp off), and the uniforms
26//! carry the true geometry in quad-local px. Colors are sRGB u8 (what
27//! `DrawCommand` records) converted to LINEAR premultiplied-ready f32 here
28//! — the fragment outputs linear, the sRGB surface encodes on write (the
29//! same one-correct-round-trip rule as the compositor's texture formats).
30//!
31//! All uniform structs share ONE layout (`BuiltinShapeUniforms`, 64 bytes)
32//! so every pipeline binds identically; each interprets `params` its own
33//! way, documented per conversion function.
34
35use rosace_core::shader::ShaderUniforms;
36use rosace_macros::ShaderUniforms;
37
38/// Raw pipeline ids of the built-in pipelines — inside `PipelineId`'s
39/// reserved built-in range (< 0x100). `rosace-shader::builtin` exposes the
40/// typed constants.
41pub const FILL_RRECT_ID:   u64 = 1;
42pub const STROKE_RRECT_ID: u64 = 2;
43pub const GRADIENT_ID:     u64 = 3;
44pub const ARC_ID:          u64 = 4;
45pub const SHADOW_ID:       u64 = 5;
46
47/// The one uniform layout every built-in pipeline binds.
48///
49/// All geometry is in the RECORDING's units (quad-local), with `quad`
50/// carrying the quad's size in those same units. The shader computes
51/// `px_scale = rosace_quad.size_px / quad` and scales everything itself —
52/// so a conversion done at logical px (widget recording, scaled ×DPR at
53/// replay) and one done at physical px (scale 1:1) are BOTH correct, and
54/// HiDPI can never desynchronize uniforms from quad placement.
55#[derive(ShaderUniforms)]
56pub struct BuiltinShapeUniforms {
57    /// Primary color, LINEAR straight-alpha RGBA.
58    pub color:  [f32; 4],
59    /// Secondary color (gradient `to`), LINEAR straight-alpha RGBA.
60    pub color2: [f32; 4],
61    /// Shape geometry in quad-local units: rects (x, y, w, h); arcs
62    /// (center_x, center_y, unused, unused).
63    pub shape:  [f32; 4],
64    /// Pipeline-specific parameters — see each conversion fn.
65    pub params: [f32; 4],
66    /// The quad's (w, h) in the same units as `shape`/`params` lengths.
67    pub quad:   [f32; 2],
68}
69
70/// sRGB u8 → linear f32, the exact EOTF (not the 2.2 shortcut) — this is
71/// the inverse of what the sRGB swapchain applies on write, so a shader
72/// fill of `Color::rgb(43,45,48)` lands at (43,45,48), byte-identical to
73/// the CPU path (the 2026-07-08 double-gamma bug is the cautionary tale).
74fn srgb_to_linear(c: u8) -> f32 {
75    let x = c as f32 / 255.0;
76    if x <= 0.04045 { x / 12.92 } else { ((x + 0.055) / 1.055).powf(2.4) }
77}
78
79/// sRGB u8 RGBA → linear f32 RGBA (alpha is linear already).
80pub fn linear_rgba(rgba: [u8; 4]) -> [f32; 4] {
81    [
82        srgb_to_linear(rgba[0]),
83        srgb_to_linear(rgba[1]),
84        srgb_to_linear(rgba[2]),
85        rgba[3] as f32 / 255.0,
86    ]
87}
88
89/// AA margin: the SDF coverage ramp is 1px wide, centered on the edge.
90const AA_MARGIN: f32 = 1.0;
91
92/// A quad rect `(x, y, w, h)` inflated by `m` on every side.
93fn inflate(rect: (f32, f32, f32, f32), m: f32) -> (f32, f32, f32, f32) {
94    (rect.0 - m, rect.1 - m, rect.2 + 2.0 * m, rect.3 + 2.0 * m)
95}
96
97/// `FillRect`/`FillRRect`/`FillCircle` → `FILL_RRECT` quad.
98/// params: (corner_radius, 0, 0, 0).
99pub fn fill_rrect_quad(
100    rect: (f32, f32, f32, f32), radius: f32, rgba: [u8; 4],
101) -> ((f32, f32, f32, f32), Vec<u8>) {
102    let quad = inflate(rect, AA_MARGIN);
103    let r = radius.max(0.0).min(rect.2 / 2.0).min(rect.3 / 2.0);
104    let u = BuiltinShapeUniforms {
105        color:  linear_rgba(rgba),
106        color2: [0.0; 4],
107        shape:  [rect.0 - quad.0, rect.1 - quad.1, rect.2, rect.3],
108        params: [r, 0.0, 0.0, 0.0],
109        quad:   [quad.2, quad.3],
110    };
111    (quad, u.to_bytes())
112}
113
114/// `StrokeRect`/`StrokeRRect` → `STROKE_RRECT` quad. The stroke is centered
115/// on the shape edge (tiny-skia `Stroke` convention).
116/// params: (corner_radius, stroke_width, 0, 0).
117pub fn stroke_rrect_quad(
118    rect: (f32, f32, f32, f32), radius: f32, width: f32, rgba: [u8; 4],
119) -> ((f32, f32, f32, f32), Vec<u8>) {
120    let quad = inflate(rect, AA_MARGIN + width / 2.0);
121    let r = radius.max(0.0).min(rect.2 / 2.0).min(rect.3 / 2.0);
122    let u = BuiltinShapeUniforms {
123        color:  linear_rgba(rgba),
124        color2: [0.0; 4],
125        shape:  [rect.0 - quad.0, rect.1 - quad.1, rect.2, rect.3],
126        params: [r, width, 0.0, 0.0],
127        quad:   [quad.2, quad.3],
128    };
129    (quad, u.to_bytes())
130}
131
132/// `FillGradient` → `GRADIENT` quad. Two stops, `from` at the rect's
133/// top/left edge to `to` at the bottom/right (pad spread), masked by the
134/// rounded rect. params: (corner_radius, vertical ? 1 : 0, 0, 0).
135///
136/// Colors are passed as sRGB (NOT pre-linearized like every other
137/// pipeline): tiny-skia interpolates gradient stops in sRGB space, so the
138/// shader must mix in sRGB and linearize AFTER — verified by A/B midpoint
139/// sampling (linear-space mixing measured +16/255 red at the midpoint of
140/// the violet→blue reference gradient vs the CPU path).
141pub fn gradient_quad(
142    rect: (f32, f32, f32, f32), radius: f32, from: [u8; 4], to: [u8; 4], vertical: bool,
143) -> ((f32, f32, f32, f32), Vec<u8>) {
144    let quad = inflate(rect, AA_MARGIN);
145    let r = radius.max(0.0).min(rect.2 / 2.0).min(rect.3 / 2.0);
146    let srgb = |c: [u8; 4]| [
147        c[0] as f32 / 255.0, c[1] as f32 / 255.0,
148        c[2] as f32 / 255.0, c[3] as f32 / 255.0,
149    ];
150    let u = BuiltinShapeUniforms {
151        color:  srgb(from),
152        color2: srgb(to),
153        shape:  [rect.0 - quad.0, rect.1 - quad.1, rect.2, rect.3],
154        params: [r, if vertical { 1.0 } else { 0.0 }, 0.0, 0.0],
155        quad:   [quad.2, quad.3],
156    };
157    (quad, u.to_bytes())
158}
159
160/// `FillArc` → `ARC` quad: ring segment of `thickness` along the circle of
161/// `radius` centered at `center`, from `start_deg` sweeping `sweep_deg`
162/// clockwise (0° = 3 o'clock, y-down), ROUND caps (the CPU path strokes
163/// with `LineCap::Round`). shape: (center in quad-local px);
164/// params: (radius, thickness, start_rad, sweep_rad) — sweep normalized
165/// non-negative here so the shader needs no sign handling.
166pub fn arc_quad(
167    center: (f32, f32), radius: f32, thickness: f32,
168    start_deg: f32, sweep_deg: f32, rgba: [u8; 4],
169) -> ((f32, f32, f32, f32), Vec<u8>) {
170    let reach = radius + thickness / 2.0;
171    let quad = inflate(
172        (center.0 - reach, center.1 - reach, reach * 2.0, reach * 2.0),
173        AA_MARGIN,
174    );
175    let (start, sweep) = if sweep_deg < 0.0 {
176        (start_deg + sweep_deg, -sweep_deg)
177    } else {
178        (start_deg, sweep_deg)
179    };
180    let u = BuiltinShapeUniforms {
181        color:  linear_rgba(rgba),
182        color2: [0.0; 4],
183        shape:  [center.0 - quad.0, center.1 - quad.1, 0.0, 0.0],
184        params: [radius, thickness, start.to_radians(), sweep.min(360.0).to_radians()],
185        quad:   [quad.2, quad.3],
186    };
187    (quad, u.to_bytes())
188}
189
190/// Blur margin multiplier: the visible falloff of the CPU path's
191/// triple-box-blur mask extends roughly 1.5×blur past the rect (its mask
192/// allocates `margin = blur` on each side plus the AA edge); 2× is safely
193/// past visually-zero for the Gaussian approximation too.
194const SHADOW_MARGIN: f32 = 2.0;
195
196/// `DrawShadow` → `SHADOW` quad: Gaussian-approximate drop shadow of the
197/// rounded rect. params: (corner_radius, sigma, 0, 0). Sigma maps from the
198/// CPU path's box-blur `blur` parameter: three box passes of width b
199/// approximate a Gaussian with σ ≈ b/2 — tuned against the real
200/// `build_shadow_mask` output in the A/B demo, not derived on paper.
201pub fn shadow_quad(
202    rect: (f32, f32, f32, f32), radius: f32, blur: f32, rgba: [u8; 4],
203) -> ((f32, f32, f32, f32), Vec<u8>) {
204    let quad = inflate(rect, AA_MARGIN + blur.max(0.0) * SHADOW_MARGIN);
205    let r = radius.max(0.0).min(rect.2 / 2.0).min(rect.3 / 2.0);
206    let u = BuiltinShapeUniforms {
207        color:  linear_rgba(rgba),
208        color2: [0.0; 4],
209        shape:  [rect.0 - quad.0, rect.1 - quad.1, rect.2, rect.3],
210        params: [r, (blur * 0.5).max(0.25), 0.0, 0.0],
211        quad:   [quad.2, quad.3],
212    };
213    (quad, u.to_bytes())
214}
215
216/// Shared WGSL: uniform struct + SDF library, prepended to every built-in
217/// fragment. (The framework's vertex stage + `rosace_quad` come from the
218/// compositor's own header — see shader_quad_header.wgsl.)
219const SDF_LIB: &str = r#"
220struct BuiltinShapeUniforms {
221    color:  vec4<f32>,
222    color2: vec4<f32>,
223    shape:  vec4<f32>,
224    params: vec4<f32>,
225    quad:   vec2<f32>,
226};
227@group(0) @binding(1) var<uniform> u: BuiltinShapeUniforms;
228
229// Signed distance to a rounded rect centered at the origin with half-size
230// `half` and corner radius `r`. Negative inside.
231fn sd_rrect(p: vec2<f32>, half: vec2<f32>, r: f32) -> f32 {
232    let q = abs(p) - half + vec2<f32>(r, r);
233    return length(max(q, vec2<f32>(0.0, 0.0))) + min(max(q.x, q.y), 0.0) - r;
234}
235
236// 1px-wide coverage ramp centered on the edge (d in px).
237fn aa_cov(d: f32) -> f32 {
238    return clamp(0.5 - d, 0.0, 1.0);
239}
240
241// Quad-local position in physical px.
242fn local_px(uv: vec2<f32>) -> vec2<f32> {
243    return uv * rosace_quad.size_px;
244}
245
246// Recording-units -> physical-px scale (the DPR when uniforms were built
247// from logical px; 1.0 when built from physical px). x == y in practice.
248fn px_scale() -> vec2<f32> {
249    return rosace_quad.size_px / max(u.quad, vec2<f32>(1e-6, 1e-6));
250}
251
252// Premultiply a straight-alpha linear color by coverage.
253fn out_color(c: vec4<f32>, cov: f32) -> vec4<f32> {
254    let a = c.a * cov;
255    return vec4<f32>(c.rgb * a, a);
256}
257"#;
258
259const FILL_RRECT_FS: &str = r#"
260@fragment
261fn fs_main(in: RosaceVsOut) -> @location(0) vec4<f32> {
262    let sc = px_scale();
263    let p = local_px(in.uv) - (u.shape.xy + u.shape.zw * 0.5) * sc;
264    let d = sd_rrect(p, u.shape.zw * 0.5 * sc, u.params.x * sc.x);
265    return out_color(u.color, aa_cov(d));
266}
267"#;
268
269const STROKE_RRECT_FS: &str = r#"
270@fragment
271fn fs_main(in: RosaceVsOut) -> @location(0) vec4<f32> {
272    let sc = px_scale();
273    let p = local_px(in.uv) - (u.shape.xy + u.shape.zw * 0.5) * sc;
274    let d = abs(sd_rrect(p, u.shape.zw * 0.5 * sc, u.params.x * sc.x)) - u.params.y * sc.x * 0.5;
275    return out_color(u.color, aa_cov(d));
276}
277"#;
278
279const GRADIENT_FS: &str = r#"
280@fragment
281fn fs_main(in: RosaceVsOut) -> @location(0) vec4<f32> {
282    let sc = px_scale();
283    let lp = local_px(in.uv) - u.shape.xy * sc;
284    var t: f32;
285    if u.params.y > 0.5 {
286        t = clamp(lp.y / max(u.shape.w * sc.y, 1e-6), 0.0, 1.0);
287    } else {
288        t = clamp(lp.x / max(u.shape.z * sc.x, 1e-6), 0.0, 1.0);
289    }
290    // Mix in sRGB (tiny-skia's convention), then linearize for output —
291    // the surface re-encodes to sRGB on write.
292    let c_srgb = mix(u.color, u.color2, t);
293    let lo = c_srgb.rgb / 12.92;
294    let hi = pow((c_srgb.rgb + vec3<f32>(0.055)) / 1.055, vec3<f32>(2.4));
295    let c = vec4<f32>(select(hi, lo, c_srgb.rgb <= vec3<f32>(0.04045)), c_srgb.a);
296    let p = local_px(in.uv) - (u.shape.xy + u.shape.zw * 0.5) * sc;
297    let d = sd_rrect(p, u.shape.zw * 0.5 * sc, u.params.x * sc.x);
298    return out_color(c, aa_cov(d));
299}
300"#;
301
302const ARC_FS: &str = r#"
303const TAU: f32 = 6.28318530718;
304
305@fragment
306fn fs_main(in: RosaceVsOut) -> @location(0) vec4<f32> {
307    let sc = px_scale();
308    let p = local_px(in.uv) - u.shape.xy * sc;
309    let radius = u.params.x * sc.x;
310    let start  = u.params.z;
311    let sweep  = u.params.w;
312
313    // Angle of this pixel, wrapped relative to the arc start.
314    var rel = atan2(p.y, p.x) - start;
315    rel = rel - floor(rel / TAU) * TAU; // wrap to [0, TAU)
316
317    var d: f32;
318    if rel <= sweep {
319        // Within the swept angle: distance to the arc's centerline circle.
320        d = abs(length(p) - radius);
321    } else {
322        // Outside: distance to the nearer endpoint — round caps for free.
323        let e0 = vec2<f32>(cos(start), sin(start)) * radius;
324        let a1 = start + sweep;
325        let e1 = vec2<f32>(cos(a1), sin(a1)) * radius;
326        d = min(distance(p, e0), distance(p, e1));
327    }
328    return out_color(u.color, aa_cov(d - u.params.y * sc.x * 0.5));
329}
330"#;
331
332const SHADOW_FS: &str = r#"
333// Gaussian CDF via an Abramowitz-Stegun-style erf approximation — the
334// rounded-rect SDF pushed through the CDF gives the blurred coverage
335// (exact along straight edges, slightly tighter than a true 2D blur at
336// corners; visually verified against the CPU box-blur mask in the A/B
337// demo).
338fn erf_approx(x: f32) -> f32 {
339    let s = sign(x);
340    let a = abs(x);
341    var t = 1.0 + (0.278393 + (0.230389 + 0.078108 * a * a) * a) * a;
342    t = t * t;
343    return s - s / (t * t);
344}
345
346@fragment
347fn fs_main(in: RosaceVsOut) -> @location(0) vec4<f32> {
348    let sc = px_scale();
349    let p = local_px(in.uv) - (u.shape.xy + u.shape.zw * 0.5) * sc;
350    let d = sd_rrect(p, u.shape.zw * 0.5 * sc, u.params.x * sc.x);
351    let sigma = max(u.params.y * sc.x, 0.25);
352    let cov = 0.5 - 0.5 * erf_approx(d / (sigma * 1.41421356));
353    return out_color(u.color, cov);
354}
355"#;
356
357/// The five built-in pipelines as `(raw_id, full_wgsl_fragment_source)` —
358/// consumed by `rosace-shader::builtin::register_builtins()`, which wraps
359/// them in `ShaderSpec`s (a Layer-5 type this crate cannot name).
360pub fn builtin_wgsl_sources() -> [(u64, String); 5] {
361    let src = |fs: &str| format!("{SDF_LIB}\n{fs}");
362    [
363        (FILL_RRECT_ID,   src(FILL_RRECT_FS)),
364        (STROKE_RRECT_ID, src(STROKE_RRECT_FS)),
365        (GRADIENT_ID,     src(GRADIENT_FS)),
366        (ARC_ID,          src(ARC_FS)),
367        (SHADOW_ID,       src(SHADOW_FS)),
368    ]
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374
375    #[test]
376    fn fill_quad_inflates_by_aa_margin_and_offsets_shape_into_quad_space() {
377        let (quad, bytes) = fill_rrect_quad((10.0, 20.0, 100.0, 50.0), 8.0, [255, 0, 0, 255]);
378        assert_eq!(quad, (9.0, 19.0, 102.0, 52.0));
379        assert_eq!(bytes.len(), 80, "4×vec4 + vec2 quad size, rounded to 16");
380        // shape.xy = true rect origin in quad-local px = (1, 1).
381        assert_eq!(&bytes[32..40], &[1.0f32.to_le_bytes(), 1.0f32.to_le_bytes()].concat()[..]);
382    }
383
384    #[test]
385    fn stroke_quad_margin_covers_half_the_stroke_width() {
386        let (quad, _) = stroke_rrect_quad((100.0, 100.0, 50.0, 50.0), 0.0, 6.0, [0, 0, 0, 255]);
387        // Inflation = 1 (AA) + 3 (width/2) = 4 per side.
388        assert_eq!(quad, (96.0, 96.0, 58.0, 58.0));
389    }
390
391    #[test]
392    fn negative_sweep_normalizes_to_positive_from_shifted_start() {
393        let (_, bytes) = arc_quad((50.0, 50.0), 20.0, 4.0, 90.0, -90.0, [0, 0, 0, 255]);
394        let start = f32::from_le_bytes(bytes[56..60].try_into().unwrap());
395        let sweep = f32::from_le_bytes(bytes[60..64].try_into().unwrap());
396        assert!((start - 0.0f32.to_radians()).abs() < 1e-6, "start must shift back: {start}");
397        assert!((sweep - 90.0f32.to_radians()).abs() < 1e-6, "sweep must be positive: {sweep}");
398    }
399
400    #[test]
401    fn srgb_conversion_round_trips_the_known_gamma_bug_color() {
402        // The D109 gamma discipline test color: #2B2D30 must come back as
403        // itself after linear → sRGB-surface encode. Linearizing 43/255
404        // then re-encoding must round-trip to 43.
405        let lin = srgb_to_linear(43);
406        let re = if lin <= 0.0031308 { lin * 12.92 } else { 1.055 * lin.powf(1.0 / 2.4) - 0.055 };
407        assert_eq!((re * 255.0).round() as u8, 43);
408    }
409
410    #[test]
411    fn radius_clamps_to_half_extent_like_the_cpu_path() {
412        let (_, bytes) = fill_rrect_quad((0.0, 0.0, 20.0, 10.0), 99.0, [0, 0, 0, 255]);
413        let r = f32::from_le_bytes(bytes[48..52].try_into().unwrap());
414        assert_eq!(r, 5.0, "radius must clamp to min(w,h)/2");
415    }
416}