feather-ui 0.4.0

Feather UI library
Documentation
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: 2025 Fundament Research Institute <https://fundament.institute>

//#import "feather.wgsl"
const UNITX = array(0.0, 1.0, 0.0, 1.0, 1.0, 0.0);
const UNITY = array(0.0, 0.0, 1.0, 0.0, 1.0, 1.0);

fn linearstep(low: f32, high: f32, x: f32) -> f32 {
  return clamp((x - low) / (high - low), 0.0f, 1.0f);
}

fn u32_to_vec4(c: u32) -> vec4<f32> {
  return vec4<f32>(f32((c & 0xff000000u) >> 24u) / 255.0, f32((c & 0x00ff0000u) >> 16u) / 255.0, f32((c & 0x0000ff00u) >> 8u) / 255.0, f32(c & 0x000000ffu) / 255.0);
}

fn srgb_to_linear(c: f32) -> f32 {
  if c <= 0.04045 {
    return c / 12.92;
  }
  else {
    return pow((c + 0.055) / 1.055, 2.4);
  }
}

fn srgb_to_linear_vec4(c: vec4<f32>) -> vec4<f32> {
  return vec4f(srgb_to_linear(c.x), srgb_to_linear(c.y), srgb_to_linear(c.z), c.w);
}

@group(0) @binding(0)
var<uniform> MVP: mat4x4f;
@group(0) @binding(1)
var<storage, read> buf: array<Data>;
@group(0) @binding(2)
var<uniform> extent: u32;

struct Data {
  corners: vec4f,
  pos: vec2f,
  dim: vec2f,
  border: f32,
  blur: f32,
  fill: u32,
  outline: u32,
}

struct VertexOutput {
  @invariant @builtin(position) position: vec4<f32>,
  @location(0) uv: vec2f,
  @location(1) @interpolate(flat) index: u32,
}

@vertex
fn vs_main(@builtin(vertex_index) idx: u32) -> VertexOutput {
  let vert = idx % 6;
  let index = idx / 6;
  var vpos = vec2(UNITX[vert], UNITY[vert]);
  let d = buf[index];

  var mv: mat4x4f;
  mv[0] = vec4f(d.dim.x, 0f, 0f, 0f);
  mv[1] = vec4f(0f, d.dim.y, 0f, 0f);
  mv[2] = vec4f(0f, 0f, 1f, 0f);
  mv[3] = vec4f(d.pos.x + d.dim.x * 0.5f, d.pos.y + d.dim.y * 0.5f, 0f, 1f);
  //let outpos = vec4f(d.pos.x, d.pos.y, d.dim.x, d.dim.y);
  let outpos = MVP * mv * vec4(vpos.x - 0.5f, vpos.y - 0.5f, 1f, 1f);

  return VertexOutput(outpos, vpos.xy, index);
}

fn rectangle_sdf(samplePosition: vec2f, halfSize: vec2f, edges: vec4f) -> f32 {
  var edge: f32 = 20.0f;
  if (samplePosition.x > 0.0f) {
    edge = select(edges.z, edges.y, samplePosition.y < 0.0f);
  }
  else {
    edge = select(edges.w, edges.x, samplePosition.y < 0.0f);
  }

  let componentWiseEdgeDistance = abs(samplePosition) - halfSize + vec2f(edge);
  let outsideDistance = length(max(componentWiseEdgeDistance, vec2f(0.0f)));
  let insideDistance = min(max(componentWiseEdgeDistance.x, componentWiseEdgeDistance.y), 0.0f);
  return outsideDistance + insideDistance - edge;
}

@fragment
fn rectangle(input: VertexOutput) -> @location(0) vec4f {
  let d = buf[input.index];
  // Ideally we would get DPI for both height and width, but for now we just assume DPI isn't weird
  let w = fwidth(d.dim.x * input.uv.x) * 0.5f * (1.0f + d.blur);
  let uv = (input.uv * d.dim) - (d.dim * 0.5f);

  let dist = rectangle_sdf(uv, d.dim * 0.5f, d.corners);
  let alpha = linearstep(w, - w, dist);
  let s = linearstep(w, - w, dist + d.border);
  let fill = srgb_to_linear_vec4(u32_to_vec4(d.fill));
  let outline = srgb_to_linear_vec4(u32_to_vec4(d.outline));

  return (vec4f(fill.rgb, 1f) * fill.a * s) + (vec4f(outline.rgb, 1f) * outline.a * clamp(alpha - s, 0.0f, 1.0f));
}

const PI = 3.14159265359;

@fragment
fn circle(input: VertexOutput) -> @location(0) vec4f {
  let d = buf[input.index];
  let l = (d.dim.x + d.dim.y) * 0.5;
  let uv = (input.uv * 2.0) - 1.0;
  let w1 = (1.0 + d.blur) * fwidth(input.uv.x);

  let border = (d.border / l) * 2.0;
  // double because UV is in range [-1,1], not [0,1]
  let t = 0.50 - (d.corners.x / l);
  // We have to compensate for needing to do smoothstep starting from 0, which combined with abs()
  // acts as a ceil() function, creating one extra half pixel.
  let r = 1.0 - t - w1;

  // SDF for circle
  let inner = (d.corners.y / l) * 2.0;
  let d0 = abs(length(uv) - r + (border * 0.5) - (inner * 0.5)) - t + (border * 0.5) + (inner * 0.5);
  let d1 = abs(length(uv) - r) - t;
  let s = pow(linearstep(w1 * 2.0, 0.0, d0), 2.2);
  let alpha = pow(linearstep(w1 * 2.0, 0.0, d1), 2.2);
  let fill = srgb_to_linear_vec4(u32_to_vec4(d.fill));
  let outline = srgb_to_linear_vec4(u32_to_vec4(d.outline));

  // Output to screen
  return (vec4f(fill.rgb, 1) * fill.a * s) + (vec4f(outline.rgb, 1) * outline.a * clamp(alpha - s, 0.0, 1.0));
}

fn linetopoint(p1: vec2f, p2: vec2f, p: vec2f) -> f32 {
  let n = p2 - p1;
  let v = vec2f(n.y, - n.x);
  return dot(normalize(v), p1 - p);
}

@fragment
fn triangle(input: VertexOutput) -> @location(0) vec4f {
  let d = buf[input.index];
  let p = input.uv * d.dim + vec2f(- 0.5, 0.5);
  let c = d.corners;
  let p2 = vec2f(c.w * d.dim.x, 0.0);
  let r1 = linetopoint(p2, vec2f(0.0, d.dim.y), p);
  let r2 = - linetopoint(p2, d.dim, p);
  var r = max(r1, r2);
  r = max(r, p.y - d.dim.y);

  // Ideally we would get DPI for both height and width, but for now we just assume DPI isn't weird
  let w = fwidth(p.x) * (1.0 + d.blur);
  let s = 1.0 - linearstep(1.0 - d.border - w * 2.0, 1.0 - d.border - w, r);
  let alpha = linearstep(1.0 - w, 1.0 - w * 2.0, r);
  let fill = srgb_to_linear_vec4(u32_to_vec4(d.fill));
  let outline = srgb_to_linear_vec4(u32_to_vec4(d.outline));

  return (vec4(fill.rgb, 1.0) * fill.a * s) + (vec4(outline.rgb, 1.0) * outline.a * clamp(alpha - s, 0.0, 1.0));
}

fn rotate(p: vec2f, a: f32) -> vec2f {
  return vec2f(p.x * cos(a) + p.y * sin(a), p.x * sin(a) - p.y * cos(a));
}

@fragment
fn arcs(input: VertexOutput) -> @location(0) vec4f {
  let data = buf[input.index];
  let l = (data.dim.x + data.dim.y) * .5;
  let uv = (input.uv * 2.) - 1.;
  let width = fwidth(input.uv.x);
  let w1 = (1. + data.blur) * width;

  let border = (data.border / l) * 2.;
  // double because UV is in range [-1,1], not [0,1]
  let t = .50 - (data.corners.z / l) + w1 * 1.5;
  // We have to compensate for needing to do smoothstep starting from 0, which combined with abs()
  // acts as a ceil() function, creating one extra half pixel.
  let r = 1. - t + w1;

  // SDF for circle
  let d0 = abs(length(uv) - r) - t + border;
  let d1 = abs(length(uv) - r) - t;

  // SDF for lines that make up arc
  let omega1 = rotate(uv, data.corners.x - data.corners.y);
  let omega2 = rotate(uv, data.corners.x + data.corners.y);
  var d = 0.0;

  // TODO: This cannot deal with non-integer circle radii, but it might be generalizable to those cases.
  if (abs(- omega1.y) + abs(omega2.y) < width) {
    d = ((data.corners.y / PI) - 0.5) * 2.0 * width;
  }
  else if (data.corners.y > PI * 0.5) {
    d = max(- omega1.y, omega2.y);
  }
  else {
    d = min(- omega1.y, omega2.y);
  }

  // Compensate for blur so the circle is still full or empty at 2pi and 0.
  d += (clamp(data.corners.y / PI, 0.0, 1.0) - 0.5) * 2.0 * (data.blur * width) + border;

  let d2 = d - border + w1;
  let d3 = min(d, omega1.x + data.corners.y) + w1;

  // Merge results of both SDFs
  let s = linearstep(- w1, w1, min(- d0, d2) - w1);
  let alpha = linearstep(- w1, w1, min(- d1, d3) - w1);
  let fill = srgb_to_linear_vec4(u32_to_vec4(data.fill));
  let outline = srgb_to_linear_vec4(u32_to_vec4(data.outline));

  // Output to screen
  return vec4(fill.rgb, 1) * fill.a * s + vec4(outline.rgb, 1) * outline.a * clamp(alpha - s, 0.0, 1.0);
}