rsplot 0.5.0

silx-style scientific plotting for egui, rendered with wgpu
Documentation
// Marker shader: draw a pixel-sized symbol at each polyline point.
//
// A non-instanced draw of 6 * point-count vertices builds one screen-space quad
// per point (read from the same storage buffer as the line). The quad spans
// local coords uv ∈ [-1, 1]²; the fragment shader fills the symbol's signed
// region and discards the rest. Sizes are in physical pixels, uniform under any
// data aspect ratio or zoom (doc/design.md §13 B2).

struct Params {
    ortho: mat4x4<f32>,
    color: vec4<f32>,
    axis_log: vec2<f32>,      // 1.0 if that axis is log10, else 0.0
    viewport_px: vec2<f32>,   // data-area size in physical pixels
    half_size_px: f32,        // half the marker size, in physical pixels
    // 0 circle, 1 square, 2 cross, 3 plus, 4 triangle, 5 diamond, 6 point,
    // 7 pixel, 8 vertical line, 9 horizontal line, 10..13 tick left/right/up/down,
    // 14..17 caret left/right/up/down, 18 heart (matches Symbol::code).
    symbol: u32,
    use_vertex_color: f32,    // >0.5 to take each marker's color from `vcolors`
};

@group(0) @binding(0) var<uniform> params: Params;
@group(0) @binding(1) var<storage, read> points: array<vec2<f32>>;
// Per-vertex linear premultiplied RGBA, one per point (silx per-point scatter
// colormap color). A 1-element placeholder when `use_vertex_color` is 0 (never
// sampled, but the binding must be present). Mirrors curve.wgsl's vcolors.
@group(0) @binding(2) var<storage, read> vcolors: array<vec4<f32>>;

const INV_LN10: f32 = 0.4342944819032518;

fn apply_scale(p: vec2<f32>) -> vec2<f32> {
    return vec2<f32>(
        select(p.x, log(p.x) * INV_LN10, params.axis_log.x > 0.5),
        select(p.y, log(p.y) * INV_LN10, params.axis_log.y > 0.5),
    );
}

fn to_ndc(p: vec2<f32>) -> vec2<f32> {
    let clip = params.ortho * vec4<f32>(apply_scale(p), 0.0, 1.0);
    return clip.xy / clip.w;
}

struct VsOut {
    @builtin(position) pos: vec4<f32>,
    @location(0) uv: vec2<f32>,
    // This marker's per-point color (constant across the quad — flat would do,
    // but the six quad vertices all read the same `vcolors[inst]`).
    @location(1) color: vec4<f32>,
};

@vertex
fn vs_main(@builtin(vertex_index) vid: u32) -> VsOut {
    let inst = vid / 6u;

    // Quad corners in local space (two triangles); function-local `var` so the
    // dynamic index works on every backend.
    var corners = array<vec2<f32>, 6>(
        vec2<f32>(-1.0, -1.0), vec2<f32>(1.0, -1.0), vec2<f32>(1.0, 1.0),
        vec2<f32>(-1.0, -1.0), vec2<f32>(1.0, 1.0), vec2<f32>(-1.0, 1.0),
    );
    let corner = corners[vid % 6u];

    let half_vp = params.viewport_px * 0.5;
    let center_px = to_ndc(points[inst]) * half_vp;
    let pos_px = center_px + corner * params.half_size_px;

    // This marker's per-point color. `vcolors` is a 1-element placeholder when
    // per-vertex color is off, so clamp the index to the bound array length to
    // stay in-bounds (the fragment shader discards it via `use_vertex_color`).
    let ci = min(inst, arrayLength(&vcolors) - 1u);

    var out: VsOut;
    out.pos = vec4<f32>(pos_px / half_vp, 0.0, 1.0);
    out.uv = corner;
    out.color = vcolors[ci];
    return out;
}

// Signed cross-product edge test, for the triangle symbol.
fn edge(p: vec2<f32>, a: vec2<f32>, b: vec2<f32>) -> f32 {
    return (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x);
}

fn inside(uv: vec2<f32>) -> bool {
    // Bar half-thickness for cross/plus symbols.
    let th = 0.32;
    // Pixel-space offset from the marker center: silx's symbol shaders test
    // against fixed-pixel thresholds (`size * (coord - 0.5)` in GLPlotCurve.py),
    // which equals `half_size_px * uv` here since `marker_size = 2 * half_size_px`
    // and `coord - 0.5 = uv / 2`. Used by the stroke/caret symbols (8..17).
    let pix = uv * params.half_size_px;
    switch params.symbol {
        case 0u: { // circle
            return dot(uv, uv) <= 1.0;
        }
        case 1u: { // square (fills the whole quad)
            return true;
        }
        case 2u: { // cross (X): near either diagonal
            let d1 = abs(uv.x - uv.y) * 0.70710677;
            let d2 = abs(uv.x + uv.y) * 0.70710677;
            return min(d1, d2) <= th;
        }
        case 3u: { // plus (+): horizontal or vertical bar
            return abs(uv.x) <= th || abs(uv.y) <= th;
        }
        case 4u: { // upward triangle
            let a = vec2<f32>(0.0, 1.0);
            let b = vec2<f32>(-0.866, -0.5);
            let c = vec2<f32>(0.866, -0.5);
            let e1 = edge(uv, a, b);
            let e2 = edge(uv, b, c);
            let e3 = edge(uv, c, a);
            return (e1 >= 0.0 && e2 >= 0.0 && e3 >= 0.0)
                || (e1 <= 0.0 && e2 <= 0.0 && e3 <= 0.0);
        }
        case 5u: { // diamond (rotated square): silx |cx| + |cy| < 0.5
            return abs(uv.x) + abs(uv.y) <= 1.0;
        }
        case 6u: { // point: a small filled circle (size shrunk on the CPU side)
            return dot(uv, uv) <= 1.0;
        }
        case 7u: { // pixel: a single-pixel square (size set to 1px on the CPU side)
            return true;
        }
        case 8u: { // vertical line: thin vertical stroke (silx |pix.x| <= 1)
            return abs(pix.x) <= 1.0;
        }
        case 9u: { // horizontal line: thin horizontal stroke (silx |pix.y| <= 1)
            return abs(pix.y) <= 1.0;
        }
        case 10u: { // tick left: horizontal stroke on the left half (silx pix.x <= 0.5)
            return pix.x <= 0.5 && abs(pix.y) <= 1.0;
        }
        case 11u: { // tick right: horizontal stroke on the right half (silx pix.x >= -0.5)
            return pix.x >= -0.5 && abs(pix.y) <= 1.0;
        }
        case 12u: { // tick up: vertical stroke on the upper half (silx pix.y <= 0.5)
            return pix.y <= 0.5 && abs(pix.x) <= 1.0;
        }
        case 13u: { // tick down: vertical stroke on the lower half (silx pix.y >= -0.5)
            return pix.y >= -0.5 && abs(pix.x) <= 1.0;
        }
        case 14u: { // caret left: open wedge, silx |pix.x| - |pix.y| >= -0.1, pix.x > 0.5
            return pix.x > 0.5 && (abs(pix.x) - abs(pix.y)) >= -0.1;
        }
        case 15u: { // caret right: silx |pix.x| - |pix.y| >= -0.1, pix.x < 0.5
            return pix.x < 0.5 && (abs(pix.x) - abs(pix.y)) >= -0.1;
        }
        case 16u: { // caret up: silx |pix.y| - |pix.x| >= -0.1, pix.y > 0.5
            return pix.y > 0.5 && (abs(pix.y) - abs(pix.x)) >= -0.1;
        }
        case 17u: { // caret down: silx |pix.y| - |pix.x| >= -0.1, pix.y < 0.5
            return pix.y < 0.5 && (abs(pix.y) - abs(pix.x)) >= -0.1;
        }
        case 18u: { // heart: silx cardioid SDF (GLPlotCurve.py HEART fragment).
            // silx works in `coord = (gl_PointCoord - 0.5) * 2`, which is exactly
            // our `uv`. It then scales, biases, and tests r - d(theta) against the
            // implicit heart curve. silx feathers the edge with
            // smoothstep(0.1, 0.001, r - d); the 0.5-alpha silhouette contour sits
            // at r - d ~= 0.05, which we use as the hard inside test.
            var p = uv * 0.75;
            p.y = p.y + 0.25;
            let a = atan2(p.x, -p.y) / 3.141593;
            let r = length(p);
            let h = abs(a);
            let d = (13.0 * h - 22.0 * h * h + 10.0 * h * h * h) / (6.0 - 5.0 * h);
            return (r - d) <= 0.05;
        }
        default: {
            return dot(uv, uv) <= 1.0;
        }
    }
}

@fragment
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
    if (!inside(in.uv)) {
        discard;
    }
    // Per-point scatter color when set (silx Scatter colormap RGBA, with any
    // per-point alpha already baked in on the CPU side), else the uniform color.
    return select(params.color, in.color, params.use_vertex_color > 0.5);
}