Skip to main content

facett_core/render/cpu/
sdf.rs

1//! **CPU SDF coverage + AA-line raster** — the L0 fallback that produces the same
2//! pixels as the GPU pipeline ([`super::super::gpu::sdf_pipeline`]).
3//!
4//! This module is the **source of truth** for the signed-distance coverage math:
5//! the WGSL fragment shaders (`sdf.wgsl` / `line.wgsl`) evaluate the *same*
6//! functions, so a CPU and a GPU frame match (the `sdf_primitives` parity test
7//! pins this). It is pure, allocation-free coverage math — the compositing into a
8//! pixmap lives in [`super::CpuCanvas`].
9//!
10//! ## The coverage convention
11//! Every shape returns a coverage `α ∈ [0, 1]`: `1` fully inside, `0` fully
12//! outside, a smooth ramp across the AA band `[−aa, +aa]` around each edge. The
13//! ramp is `smoothstep`, matching the GPU `smoothstep`, so the AA fringe is
14//! identical. With `aa = 0` the edge is hard (a step at the boundary).
15
16use crate::render::prim::{shape, LineInstance, QuadInstance};
17
18/// `smoothstep(edge0, edge1, x)` — the cubic Hermite ramp, identical to WGSL's
19/// `smoothstep`. Used to turn a signed distance into AA coverage.
20#[inline]
21pub fn smoothstep(edge0: f32, edge1: f32, x: f32) -> f32 {
22    if edge0 == edge1 {
23        return if x < edge0 { 0.0 } else { 1.0 };
24    }
25    let t = ((x - edge0) / (edge1 - edge0)).clamp(0.0, 1.0);
26    t * t * (3.0 - 2.0 * t)
27}
28
29/// Coverage from a **signed distance** `d` (negative = inside) and an AA half-band
30/// `aa`: `1` deep inside, ramping to `0` across `[−aa, +aa]`. This is the single
31/// distance→coverage kernel every shape funnels through (the GPU does the same:
32/// `1 - smoothstep(-aa, aa, d)`).
33#[inline]
34pub fn coverage_from_sd(d: f32, aa: f32) -> f32 {
35    if aa <= 0.0 {
36        return if d <= 0.0 { 1.0 } else { 0.0 };
37    }
38    1.0 - smoothstep(-aa, aa, d)
39}
40
41/// Filled-disc coverage at a point `dist` pixels from the centre. `α = 1` inside
42/// `radius − aa`, ramps to `0` by `radius + aa`.
43#[inline]
44pub fn circle_coverage(dist: f32, radius: f32, aa: f32) -> f32 {
45    coverage_from_sd(dist - radius, aa)
46}
47
48/// Annulus (ring) coverage: inside the outer edge **and** outside the inner edge.
49/// Combines the disc SDF (`dist − outer`) with the hole SDF (`inner − dist`); the
50/// max of the two signed distances is the ring's SDF (intersection of "inside
51/// outer" and "outside inner").
52#[inline]
53pub fn ring_coverage(dist: f32, outer: f32, inner: f32, aa: f32) -> f32 {
54    let sd = (dist - outer).max(inner - dist);
55    coverage_from_sd(sd, aa)
56}
57
58/// Rounded-axis-aligned-square coverage. `(px, py)` is the offset from the centre;
59/// `half` the half-extent; `corner` the corner radius. The classic rounded-box
60/// SDF: `length(max(|p| − (half − corner), 0)) − corner`.
61#[inline]
62pub fn square_coverage(px: f32, py: f32, half: f32, corner: f32, aa: f32) -> f32 {
63    let corner = corner.clamp(0.0, half);
64    // Box-SDF with interior term: d = |p| - (half - corner).
65    let dx = px.abs() - (half - corner);
66    let dy = py.abs() - (half - corner);
67    let outside = (dx.max(0.0).powi(2) + dy.max(0.0).powi(2)).sqrt();
68    let inside = dx.max(dy).min(0.0); // negative when fully inside the box
69    let sd = outside + inside - corner;
70    coverage_from_sd(sd, aa)
71}
72
73/// Diamond (square rotated 45°) coverage: the L1 / Manhattan ball.
74/// `sd = (|x| + |y|) − half`.
75#[inline]
76pub fn diamond_coverage(px: f32, py: f32, half: f32, aa: f32) -> f32 {
77    let sd = (px.abs() + py.abs()) - half;
78    coverage_from_sd(sd, aa)
79}
80
81/// Upward triangle coverage as the intersection of three half-planes (bottom + two
82/// slanted sides). Each edge contributes a signed distance; the max is the convex
83/// polygon SDF (negative inside). Apex at `(0, −half)`, base corners at
84/// `(±half, +half)`. Robust + cheap — and identical to `sdf.wgsl`'s triangle.
85#[inline]
86pub fn triangle_coverage(px: f32, py: f32, half: f32, aa: f32) -> f32 {
87    // Bottom edge: y = +half, inside is y < half ⇒ sd = py - half.
88    let bottom = py - half;
89    // Right edge from apex (0,-half) to (half, half): direction (half, 2*half) ~ (1,2).
90    // Outward normal (2, -1) normalized; line through apex.
91    let inv = 1.0 / (5.0f32).sqrt(); // |(2,-1)| = sqrt(5)
92    let right = (px * 2.0 - (py + half)) * inv;
93    // Left edge from apex to (-half, half): mirror, outward normal (-2,-1).
94    let left = (px * -2.0 - (py + half)) * inv;
95    let sd = bottom.max(right).max(left);
96    coverage_from_sd(sd, aa)
97}
98
99/// Evaluate a [`QuadInstance`]'s coverage at the pixel-centre offset `(dx, dy)`
100/// from the instance centre. The single dispatch the CPU raster calls per pixel —
101/// it mirrors `sdf.wgsl`'s `coverage()` switch.
102#[inline]
103pub fn quad_coverage(inst: &QuadInstance, dx: f32, dy: f32) -> f32 {
104    match inst.shape {
105        shape::CIRCLE => circle_coverage((dx * dx + dy * dy).sqrt(), inst.radius, inst.aa),
106        shape::RING => ring_coverage((dx * dx + dy * dy).sqrt(), inst.radius, inst.inner, inst.aa),
107        shape::SQUARE => square_coverage(dx, dy, inst.radius, inst.inner, inst.aa),
108        shape::TRIANGLE => triangle_coverage(dx, dy, inst.radius, inst.aa),
109        shape::DIAMOND => diamond_coverage(dx, dy, inst.radius, inst.aa),
110        _ => 0.0,
111    }
112}
113
114/// Squared distance from point `p` to the segment `a → b`, plus the clamped
115/// parameter `t` (0 at `a`, 1 at `b`) — the line raster needs both (the `t` tells
116/// it whether a butt cap rejects the pixel past an endpoint).
117#[inline]
118pub fn point_segment(p: [f32; 2], a: [f32; 2], b: [f32; 2]) -> (f32, f32) {
119    let abx = b[0] - a[0];
120    let aby = b[1] - a[1];
121    let apx = p[0] - a[0];
122    let apy = p[1] - a[1];
123    let len2 = abx * abx + aby * aby;
124    let t = if len2 <= 1e-12 { 0.0 } else { ((apx * abx + apy * aby) / len2).clamp(0.0, 1.0) };
125    let cx = a[0] + abx * t;
126    let cy = a[1] + aby * t;
127    let dx = p[0] - cx;
128    let dy = p[1] - cy;
129    (dx * dx + dy * dy, t)
130}
131
132/// Thick-AA-line coverage at pixel `p` for a [`LineInstance`]. Distance to the
133/// segment vs `half_width`, with the AA ramp — the **same** capsule SDF the GPU
134/// `line.wgsl` evaluates. A **butt** cap additionally rejects the rounded
135/// overshoot past the endpoints (so the line ends flush).
136#[inline]
137pub fn line_coverage(inst: &LineInstance, p: [f32; 2]) -> f32 {
138    let (d2, _t) = point_segment(p, inst.a, inst.b);
139    let dist = d2.sqrt();
140    let mut cov = coverage_from_sd(dist - inst.half_width, inst.aa);
141
142    if inst.cap == crate::render::prim::cap::BUTT {
143        // Clip the rounded ends to a flat cap: reject the part of the pixel beyond
144        // the segment span along the line direction. Project p onto the (un-
145        // clamped) line axis; coverage falls off past [0, len] over the AA band.
146        let abx = inst.b[0] - inst.a[0];
147        let aby = inst.b[1] - inst.a[1];
148        let len = (abx * abx + aby * aby).sqrt();
149        if len > 1e-6 {
150            let ux = abx / len;
151            let uy = aby / len;
152            let s = (p[0] - inst.a[0]) * ux + (p[1] - inst.a[1]) * uy; // 0..len inside
153            // signed distance outside the [0, len] band along the axis.
154            let axis_sd = (-s).max(s - len);
155            cov *= coverage_from_sd(axis_sd, inst.aa);
156        }
157    }
158    cov
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164    use crate::render::prim::{CircleInstance, LineInstance, MarkerInstance, RingInstance};
165
166    #[test]
167    fn coverage_kernel_is_monotone_across_the_band() {
168        // Inside → 1, outside → 0, monotone ramp between.
169        assert_eq!(coverage_from_sd(-5.0, 1.0), 1.0);
170        assert_eq!(coverage_from_sd(5.0, 1.0), 0.0);
171        let mut prev = 1.0;
172        let mut d = -1.0;
173        while d <= 1.0 {
174            let c = coverage_from_sd(d, 1.0);
175            assert!(c <= prev + 1e-6, "monotone non-increasing as d grows");
176            prev = c;
177            d += 0.1;
178        }
179        // aa=0 ⇒ hard step.
180        assert_eq!(coverage_from_sd(-0.01, 0.0), 1.0);
181        assert_eq!(coverage_from_sd(0.01, 0.0), 0.0);
182    }
183
184    #[test]
185    fn circle_lit_only_inside_radius_band() {
186        let r = 10.0;
187        let aa = 1.0;
188        assert!(circle_coverage(0.0, r, aa) > 0.99, "centre fully lit");
189        assert!(circle_coverage(r - aa - 0.5, r, aa) > 0.99, "just inside fully lit");
190        assert!(circle_coverage(r + aa + 0.5, r, aa) < 0.01, "outside band dark");
191        assert!((circle_coverage(r, r, aa) - 0.5).abs() < 0.05, "edge ≈ half coverage");
192    }
193
194    #[test]
195    fn ring_has_a_hole() {
196        let outer = 12.0;
197        let inner = 6.0;
198        let aa = 1.0;
199        assert!(ring_coverage(9.0, outer, inner, aa) > 0.99, "in the band is lit");
200        assert!(ring_coverage(0.0, outer, inner, aa) < 0.01, "centre is a hole");
201        assert!(ring_coverage(20.0, outer, inner, aa) < 0.01, "outside dark");
202    }
203
204    #[test]
205    fn marker_shapes_differ_at_a_corner() {
206        let half = 10.0;
207        let aa = 0.5;
208        // A point near the box corner (8,8): inside the square, outside the diamond
209        // (|8|+|8|=16 > 10) and outside the triangle.
210        assert!(square_coverage(8.0, 8.0, half, 0.0, aa) > 0.9, "square fills its corner");
211        assert!(diamond_coverage(8.0, 8.0, half, aa) < 0.1, "diamond excludes the corner");
212        // Triangle: apex up. A point high-centre is inside; a point at the top
213        // corners is outside.
214        assert!(triangle_coverage(0.0, 7.0, half, aa) > 0.9, "triangle base-centre inside");
215        assert!(triangle_coverage(9.0, -9.0, half, aa) < 0.1, "triangle top-corner outside");
216    }
217
218    #[test]
219    fn quad_coverage_dispatches_per_shape() {
220        let c = CircleInstance { center: [0.0, 0.0], radius: 5.0, color: [1.0; 4], aa: 1.0 }.lower();
221        assert!(quad_coverage(&c, 0.0, 0.0) > 0.99);
222        assert!(quad_coverage(&c, 10.0, 0.0) < 0.01);
223
224        let ring = RingInstance { center: [0.0; 2], radius: 8.0, inner: 4.0, color: [1.0; 4], aa: 1.0 }
225            .lower();
226        assert!(quad_coverage(&ring, 0.0, 0.0) < 0.01, "ring centre hole");
227        assert!(quad_coverage(&ring, 6.0, 0.0) > 0.9, "ring band lit");
228
229        let diamond = MarkerInstance {
230            center: [0.0; 2],
231            radius: 10.0,
232            corner: 0.0,
233            color: [1.0; 4],
234            aa: 0.5,
235            shape: shape::DIAMOND,
236        }
237        .lower();
238        assert!(quad_coverage(&diamond, 0.0, 0.0) > 0.9);
239        assert!(quad_coverage(&diamond, 9.0, 9.0) < 0.1);
240    }
241
242    #[test]
243    fn line_coverage_matches_requested_width() {
244        // Horizontal segment, half_width 3 → coverage out to ±3 from the axis.
245        let l = LineInstance::round([10.0, 50.0], [90.0, 50.0], 3.0, 1.0, [1.0; 4]);
246        assert!(line_coverage(&l, [50.0, 50.0]) > 0.99, "on axis lit");
247        assert!(line_coverage(&l, [50.0, 52.0]) > 0.9, "within half_width lit");
248        assert!(line_coverage(&l, [50.0, 56.0]) < 0.05, "beyond width+aa dark");
249        assert!((line_coverage(&l, [50.0, 53.0]) - 0.5).abs() < 0.1, "edge ≈ half coverage");
250    }
251
252    #[test]
253    fn butt_cap_ends_flush_round_cap_overshoots() {
254        let butt = LineInstance::butt([20.0, 50.0], [80.0, 50.0], 4.0, 1.0, [1.0; 4]);
255        let round = LineInstance::round([20.0, 50.0], [80.0, 50.0], 4.0, 1.0, [1.0; 4]);
256        // Past the endpoint along the axis: round cap still covers, butt does not.
257        assert!(round.cap == crate::render::prim::cap::ROUND);
258        assert!(line_coverage(&round, [82.0, 50.0]) > 0.5, "round cap overshoots");
259        assert!(line_coverage(&butt, [82.0, 50.0]) < 0.2, "butt cap ends flush");
260        // Both cover the interior identically.
261        assert!(line_coverage(&butt, [50.0, 50.0]) > 0.99);
262        assert!(line_coverage(&round, [50.0, 50.0]) > 0.99);
263    }
264}