graph-explorer-render 0.3.0

WebGL2/wgpu renderer for graph-explorer — nodes, edges, halos and labels.
Documentation
struct Camera {
  center: vec2<f32>,
  zoom: f32,
  fade: f32,
  viewport: vec2<f32>,
  _pad1: vec2<f32>,
};
@group(0) @binding(0) var<uniform> cam: Camera;

// Half-width of the antialiasing ramp, in pixels. The quad is grown by this
// much on each side so the coverage falloff has geometry to run on; without
// the growth the ramp would be clipped by the stroke's own hard boundary and
// the edge would stay as jagged as before.
const AA_PAD: f32 = 1.0;

// Never rasterize a stroke thinner than this many pixels. Below roughly half a
// pixel a line stops being a continuous run of partially-covered pixels and
// starts breaking into a dotted crawl that shimmers as the camera moves. We
// hold the width at the floor and scale alpha down by however much we widened,
// which keeps the apparent ink density right while killing the shimmer.
const MIN_HALF_PX: f32 = 0.5;

// unit quad in (t, s): t along the edge 0..1, s across -1..1
var<private> CORNERS: array<vec2<f32>, 6> = array<vec2<f32>, 6>(
  vec2<f32>(0.0, -1.0), vec2<f32>(1.0, -1.0), vec2<f32>(1.0, 1.0),
  vec2<f32>(0.0, -1.0), vec2<f32>(1.0, 1.0), vec2<f32>(0.0, 1.0),
);

struct VsOut {
  @builtin(position) clip: vec4<f32>,
  @location(0) color: vec4<f32>,
  @location(1) opacity: f32,
  // Signed distance across the stroke in PIXELS, interpolated. Pixels (not the
  // old normalized -1..1) because the AA ramp must be one pixel wide at every
  // zoom, and only a pixel-space coordinate makes that a constant.
  @location(2) across_px: f32,
  // The stroke's half-width in pixels, after the sub-pixel floor. Flat: it is
  // constant per edge, and interpolating it would bend the ramp.
  @location(3) @interpolate(flat) half_px: f32,
};

@vertex
fn vs_main(
  @builtin(vertex_index) vi: u32,
  @location(0) a: vec2<f32>,
  @location(1) b: vec2<f32>,
  @location(2) color: vec4<f32>,
  @location(3) width: f32,
  @location(4) opacity: f32,
) -> VsOut {
  let c = CORNERS[vi];
  let d = b - a;
  let len = length(d);
  let dir = select(vec2<f32>(1.0, 0.0), d / len, len > 1e-5);
  let normal = vec2<f32>(-dir.y, dir.x);

  let half_px_true = width * 0.5 * cam.zoom;
  let half_px = max(half_px_true, MIN_HALF_PX);
  // Only ever fades (never brightens): half_px_true > MIN_HALF_PX leaves this
  // clamped at 1.0, so ordinary strokes are untouched.
  let thin_fade = min(half_px_true / MIN_HALF_PX, 1.0);

  // Grow across by the ramp, converting the pixel pad back to world units so
  // the expansion is a constant pixel width at any zoom.
  let out_px = half_px + AA_PAD;
  let world = mix(a, b, c.x) + normal * (out_px / cam.zoom) * c.y;

  let rel = (world - cam.center) * cam.zoom;
  let clip = vec2<f32>(rel.x / (cam.viewport.x * 0.5), rel.y / (cam.viewport.y * 0.5));
  var out: VsOut;
  out.clip = vec4<f32>(clip, 0.0, 1.0);
  out.color = color;
  out.opacity = opacity * thin_fade;
  out.across_px = c.y * out_px;
  out.half_px = half_px;
  return out;
}

@fragment
fn fs_main(in: VsOut) -> @location(0) vec4<f32> {
  // Box-filter coverage: the fraction of a one-pixel-wide sample straddling
  // this fragment that falls inside the stroke. Exact for a straight edge —
  // which every segment of a line quad is — and costs two adds and a clamp.
  let coverage = clamp(in.half_px - abs(in.across_px) + 0.5, 0.0, 1.0);
  if (coverage <= 0.0) { discard; }
  return vec4<f32>(in.color.rgb, in.color.a * in.opacity * coverage * cam.fade);
}