1use rosace_core::shader::ShaderUniforms;
36use rosace_macros::ShaderUniforms;
37
38pub 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#[derive(ShaderUniforms)]
56pub struct BuiltinShapeUniforms {
57 pub color: [f32; 4],
59 pub color2: [f32; 4],
61 pub shape: [f32; 4],
64 pub params: [f32; 4],
66 pub quad: [f32; 2],
68}
69
70fn 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
79pub 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
89const AA_MARGIN: f32 = 1.0;
91
92fn 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
97pub 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
114pub 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
132pub 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
160pub 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
190const SHADOW_MARGIN: f32 = 2.0;
195
196pub 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
216const 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
357pub 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 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 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 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}