// 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);
const IDENTITY_MAT4 = mat4x4f(1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.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);
}
fn linear_to_srgb(c: f32) -> f32 {
if c < 0.0031308 {
return c * 12.92;
}
else {
return 1.055 * pow(c, (1.0 / 2.4)) - (0.055);
}
}
fn linear_to_srgb_vec4(c: vec4f) -> vec4f {
return vec4f(linear_to_srgb(c.x), linear_to_srgb(c.y), linear_to_srgb(c.z), c.w);
}
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);
}
// Rotates a point around the origin
fn rotate(p: vec2f, r: f32) -> vec2f {
let sr = sin(r);
let cr = cos(r);
return vec2f(p.x * cr - p.y * sr, p.y * cr + p.x * sr);
}
@group(0) @binding(0)
var<uniform> MVP: mat4x4f;
@group(0) @binding(1)
var<storage, read> buf: array<Data>;
@group(0) @binding(2)
var<storage, read> cliprects: array<vec4f>;
@group(0) @binding(3)
var sampling: sampler;
@group(0) @binding(4)
var atlas: texture_2d_array<f32>;
@group(0) @binding(5)
var layeratlas: texture_2d_array<f32>;
struct Data {
pos: vec2f,
dim: vec2f,
uv: vec2f,
uvdim: vec2f,
color: u32,
rotation: f32,
texclip: u32,
}
struct VertexOutput {
@invariant @builtin(position) position: vec4<f32>,
@location(0) uv: vec2f,
@location(1) dist: vec2f,
@location(2) @interpolate(flat) index: u32,
@location(3) color: vec4f,
}
@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];
// Setting this flag *disables* inflation, so we invert it by comparing to 0
let inflate = (d.texclip & 0x80000000) == 0;
let layer = (d.texclip & 0x40000000) != 0;
var inflate_dim = d.dim;
var inflate_pos = d.pos;
var inflate_uv = d.uv;
var inflate_uvdim = d.uvdim;
if (inflate) {
// To emulate conservative rasterization, we must inflate the quad by 0.5 pixels outwards. This
// is done by increasing the total dimension size by 1, then subtracing 0.5 from the position.
inflate_dim += vec2f(1);
inflate_pos -= vec2f(0.5);
// We must also compensate the UV coordinates, but this is trickier because they could already be
// scaled differently. We acquire the size of a UV pixel by dividing the UV dimensions by the true
// dimensions. Thus, if we have a 2x2 UV lookup scaled to a 4x4 square, one scaled UV pixel is 0.5
let uv_pixel = d.uvdim / d.dim;
inflate_uvdim += vec2f(uv_pixel);
inflate_uv -= vec2f(uv_pixel * 0.5);
}
var pos = vpos;
pos *= inflate_dim;
if d.rotation != 0.0f {
// For complicated reasons, we have to rotate around the center of whatever we're rendering. This
// allows lines to precisely position themselves, and also conveniently works with inflation enabled.
let half = inflate_dim * vec2f(0.5);
pos = rotate(pos - half, d.rotation) + half;
}
pos += inflate_pos;
let out_pos = MVP * vec4(pos.x, pos.y, 1f, 1f);
// When porting this to older shader versions, you can pass in the texture extent via a uniform instead,
// but since we can access the texture from the vertex shader here, we just get the dimensions explicitly.
var extent: vec2<u32>;
if layer {
extent = textureDimensions(layeratlas);
}
else {
extent = textureDimensions(atlas);
}
var source = IDENTITY_MAT4;
let uv = inflate_uv / vec2f(extent);
let uvdim = inflate_uvdim / vec2f(extent);
var out_uv = vpos * uvdim;
// If the rotation is negative it's applied to the UV rectangle as well
if d.rotation < 0.0f {
let half = uvdim * vec2f(0.5);
out_uv = rotate(out_uv - half, d.rotation) + half;
}
out_uv += uv;
let color = srgb_to_linear_vec4(u32_to_vec4(d.color));
let dist = (vpos - vec2f(0.5f)) * inflate_dim;
return VertexOutput(out_pos, out_uv.xy, dist, index, color);
}
@fragment
fn fs_main(input: VertexOutput) -> @location(0) vec4f {
let d = buf[input.index];
let clip = d.texclip & 0x0000FFFF;
let tex = (d.texclip & 0x00FF0000) >> 16;
let inflate = (d.texclip & 0x80000000) == 0;
let layer = (d.texclip & 0x40000000) != 0;
if clip > 0 {
let r = cliprects[clip];
if !(input.uv.x >= r.x && input.uv.y >= r.y && input.uv.x < r.z && input.uv.y < r.z) {
discard;
}
}
var color = vec4f(input.color.rgb * input.color.a, input.color.a);
var uv = input.uv;
if (inflate) {
var extent: vec2<u32>;
if layer {
extent = textureDimensions(layeratlas);
}
else {
extent = textureDimensions(atlas);
}
// A pixel-perfect texture lookup at pixel 0,0 actually samples at 0.5,0.5, at the center of the
// texel. Hence, if we simply clamp from 0,0 to height,width, this doesn't prevent bleedover when
// we get a misaligned pixel that tries to sample the texel at 0,0, which will bleed over into the
// texels next to it. As a result, we must clamp from 0.5,0.5 to width - 0.5, height - 0.5
let uvmin = (d.uv + vec2f(0.5)) / vec2f(extent);
let uvmax = (d.uv + d.uvdim - vec2f(0.5)) / vec2f(extent);
uv = clamp(input.uv, uvmin, uvmax);
// We get the pixel distance from the center of our quad, which we then use to do a precise alpha
// dropoff, which recreates anti-aliasing.
let dist = 1.0 - (abs(input.dist) - (d.dim * 0.5) + 0.5);
color *= clamp(min(dist.x, dist.y), 0.0, 1.0);
}
if tex == 0xFF {
return color;
}
if layer {
return textureSample(layeratlas, sampling, uv, tex) * color;
}
return textureSample(atlas, sampling, uv, tex) * color;
}