pub const SDF_SHADER: &str = r#"
// ============================================================================
// Blinc SDF Primitive Shader
// ============================================================================
struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) uv: vec2<f32>,
@location(1) @interpolate(flat) instance_index: u32,
}
struct Uniforms {
viewport_size: vec2<f32>,
_padding: vec2<f32>,
}
// Primitive types
const PRIM_RECT: u32 = 0u;
const PRIM_CIRCLE: u32 = 1u;
const PRIM_ELLIPSE: u32 = 2u;
const PRIM_SHADOW: u32 = 3u;
const PRIM_INNER_SHADOW: u32 = 4u;
const PRIM_CIRCLE_SHADOW: u32 = 5u;
const PRIM_CIRCLE_INNER_SHADOW: u32 = 6u;
const PRIM_TEXT: u32 = 7u; // Text glyph - samples from atlas texture
// Fill types
const FILL_SOLID: u32 = 0u;
const FILL_LINEAR_GRADIENT: u32 = 1u;
const FILL_RADIAL_GRADIENT: u32 = 2u;
// Clip types
const CLIP_NONE: u32 = 0u;
const CLIP_RECT: u32 = 1u;
const CLIP_CIRCLE: u32 = 2u;
const CLIP_ELLIPSE: u32 = 3u;
const CLIP_POLYGON: u32 = 4u;
struct Primitive {
// Bounds (x, y, width, height)
bounds: vec4<f32>,
// Corner radii (top-left, top-right, bottom-right, bottom-left)
corner_radius: vec4<f32>,
// Fill color (or gradient start color)
color: vec4<f32>,
// Gradient end color (for gradients)
color2: vec4<f32>,
// Border (width, 0, 0, 0)
border: vec4<f32>,
// Border color
border_color: vec4<f32>,
// Shadow (offset_x, offset_y, blur, spread)
shadow: vec4<f32>,
// Shadow color
shadow_color: vec4<f32>,
// Clip bounds (x, y, width, height) for rect clips, (cx, cy, rx, ry) for circle/ellipse
clip_bounds: vec4<f32>,
// Clip corner radii (for rounded rect) or (radius_x, radius_y, 0, 0) for ellipse
clip_radius: vec4<f32>,
// Gradient parameters: linear (x1, y1, x2, y2), radial (cx, cy, r, 0) in user space
gradient_params: vec4<f32>,
// Rotation (sin_rz, cos_rz, sin_ry, cos_ry) - for rotated SDF evaluation
rotation: vec4<f32>,
// Local 2x2 affine (a, b, c, d) - normalized (DPI removed).
// Maps local rect space → screen space. Supports rotation, scale, AND skew.
// Identity = (1, 0, 0, 1).
local_affine: vec4<f32>,
// Perspective (sin_rx, cos_rx, perspective_d, shape_3d_type)
// shape_3d_type: 0=none, 1=box, 2=sphere, 3=cylinder, 4=torus, 5=capsule, 6=group
perspective: vec4<f32>,
// SDF 3D params (depth, ambient, specular_power, translate_z)
sdf_3d: vec4<f32>,
// Light params (dir_x, dir_y, dir_z, intensity)
light: vec4<f32>,
// CSS filter A (grayscale, invert, sepia, hue_rotate_rad)
filter_a: vec4<f32>,
// CSS filter B (brightness, contrast, saturate, 0)
filter_b: vec4<f32>,
// Mask gradient params: linear=(x1,y1,x2,y2), radial=(cx,cy,r,0) in OBB (0-1) space
mask_params: vec4<f32>,
// Mask info: (mask_type, start_alpha, end_alpha, 0)
// mask_type: 0=none, 1=linear, 2=radial
mask_info: vec4<f32>,
// Corner shape (superellipse n parameter per corner)
// n=1.0 = round (default), n=0.0 = bevel, n=2.0 = squircle, n=-1.0 = scoop
corner_shape: vec4<f32>,
// Overflow fade distances (top, right, bottom, left) in pixels
clip_fade: vec4<f32>,
// Type info (primitive_type, fill_type, clip_type, 0)
type_info: vec4<u32>,
}
@group(0) @binding(0) var<uniform> uniforms: Uniforms;
@group(0) @binding(1) var<storage, read> primitives: array<Primitive>;
// Glyph atlas textures for unified text rendering
@group(0) @binding(2) var glyph_atlas: texture_2d<f32>;
@group(0) @binding(3) var glyph_sampler: sampler;
@group(0) @binding(4) var color_glyph_atlas: texture_2d<f32>;
// Auxiliary data buffer for variable-length per-primitive data
// (3D group shape descriptors, polygon clip vertices, etc.)
@group(0) @binding(5) var<storage, read> aux_data: array<vec4<f32>>;
// ============================================================================
// Vertex Shader
// ============================================================================
@vertex
fn vs_main(
@builtin(vertex_index) vertex_index: u32,
@builtin(instance_index) instance_index: u32,
) -> VertexOutput {
var out: VertexOutput;
let prim = primitives[instance_index];
// Expand bounds for shadow blur
let blur_expand = prim.shadow.z * 3.0 + abs(prim.shadow.x) + abs(prim.shadow.y);
// Check for rotation, skew, and 3D transforms
let sin_rz = prim.rotation.x;
let cos_rz = prim.rotation.y;
let sin_ry = prim.rotation.z;
let cos_ry = prim.rotation.w;
let sin_rx = prim.perspective.x;
let cos_rx = prim.perspective.y;
let persp_d = prim.perspective.z;
let la = prim.local_affine; // [a, b, c, d] of normalized 2x2 affine
let has_3d = abs(sin_ry) > 0.0001 || abs(sin_rx) > 0.0001 || persp_d > 0.001;
// Check if local_affine is non-identity (rotation, skew, or non-uniform scale)
let has_local_affine = abs(la.x - 1.0) > 0.0001 || abs(la.y) > 0.0001
|| abs(la.z) > 0.0001 || abs(la.w - 1.0) > 0.0001;
var bounds: vec4<f32>;
if has_3d {
// 3D perspective: project all 8 corners of the 3D bounding box to find AABB
let ctr = prim.bounds.xy + prim.bounds.zw * 0.5;
let half = prim.bounds.zw * 0.5;
let half_d = prim.sdf_3d.x * 0.5; // half-depth
let corners3d = array<vec3<f32>, 8>(
vec3<f32>(-half.x, -half.y, -half_d),
vec3<f32>( half.x, -half.y, -half_d),
vec3<f32>( half.x, half.y, -half_d),
vec3<f32>(-half.x, half.y, -half_d),
vec3<f32>(-half.x, -half.y, half_d),
vec3<f32>( half.x, -half.y, half_d),
vec3<f32>( half.x, half.y, half_d),
vec3<f32>(-half.x, half.y, half_d),
);
var min_p = vec2<f32>(1e10);
var max_p = vec2<f32>(-1e10);
let pd = select(800.0, persp_d, persp_d > 0.001);
for (var i = 0u; i < 8u; i++) {
let c = corners3d[i];
// Apply rotateZ
let rz_x = c.x * cos_rz - c.y * sin_rz;
let rz_y = c.x * sin_rz + c.y * cos_rz;
let rz_z = c.z;
// Apply rotateX (tilt Y/Z)
let rx_y = rz_y * cos_rx - rz_z * sin_rx;
let rx_z = rz_y * sin_rx + rz_z * cos_rx;
// Apply rotateY (tilt X/Z)
let ry_x = rz_x * cos_ry + rx_z * sin_ry;
let ry_z = -rz_x * sin_ry + rx_z * cos_ry;
// Perspective divide
let w = 1.0 - ry_z / pd;
let proj = vec2<f32>(ry_x, rx_y) / max(w, 0.001);
min_p = min(min_p, proj);
max_p = max(max_p, proj);
}
min_p -= vec2<f32>(blur_expand + 2.0);
max_p += vec2<f32>(blur_expand + 2.0);
bounds = vec4<f32>(ctr + min_p, max_p - min_p);
} else if has_local_affine {
// General 2D affine (rotation, skew, non-uniform scale):
// Transform the 4 corners of the local rect by the local_affine to find AABB
let center = prim.bounds.xy + prim.bounds.zw * 0.5;
let hw = prim.bounds.z * 0.5;
let hh = prim.bounds.w * 0.5;
// Transform corners: la * (±hw, ±hh)
// new_x = la.x * cx + la.z * cy, new_y = la.y * cx + la.w * cy
let c0x = la.x * hw + la.z * hh;
let c0y = la.y * hw + la.w * hh;
let c1x = -la.x * hw + la.z * hh;
let c1y = -la.y * hw + la.w * hh;
let aabb_hw = max(abs(c0x), abs(c1x)) + blur_expand;
let aabb_hh = max(abs(c0y), abs(c1y)) + blur_expand;
bounds = vec4<f32>(center.x - aabb_hw, center.y - aabb_hh, aabb_hw * 2.0, aabb_hh * 2.0);
} else {
// Original non-rotated, non-skewed path
bounds = vec4<f32>(
prim.bounds.x - blur_expand,
prim.bounds.y - blur_expand,
prim.bounds.z + blur_expand * 2.0,
prim.bounds.w + blur_expand * 2.0
);
}
// Generate quad vertices (two triangles split along / diagonal)
// 0--1
// |\ |
// | \|
// 3--2
// Triangle 1: 0 → 1 → 3 (TL → TR → BL) - upper-left triangle
// Triangle 2: 1 → 2 → 3 (TR → BR → BL) - lower-right triangle
// Shared edge: 1-3 (top-right to bottom-left = / diagonal)
//
// PowerVR Vulkan codegen bug workaround: dynamic indexing into a
// `let array<...>(literal)` produces an `OpConstantComposite` +
// `OpAccessChain` pattern that the Pixel 10 Pro / Tensor G5
// PowerVR driver compiles incorrectly — vertex_index 0..2 work
// but 3..5 silently produce degenerate output, collapsing the
// second triangle to a point and leaving every primitive a
// half-quad. Replacing the array-literal indexing with an explicit
// `switch` forces naga to emit `OpSwitch`, which the driver
// handles correctly. Confirmed on Android 16 / driver 25.1@6794074.
var uv: vec2<f32>;
switch vertex_index {
case 0u: { uv = vec2<f32>(0.0, 0.0); } // 0 - top-left
case 1u: { uv = vec2<f32>(1.0, 0.0); } // 1 - top-right
case 2u: { uv = vec2<f32>(0.0, 1.0); } // 3 - bottom-left
case 3u: { uv = vec2<f32>(1.0, 0.0); } // 1 - top-right
case 4u: { uv = vec2<f32>(1.0, 1.0); } // 2 - bottom-right
default: { uv = vec2<f32>(0.0, 1.0); } // 3 - bottom-left
}
let pos = vec2<f32>(
bounds.x + uv.x * bounds.z,
bounds.y + uv.y * bounds.w
);
// Convert to clip space (-1 to 1)
let clip_pos = vec2<f32>(
(pos.x / uniforms.viewport_size.x) * 2.0 - 1.0,
1.0 - (pos.y / uniforms.viewport_size.y) * 2.0
);
out.position = vec4<f32>(clip_pos, 0.0, 1.0);
out.uv = pos; // Pass world position for SDF calculation
out.instance_index = instance_index;
return out;
}
// ============================================================================
// SDF Functions
// ============================================================================
// Rounded rectangle SDF
fn sd_rounded_rect(p: vec2<f32>, origin: vec2<f32>, size: vec2<f32>, radius: vec4<f32>) -> f32 {
let half_size = size * 0.5;
let center = origin + half_size;
let rel = p - center; // Relative position from center (signed)
let q = abs(rel) - half_size;
// Select corner radius based on quadrant
// radius: (top-left, top-right, bottom-right, bottom-left)
// In screen coords: Y increases downward, so rel.y < 0 means top half
var r: f32;
if rel.y < 0.0 {
// Top half (y is above center)
if rel.x > 0.0 {
r = radius.y; // top-right
} else {
r = radius.x; // top-left
}
} else {
// Bottom half (y is below center)
if rel.x > 0.0 {
r = radius.z; // bottom-right
} else {
r = radius.w; // bottom-left
}
}
// Clamp radius to half the minimum dimension
r = min(r, min(half_size.x, half_size.y));
let q_adjusted = q + vec2<f32>(r);
return length(max(q_adjusted, vec2<f32>(0.0))) + min(max(q_adjusted.x, q_adjusted.y), 0.0) - r;
}
// Shaped rectangle SDF with per-corner superellipse parameter
// shape.xyzw = superellipse n for (top-left, top-right, bottom-right, bottom-left)
// n=1.0 = round (circle), n=0.0 = bevel, n=2.0 = squircle
// n>=100.0 = square, n<=-100.0 = notch, n<0 = concave (scoop)
fn sd_shaped_rect(p: vec2<f32>, origin: vec2<f32>, size: vec2<f32>, radius: vec4<f32>, shape: vec4<f32>) -> f32 {
let half_size = size * 0.5;
let center = origin + half_size;
let rel = p - center;
let q = abs(rel) - half_size;
// Select corner radius and shape based on quadrant
var r: f32;
var n: f32;
if rel.y < 0.0 {
if rel.x > 0.0 {
r = radius.y; n = shape.y; // top-right
} else {
r = radius.x; n = shape.x; // top-left
}
} else {
if rel.x > 0.0 {
r = radius.z; n = shape.z; // bottom-right
} else {
r = radius.w; n = shape.w; // bottom-left
}
}
r = min(r, min(half_size.x, half_size.y));
// Notch: rectangular step cut at each corner (before guard)
// Shape = union of horizontal bar (full width, height-2r) and vertical bar (width-2r, full height)
if n <= -100.0 {
let d_h = max(q.x, q.y + r); // horizontal bar SDF
let d_v = max(q.x + r, q.y); // vertical bar SDF
return min(d_h, d_v); // union
}
let q_adj = q + vec2<f32>(r);
// Fast path: n ~ 1.0 -> standard circular
if abs(n - 1.0) < 0.01 {
return length(max(q_adj, vec2<f32>(0.0))) + min(max(q_adj.x, q_adj.y), 0.0) - r;
}
// Outside corner region -> flat edge
if q_adj.x <= 0.0 || q_adj.y <= 0.0 {
return max(q.x, q.y);
}
// Square: sharp corner (L-infinity convex)
if n >= 100.0 {
return max(q_adj.x, q_adj.y) - r;
}
// Superellipse: p_exp = 2^|n|, clamped to avoid overflow
let t = q_adj / max(r, 0.001);
let p_exp = pow(2.0, min(abs(n), 5.0));
let se = pow(t.x, p_exp) + pow(t.y, p_exp);
let se_dist = (pow(se, 1.0 / p_exp) - 1.0) * r;
if n < 0.0 {
return -se_dist; // concave (scoop)
}
return se_dist; // convex
}
// Circle SDF
fn sd_circle(p: vec2<f32>, center: vec2<f32>, radius: f32) -> f32 {
return length(p - center) - radius;
}
// Ellipse SDF (approximation)
fn sd_ellipse(p: vec2<f32>, center: vec2<f32>, radii: vec2<f32>) -> f32 {
let p_centered = p - center;
let p_norm = p_centered / radii;
let dist = length(p_norm);
return (dist - 1.0) * min(radii.x, radii.y);
}
// Quarter ellipse SDF for inner corners with asymmetric borders (GPUI approach)
// This handles the case where adjacent border widths differ, creating an elliptical
// inner corner instead of circular. Returns negative inside, positive outside.
fn quarter_ellipse_sdf(point: vec2<f32>, radii: vec2<f32>) -> f32 {
// Avoid division by zero
let safe_radii = max(radii, vec2<f32>(0.001));
// Map to unit circle space
let circle_vec = point / safe_radii;
let unit_circle_sdf = length(circle_vec) - 1.0;
// Scale back using average radius for distance approximation
return unit_circle_sdf * (safe_radii.x + safe_radii.y) * -0.5;
}
// Error function approximation for Gaussian blur
fn erf(x: f32) -> f32 {
let s = sign(x);
let a = abs(x);
let t = 1.0 / (1.0 + 0.3275911 * a);
let y = 1.0 - (((((1.061405429 * t - 1.453152027) * t) + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t * exp(-a * a);
return s * y;
}
// Gaussian shadow for rectangle (without corner radii - legacy)
fn shadow_rect(p: vec2<f32>, origin: vec2<f32>, size: vec2<f32>, sigma: f32) -> f32 {
if sigma < 0.001 {
// No blur - use hard edge
let d = sd_rounded_rect(p, origin, size, vec4<f32>(0.0));
return select(0.0, 1.0, d < 0.0);
}
let d = 0.5 * sqrt(2.0) * sigma;
let half = size * 0.5;
let center = origin + half;
let rel = p - center;
let x = 0.5 * (erf((half.x - rel.x) / d) + erf((half.x + rel.x) / d));
let y = 0.5 * (erf((half.y - rel.y) / d) + erf((half.y + rel.y) / d));
return x * y;
}
// Gaussian shadow for rounded rectangle - uses SDF for proper corner handling
fn shadow_rounded_rect(p: vec2<f32>, origin: vec2<f32>, size: vec2<f32>, corner_radius: vec4<f32>, sigma: f32) -> f32 {
// Get signed distance to the rounded rectangle
let sdf_dist = sd_rounded_rect(p, origin, size, corner_radius);
if sigma < 0.001 {
// No blur - use hard edge
return select(0.0, 1.0, sdf_dist < 0.0);
}
// Gaussian falloff based on SDF distance
// Same approach as shadow_circle: 1 inside, Gaussian falloff outside
let d = 0.5 * sqrt(2.0) * sigma;
return 0.5 * (1.0 + erf(-sdf_dist / d));
}
// Gaussian shadow for circle - radially symmetric blur
fn shadow_circle(p: vec2<f32>, center: vec2<f32>, radius: f32, sigma: f32) -> f32 {
let dist = length(p - center);
if sigma < 0.001 {
// No blur - hard edge
return select(0.0, 1.0, dist < radius);
}
// Gaussian falloff from circle edge
// erf gives cumulative distribution, we want shadow inside and fading outside
let d = 0.5 * sqrt(2.0) * sigma;
return 0.5 * (1.0 + erf((radius - dist) / d));
}
// Calculate clip alpha (1.0 = inside clip, 0.0 = outside)
// For non-rect clips (circle, ellipse, polygon):
// clip_bounds = rect scissor from parent clips [x, y, w, h]
// clip_radius = shape-specific data
// The shader applies BOTH the rect scissor AND the shape clip.
// clip_fade = (top, right, bottom, left) overflow fade distances in pixels
fn calculate_clip_alpha(p: vec2<f32>, clip_bounds: vec4<f32>, clip_radius: vec4<f32>, clip_type: u32, clip_fade: vec4<f32>) -> f32 {
var alpha: f32 = 1.0;
if clip_type != CLIP_NONE {
let aa_width = 0.75;
switch clip_type {
case CLIP_RECT: {
let clip_origin = clip_bounds.xy;
let clip_size = clip_bounds.zw;
let clip_d = sd_rounded_rect(p, clip_origin, clip_size, clip_radius);
alpha = 1.0 - smoothstep(-aa_width, aa_width, clip_d);
}
case CLIP_CIRCLE: {
let scissor_d = sd_rounded_rect(p, clip_bounds.xy, clip_bounds.zw, vec4<f32>(0.0));
let scissor_alpha = 1.0 - smoothstep(-aa_width, aa_width, scissor_d);
let center = clip_radius.xy;
let radius = clip_radius.z;
let clip_d = sd_circle(p, center, radius);
let shape_alpha = 1.0 - smoothstep(-aa_width, aa_width, clip_d);
alpha = scissor_alpha * shape_alpha;
}
case CLIP_ELLIPSE: {
let scissor_d = sd_rounded_rect(p, clip_bounds.xy, clip_bounds.zw, vec4<f32>(0.0));
let scissor_alpha = 1.0 - smoothstep(-aa_width, aa_width, scissor_d);
let center = clip_radius.xy;
let radii = clip_radius.zw;
let clip_d = sd_ellipse(p, center, radii);
let shape_alpha = 1.0 - smoothstep(-aa_width, aa_width, clip_d);
alpha = scissor_alpha * shape_alpha;
}
case CLIP_POLYGON: {
let scissor_d = sd_rounded_rect(p, clip_bounds.xy, clip_bounds.zw, vec4<f32>(0.0));
let scissor_alpha = 1.0 - smoothstep(-aa_width, aa_width, scissor_d);
let vertex_count = u32(clip_radius.z);
let aux_offset = u32(clip_radius.w);
let shape_alpha = calculate_polygon_clip_alpha(p, vertex_count, aux_offset);
alpha = scissor_alpha * shape_alpha;
}
default: {}
}
}
// Apply overflow fade (smooth alpha ramp at clip edges)
if clip_fade.x > 0.0 || clip_fade.y > 0.0 || clip_fade.z > 0.0 || clip_fade.w > 0.0 {
let clip_min = clip_bounds.xy;
let clip_max = clip_bounds.xy + clip_bounds.zw;
if clip_fade.x > 0.0 { alpha *= saturate((p.y - clip_min.y) / clip_fade.x); } // top
if clip_fade.y > 0.0 { alpha *= saturate((clip_max.x - p.x) / clip_fade.y); } // right
if clip_fade.z > 0.0 { alpha *= saturate((clip_max.y - p.y) / clip_fade.z); } // bottom
if clip_fade.w > 0.0 { alpha *= saturate((p.x - clip_min.x) / clip_fade.w); } // left
}
return alpha;
}
// Polygon clip using winding number test with edge-distance anti-aliasing.
// Vertices packed in aux_data as vec4(x0, y0, x1, y1) — 2 vertices per vec4.
fn calculate_polygon_clip_alpha(p: vec2<f32>, vertex_count: u32, aux_offset: u32) -> f32 {
if vertex_count < 3u {
return 1.0;
}
var winding: i32 = 0;
var min_edge_dist: f32 = 1e10;
for (var i: u32 = 0u; i < vertex_count; i = i + 1u) {
// Read vertex i: packed as (x0, y0, x1, y1) per vec4
let vec_idx = aux_offset + (i / 2u);
let data = aux_data[vec_idx];
var vi: vec2<f32>;
if (i % 2u) == 0u {
vi = data.xy;
} else {
vi = data.zw;
}
// Read vertex j (next, wrapping)
let j = (i + 1u) % vertex_count;
let vec_idx_j = aux_offset + (j / 2u);
let data_j = aux_data[vec_idx_j];
var vj: vec2<f32>;
if (j % 2u) == 0u {
vj = data_j.xy;
} else {
vj = data_j.zw;
}
// Winding number contribution (crossing number test)
let edge = vj - vi;
if vi.y <= p.y {
if vj.y > p.y {
// Upward crossing
let cross_val = edge.x * (p.y - vi.y) - edge.y * (p.x - vi.x);
if cross_val > 0.0 {
winding = winding + 1;
}
}
} else {
if vj.y <= p.y {
// Downward crossing
let cross_val = edge.x * (p.y - vi.y) - edge.y * (p.x - vi.x);
if cross_val < 0.0 {
winding = winding - 1;
}
}
}
// Minimum distance to this edge segment (for anti-aliasing)
let ap = p - vi;
let edge_len_sq = dot(edge, edge);
var t: f32 = 0.0;
if edge_len_sq > 0.0001 {
t = clamp(dot(ap, edge) / edge_len_sq, 0.0, 1.0);
}
let closest = vi + edge * t;
let dist = length(p - closest);
min_edge_dist = min(min_edge_dist, dist);
}
// Inside if winding number is non-zero
let is_inside = winding != 0;
// Signed distance: negative inside, positive outside
let signed_dist = select(min_edge_dist, -min_edge_dist, is_inside);
// Anti-aliased edge
let aa_width = 0.75;
return 1.0 - smoothstep(-aa_width, aa_width, signed_dist);
}
// ============================================================================
// CSS Filter Functions
// ============================================================================
/// Apply CSS filter effects to a color.
/// filter_a = (grayscale, invert, sepia, hue_rotate_rad)
/// filter_b = (brightness, contrast, saturate, 0)
fn apply_css_filter(color: vec4<f32>, filter_a: vec4<f32>, filter_b: vec4<f32>) -> vec4<f32> {
var rgb = color.rgb;
// Grayscale: desaturate using luminance weights
let grayscale = filter_a.x;
if grayscale > 0.0 {
let lum = dot(rgb, vec3<f32>(0.2126, 0.7152, 0.0722));
rgb = mix(rgb, vec3<f32>(lum, lum, lum), grayscale);
}
// Sepia: apply sepia tone matrix
let sepia = filter_a.z;
if sepia > 0.0 {
let sepia_r = dot(rgb, vec3<f32>(0.393, 0.769, 0.189));
let sepia_g = dot(rgb, vec3<f32>(0.349, 0.686, 0.168));
let sepia_b = dot(rgb, vec3<f32>(0.272, 0.534, 0.131));
rgb = mix(rgb, vec3<f32>(sepia_r, sepia_g, sepia_b), sepia);
}
// Invert
let invert = filter_a.y;
if invert > 0.0 {
rgb = mix(rgb, vec3<f32>(1.0) - rgb, invert);
}
// Hue-rotate: rotate in RGB space using rotation matrix
let hue_rad = filter_a.w;
if abs(hue_rad) > 0.001 {
let cos_h = cos(hue_rad);
let sin_h = sin(hue_rad);
let w = vec3<f32>(0.2126, 0.7152, 0.0722);
// Rodrigues-style hue rotation matrix
let r = vec3<f32>(
cos_h + (1.0 - cos_h) * w.x,
(1.0 - cos_h) * w.x * w.y - sin_h * w.z,
(1.0 - cos_h) * w.x * w.z + sin_h * w.y
);
let g = vec3<f32>(
(1.0 - cos_h) * w.x * w.y + sin_h * w.z,
cos_h + (1.0 - cos_h) * w.y,
(1.0 - cos_h) * w.y * w.z - sin_h * w.x
);
let b = vec3<f32>(
(1.0 - cos_h) * w.x * w.z - sin_h * w.y,
(1.0 - cos_h) * w.y * w.z + sin_h * w.x,
cos_h + (1.0 - cos_h) * w.z
);
rgb = vec3<f32>(dot(rgb, r), dot(rgb, g), dot(rgb, b));
}
// Brightness
let brightness = filter_b.x;
rgb = rgb * brightness;
// Contrast
let contrast = filter_b.y;
rgb = (rgb - vec3<f32>(0.5)) * contrast + vec3<f32>(0.5);
// Saturate
let saturate = filter_b.z;
if abs(saturate - 1.0) > 0.001 {
let lum = dot(rgb, vec3<f32>(0.2126, 0.7152, 0.0722));
rgb = mix(vec3<f32>(lum, lum, lum), rgb, saturate);
}
return vec4<f32>(clamp(rgb, vec3<f32>(0.0), vec3<f32>(1.0)), color.a);
}
// ============================================================================
// 3D SDF Functions
// ============================================================================
const SHAPE_NONE: u32 = 0u;
const SHAPE_BOX: u32 = 1u;
const SHAPE_SPHERE: u32 = 2u;
const SHAPE_CYLINDER: u32 = 3u;
const SHAPE_TORUS: u32 = 4u;
const SHAPE_CAPSULE: u32 = 5u;
const SHAPE_GROUP: u32 = 6u;
fn sd_box_3d(p: vec3<f32>, half_ext: vec3<f32>, r: f32) -> f32 {
let q = abs(p) - half_ext + vec3<f32>(r);
return length(max(q, vec3<f32>(0.0))) + min(max(q.x, max(q.y, q.z)), 0.0) - r;
}
fn sd_sphere_3d(p: vec3<f32>, r: f32) -> f32 {
return length(p) - r;
}
fn sd_cylinder_3d(p: vec3<f32>, h: f32, r: f32) -> f32 {
let d = vec2<f32>(length(p.xz) - r, abs(p.y) - h);
return min(max(d.x, d.y), 0.0) + length(max(d, vec2<f32>(0.0)));
}
fn sd_torus_3d(p: vec3<f32>, major_r: f32, minor_r: f32) -> f32 {
let q = vec2<f32>(length(p.xz) - major_r, p.y);
return length(q) - minor_r;
}
fn sd_capsule_3d(p: vec3<f32>, h: f32, r: f32) -> f32 {
let py = p.y - clamp(p.y, -h, h);
return length(vec3<f32>(p.x, py, p.z)) - r;
}
fn sdf_3d_eval(p: vec3<f32>, shape_type: u32, half_ext: vec3<f32>, corner_r: f32) -> f32 {
// Use X-Y dimensions for shape sizing (not Z/depth which may be smaller)
let min_xy = min(half_ext.x, half_ext.y);
switch shape_type {
case 1u: { return sd_box_3d(p, half_ext, corner_r); }
case 2u: { return sd_sphere_3d(p, min_xy); }
case 3u: { return sd_cylinder_3d(p, half_ext.y, half_ext.x); }
case 4u: {
// Torus: minor + major = min_xy so outer edge fills element
let minor = min(min_xy / 3.0, half_ext.y);
let major = min_xy - minor;
return sd_torus_3d(p, major, minor);
}
case 5u: {
// Capsule: inscribe in X-Y bounding box
let r = min(half_ext.x, half_ext.y * 0.5);
let h = max(half_ext.y - r, 0.0);
return sd_capsule_3d(p, h, r);
}
default: { return 1e10; }
}
}
// ============================================================================
// 3D Boolean Operations
// ============================================================================
fn op_union(d1: f32, d2: f32) -> f32 { return min(d1, d2); }
fn op_subtract(d1: f32, d2: f32) -> f32 { return max(d1, -d2); }
fn op_intersect(d1: f32, d2: f32) -> f32 { return max(d1, d2); }
fn op_smooth_union(d1: f32, d2: f32, k: f32) -> f32 {
let h = clamp(0.5 + 0.5 * (d2 - d1) / k, 0.0, 1.0);
return mix(d2, d1, h) - k * h * (1.0 - h);
}
fn op_smooth_subtract(d1: f32, d2: f32, k: f32) -> f32 {
let h = clamp(0.5 - 0.5 * (d2 + d1) / k, 0.0, 1.0);
return mix(d1, -d2, h) + k * h * (1.0 - h);
}
fn op_smooth_intersect(d1: f32, d2: f32, k: f32) -> f32 {
let h = clamp(0.5 - 0.5 * (d2 - d1) / k, 0.0, 1.0);
return mix(d2, d1, h) + k * h * (1.0 - h);
}
fn apply_boolean_op(d_accum: f32, d_new: f32, op_type: u32, blend: f32) -> f32 {
switch op_type {
case 0u: { return op_union(d_accum, d_new); }
case 1u: { return op_subtract(d_accum, d_new); }
case 2u: { return op_intersect(d_accum, d_new); }
case 3u: { return op_smooth_union(d_accum, d_new, max(blend, 0.001)); }
case 4u: { return op_smooth_subtract(d_accum, d_new, max(blend, 0.001)); }
case 5u: { return op_smooth_intersect(d_accum, d_new, max(blend, 0.001)); }
default: { return op_union(d_accum, d_new); }
}
}
// ============================================================================
// 3D Group SDF Evaluation
// ============================================================================
fn eval_group_sdf(p: vec3<f32>, shape_count: u32, aux_offset: u32) -> f32 {
var d = 1e10;
for (var i = 0u; i < shape_count; i++) {
let base = aux_offset + i * 4u;
let s_offset = aux_data[base]; // x, y, z, corner_radius
let s_params = aux_data[base + 1u]; // shape_type, depth, op_type, blend
let s_half = aux_data[base + 2u]; // half_w, half_h, half_d, 0
let local_p = p - s_offset.xyz;
let shape_d = sdf_3d_eval(local_p, u32(s_params.x), s_half.xyz, s_offset.w);
if i == 0u {
d = shape_d;
} else {
d = apply_boolean_op(d, shape_d, u32(s_params.z), s_params.w);
}
}
return d;
}
// Compute group normal via central differences
fn eval_group_normal(hp: vec3<f32>, shape_count: u32, aux_offset: u32) -> vec3<f32> {
let eps = 0.001;
return normalize(vec3<f32>(
eval_group_sdf(hp + vec3<f32>(eps, 0.0, 0.0), shape_count, aux_offset) -
eval_group_sdf(hp - vec3<f32>(eps, 0.0, 0.0), shape_count, aux_offset),
eval_group_sdf(hp + vec3<f32>(0.0, eps, 0.0), shape_count, aux_offset) -
eval_group_sdf(hp - vec3<f32>(0.0, eps, 0.0), shape_count, aux_offset),
eval_group_sdf(hp + vec3<f32>(0.0, 0.0, eps), shape_count, aux_offset) -
eval_group_sdf(hp - vec3<f32>(0.0, 0.0, eps), shape_count, aux_offset)
));
}
// Find which shape in the group is closest to the hit point (for per-shape coloring)
fn eval_group_closest_shape_color(hp: vec3<f32>, shape_count: u32, aux_offset: u32) -> vec4<f32> {
var min_d = 1e10;
var closest_color = vec4<f32>(1.0);
for (var i = 0u; i < shape_count; i++) {
let base = aux_offset + i * 4u;
let s_offset = aux_data[base];
let s_params = aux_data[base + 1u];
let s_half = aux_data[base + 2u];
let s_color = aux_data[base + 3u];
let local_p = hp - s_offset.xyz;
let d = abs(sdf_3d_eval(local_p, u32(s_params.x), s_half.xyz, s_offset.w));
if d < min_d {
min_d = d;
closest_color = s_color;
}
}
return closest_color;
}
// ============================================================================
// UV Mapping for 3D Shapes
// ============================================================================
fn compute_uv_box(hp: vec3<f32>, half: vec3<f32>) -> vec2<f32> {
let abs_hp = abs(hp);
let safe_half = max(abs(half), vec3<f32>(0.001));
// Project onto dominant face
if abs_hp.z >= safe_half.z - 0.01 {
// Front/back face
return vec2<f32>((hp.x / safe_half.x + 1.0) * 0.5, (hp.y / safe_half.y + 1.0) * 0.5);
} else if abs_hp.y >= safe_half.y - 0.01 {
// Top/bottom face
return vec2<f32>((hp.x / safe_half.x + 1.0) * 0.5, (hp.z / safe_half.z + 1.0) * 0.5);
} else {
// Left/right face
return vec2<f32>((hp.z / safe_half.z + 1.0) * 0.5, (hp.y / safe_half.y + 1.0) * 0.5);
}
}
fn compute_uv_sphere(hp: vec3<f32>) -> vec2<f32> {
let n = normalize(hp + vec3<f32>(0.0001));
let u = atan2(n.z, n.x) / (2.0 * 3.14159) + 0.5;
let v = asin(clamp(n.y, -1.0, 1.0)) / 3.14159 + 0.5;
return vec2<f32>(u, v);
}
fn compute_uv_cylinder(hp: vec3<f32>, half_h: f32) -> vec2<f32> {
let u = atan2(hp.z, hp.x) / (2.0 * 3.14159) + 0.5;
let v = (hp.y / max(half_h, 0.001) + 1.0) * 0.5;
return vec2<f32>(u, v);
}
fn compute_uv_3d(hp: vec3<f32>, shape_type: u32, half: vec3<f32>) -> vec2<f32> {
switch shape_type {
case 1u: { return compute_uv_box(hp, half); }
case 2u: { return compute_uv_sphere(hp); }
case 3u: { return compute_uv_cylinder(hp, half.y); }
case 4u: { return compute_uv_cylinder(hp, half.y); } // torus uses cylindrical
case 5u: { return compute_uv_cylinder(hp, half.y); } // capsule uses cylindrical
default: { return vec2<f32>(0.5, 0.5); }
}
}
// Analytical ray-AABB intersection (slab method)
// Returns vec2(t_enter, t_exit). If t_enter > t_exit, the ray misses.
fn ray_aabb_intersect(ro: vec3<f32>, rd: vec3<f32>, half: vec3<f32>) -> vec2<f32> {
let inv_rd = vec3<f32>(
select(1.0 / rd.x, 1e10, abs(rd.x) < 1e-8),
select(1.0 / rd.y, 1e10, abs(rd.y) < 1e-8),
select(1.0 / rd.z, 1e10, abs(rd.z) < 1e-8),
);
let t1 = (-half - ro) * inv_rd;
let t2 = (half - ro) * inv_rd;
let tmin = min(t1, t2);
let tmax = max(t1, t2);
let t_enter = max(max(tmin.x, tmin.y), tmin.z);
let t_exit = min(min(tmax.x, tmax.y), tmax.z);
return vec2<f32>(t_enter, t_exit);
}
// Inverse rotation helpers (transpose of forward rotation)
fn rotate_y_inv(p: vec3<f32>, s: f32, c: f32) -> vec3<f32> {
return vec3<f32>(c * p.x - s * p.z, p.y, s * p.x + c * p.z);
}
fn rotate_x_inv(p: vec3<f32>, s: f32, c: f32) -> vec3<f32> {
return vec3<f32>(p.x, c * p.y + s * p.z, -s * p.y + c * p.z);
}
fn rotate_z_inv(p: vec3<f32>, s: f32, c: f32) -> vec3<f32> {
return vec3<f32>(c * p.x + s * p.y, -s * p.x + c * p.y, p.z);
}
// ============================================================================
// Fragment Shader
// ============================================================================
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
let prim = primitives[in.instance_index];
let p = in.uv;
// Screen-space derivative magnitude, computed up-front *outside*
// any control flow that depends on per-instance data. WGSL strictly
// requires `fwidth` / `dpdx` / `dpdy` to be called from uniform
// control flow; computing it here on the continuously-interpolated
// `in.uv` (which is uniform across every 2x2 pixel quad regardless
// of which primitive a quad belongs to) satisfies the rule.
//
// For an axis-aligned screen-space SDF this is identical to
// `fwidth(d)`: `d` is locally 1-Lipschitz in pixels, so
// `length(vec2(fwidth(p.x), fwidth(p.y)))` returns ~1.0 for
// axis-aligned edges and ~1.41 for 45° diagonals — the same range
// the previous `fwidth(d)` produced. Native backends (Metal,
// Vulkan, DX12) didn't enforce the uniformity rule so the old
// `fwidth(d)` worked there; Dawn (Chrome's WebGPU validator)
// rejects it because `d` is computed inside a switch that branches
// on a non-uniform `prim_type`. See
// https://www.w3.org/TR/WGSL/#uniformity for the spec rule.
let d_fw_screen = length(vec2<f32>(fwidth(p.x), fwidth(p.y)));
let prim_type = prim.type_info.x;
let fill_type = prim.type_info.y;
let clip_type = prim.type_info.z;
// Early clip test - discard if completely outside clip region (screen space)
let clip_alpha = calculate_clip_alpha(p, prim.clip_bounds, prim.clip_radius, clip_type, prim.clip_fade);
if clip_alpha < 0.001 {
discard;
}
let origin = prim.bounds.xy;
let size = prim.bounds.zw;
let center = origin + size * 0.5;
// Extract rotation and perspective parameters
let sin_rz = prim.rotation.x;
let cos_rz = prim.rotation.y;
let sin_ry = prim.rotation.z;
let cos_ry = prim.rotation.w;
let sin_rx = prim.perspective.x;
let cos_rx = prim.perspective.y;
let persp_d = prim.perspective.z;
let shape_type = u32(prim.perspective.w);
let depth = prim.sdf_3d.x;
let has_3d = abs(sin_ry) > 0.0001 || abs(sin_rx) > 0.0001 || persp_d > 0.001;
// ── 3D SDF Raymarching Path ──
if shape_type > 0u && shape_type != SHAPE_GROUP && depth > 0.001 {
let translate_z = prim.sdf_3d.w;
let pd = select(800.0, persp_d, persp_d > 0.001);
let rel = p - center;
// Ray setup: camera at (0, 0, pd - translate_z), fragment at (rel.x, rel.y, 0)
// Positive translate_z moves object toward viewer (closer = larger)
let cam_vs = vec3<f32>(0.0, 0.0, pd - translate_z);
let frag_vs = vec3<f32>(rel.x, rel.y, 0.0);
let ray_dir_vs = normalize(frag_vs - cam_vs);
// Transform ray to shape space (inverse of rotateZ → rotateX → rotateY)
var ro = cam_vs;
var rd = ray_dir_vs;
ro = rotate_y_inv(ro, sin_ry, cos_ry);
rd = rotate_y_inv(rd, sin_ry, cos_ry);
ro = rotate_x_inv(ro, sin_rx, cos_rx);
rd = rotate_x_inv(rd, sin_rx, cos_rx);
ro = rotate_z_inv(ro, sin_rz, cos_rz);
rd = rotate_z_inv(rd, sin_rz, cos_rz);
// Shape bounds in shape space
let half_3d = vec3<f32>(size.x * 0.5, size.y * 0.5, depth * 0.5);
let corner_r = min(min(prim.corner_radius.x, prim.corner_radius.y), depth * 0.5);
// Analytical ray-AABB intersection for tight starting bound
let aabb_t = ray_aabb_intersect(ro, rd, half_3d);
if aabb_t.x > aabb_t.y || aabb_t.y < 0.0 {
discard; // Ray misses bounding box entirely
}
// Raymarch (32 steps) starting from AABB entry point
// Use AABB diagonal as max distance to allow rays to reach rounded corners
// (at AABB corners, the SDF distance to rounded surface can be large)
var t_rm = max(aabb_t.x - 0.01, 0.0);
let t_max = aabb_t.x + length(half_3d) * 2.0 + 1.0;
var hit = false;
var min_d = 1e10;
for (var i = 0u; i < 32u; i++) {
let pos = ro + rd * t_rm;
let d3 = sdf_3d_eval(pos, shape_type, half_3d, corner_r);
min_d = min(min_d, d3);
if d3 < 0.001 {
hit = true;
break;
}
t_rm += d3;
if t_rm > t_max {
break;
}
}
// Edge anti-aliasing: smooth alpha based on closest approach distance
let pixel_size = max(t_rm / pd, 0.5);
var edge_aa = 1.0;
if !hit {
if min_d > pixel_size * 2.0 {
discard;
}
edge_aa = 1.0 - smoothstep(0.0, pixel_size * 1.5, min_d);
}
// Compute normal via central differences
let hp = ro + rd * t_rm;
let eps = 0.001;
let normal = normalize(vec3<f32>(
sdf_3d_eval(hp + vec3<f32>(eps, 0.0, 0.0), shape_type, half_3d, corner_r) -
sdf_3d_eval(hp - vec3<f32>(eps, 0.0, 0.0), shape_type, half_3d, corner_r),
sdf_3d_eval(hp + vec3<f32>(0.0, eps, 0.0), shape_type, half_3d, corner_r) -
sdf_3d_eval(hp - vec3<f32>(0.0, eps, 0.0), shape_type, half_3d, corner_r),
sdf_3d_eval(hp + vec3<f32>(0.0, 0.0, eps), shape_type, half_3d, corner_r) -
sdf_3d_eval(hp - vec3<f32>(0.0, 0.0, eps), shape_type, half_3d, corner_r)
));
// Lighting
let light_dir = normalize(prim.light.xyz);
let n_dot_l = max(dot(normal, light_dir), 0.0);
let ambient_3d = prim.sdf_3d.y;
let diffuse_3d = n_dot_l * prim.light.w;
// Specular (Blinn-Phong)
let view_dir = normalize(-rd);
let half_vec = normalize(light_dir + view_dir);
let spec_3d = pow(max(dot(normal, half_vec), 0.0), prim.sdf_3d.z) * 0.5;
let lighting = ambient_3d + diffuse_3d + spec_3d;
// UV mapping: use screen-space position for gradient evaluation.
// This gives smooth gradients across all visible faces of 3D shapes
// (avoids face-based UV discontinuities on boxes).
// Screen-space UV: fragment position relative to element bounds.
var base_color_3d: vec4<f32>;
if fill_type == 1u {
// Linear gradient: gradient_params are already in screen-space pixels
let gp = prim.gradient_params;
let gdir = gp.zw - gp.xy;
let glen_sq = dot(gdir, gdir);
if glen_sq > 0.000001 {
let t_g = dot(p - gp.xy, gdir) / glen_sq;
base_color_3d = mix(prim.color, prim.color2, clamp(t_g, 0.0, 1.0));
} else {
base_color_3d = prim.color;
}
} else if fill_type == 2u {
// Radial gradient: (cx, cy, radius, 0) in screen-space pixels
let t_g = length(p - prim.gradient_params.xy) / max(prim.gradient_params.z, 0.001);
base_color_3d = mix(prim.color, prim.color2, clamp(t_g, 0.0, 1.0));
} else {
base_color_3d = prim.color;
}
var result_3d = base_color_3d * vec4<f32>(vec3<f32>(lighting), 1.0);
result_3d.a *= clip_alpha * edge_aa;
return result_3d;
}
// ── 3D Group SDF Raymarching Path ──
// border[1] = shape_count, border[2] = aux_data offset
if shape_type == SHAPE_GROUP && prim.border.y > 0.5 {
let group_shape_count = u32(prim.border.y);
let group_aux_offset = u32(prim.border.z);
// Use max depth from child shapes via border[3] (set by paint context)
let group_depth = max(prim.border.w, 1.0);
let translate_z = prim.sdf_3d.w;
let pd = select(800.0, persp_d, persp_d > 0.001);
let rel = p - center;
// Ray setup (same as individual shapes)
let cam_vs = vec3<f32>(0.0, 0.0, pd - translate_z);
let frag_vs = vec3<f32>(rel.x, rel.y, 0.0);
let ray_dir_vs = normalize(frag_vs - cam_vs);
// Transform ray to shape space
var ro = cam_vs;
var rd = ray_dir_vs;
ro = rotate_y_inv(ro, sin_ry, cos_ry);
rd = rotate_y_inv(rd, sin_ry, cos_ry);
ro = rotate_x_inv(ro, sin_rx, cos_rx);
rd = rotate_x_inv(rd, sin_rx, cos_rx);
ro = rotate_z_inv(ro, sin_rz, cos_rz);
rd = rotate_z_inv(rd, sin_rz, cos_rz);
// AABB for the entire group
let half_3d = vec3<f32>(size.x * 0.5, size.y * 0.5, group_depth * 0.5);
let aabb_t = ray_aabb_intersect(ro, rd, half_3d);
if aabb_t.x > aabb_t.y || aabb_t.y < 0.0 {
discard;
}
// Raymarch the compound SDF (32 steps)
var t_rm = max(aabb_t.x - 0.01, 0.0);
let t_max = aabb_t.x + length(half_3d) * 2.0 + 1.0;
var hit = false;
var min_d = 1e10;
for (var i = 0u; i < 32u; i++) {
let pos = ro + rd * t_rm;
let d3 = eval_group_sdf(pos, group_shape_count, group_aux_offset);
min_d = min(min_d, d3);
if d3 < 0.001 {
hit = true;
break;
}
t_rm += d3;
if t_rm > t_max {
break;
}
}
// Edge anti-aliasing
let pixel_size = max(t_rm / pd, 0.5);
var edge_aa = 1.0;
if !hit {
if min_d > pixel_size * 2.0 {
discard;
}
edge_aa = 1.0 - smoothstep(0.0, pixel_size * 1.5, min_d);
}
// Compute normal via group SDF
let hp = ro + rd * t_rm;
let normal = eval_group_normal(hp, group_shape_count, group_aux_offset);
// Lighting (same as individual shapes)
let light_dir = normalize(prim.light.xyz);
let n_dot_l = max(dot(normal, light_dir), 0.0);
let ambient_3d = prim.sdf_3d.y;
let diffuse_3d = n_dot_l * prim.light.w;
let view_dir = normalize(-rd);
let half_vec = normalize(light_dir + view_dir);
let spec_3d = pow(max(dot(normal, half_vec), 0.0), prim.sdf_3d.z) * 0.5;
let lighting = ambient_3d + diffuse_3d + spec_3d;
// Per-shape coloring: find which child shape is closest to the hit point
let base_color_3d = eval_group_closest_shape_color(hp, group_shape_count, group_aux_offset);
var result_3d = base_color_3d * vec4<f32>(vec3<f32>(lighting), 1.0);
result_3d.a *= clip_alpha * edge_aa;
return result_3d;
}
// ── Perspective Unprojection (flat elements with 3D perspective) ──
var sp = p;
if has_3d {
let pd = select(800.0, persp_d, persp_d > 0.001);
let rel = p - center;
// Inverse homography: map screen point back to element local coords
let safe_cos_ry = max(abs(cos_ry), 0.0001) * sign(cos_ry + 0.0001);
let safe_cos_rx = max(abs(cos_rx), 0.0001) * sign(cos_rx + 0.0001);
let tan_ry = sin_ry / safe_cos_ry;
let tan_rx = sin_rx / safe_cos_rx;
let u = rel.x * cos_rz / safe_cos_ry + rel.y * (-cos_rz * tan_rx * tan_ry + sin_rz / safe_cos_rx);
let v = rel.x * (-sin_rz) / safe_cos_ry + rel.y * (sin_rz * tan_rx * tan_ry + cos_rz / safe_cos_rx);
let w = 1.0 - rel.x * tan_ry / pd + rel.y * tan_rx / (pd * safe_cos_ry);
let safe_w = max(abs(w), 0.001) * sign(w + 0.001);
sp = vec2<f32>(u / safe_w, v / safe_w) + center;
} else {
// 2D affine (rotation, skew, non-uniform scale) via inverse local_affine
let la = prim.local_affine;
let is_identity = abs(la.x - 1.0) < 0.0001 && abs(la.y) < 0.0001
&& abs(la.z) < 0.0001 && abs(la.w - 1.0) < 0.0001;
if !is_identity {
let rel = p - center;
// Compute inverse of 2x2 [a,b; c,d]: inv = [d,-b; -c,a] / det
let det = la.x * la.w - la.y * la.z;
let inv_det = select(-1.0, 1.0, det >= 0.0) / max(abs(det), 0.0001);
let inv_a = la.w * inv_det;
let inv_b = -la.y * inv_det;
let inv_c = -la.z * inv_det;
let inv_d = la.x * inv_det;
sp = vec2<f32>(inv_a * rel.x + inv_c * rel.y, inv_b * rel.x + inv_d * rel.y) + center;
}
}
var result = vec4<f32>(0.0);
// Calculate shadow first (rendered behind) - but NOT for inner shadow primitives
// InnerShadow primitives handle their own shadow rendering differently
if (prim.shadow.z > 0.0 || prim.shadow.w != 0.0) && prim_type != PRIM_INNER_SHADOW {
let shadow_offset = prim.shadow.xy;
let blur = prim.shadow.z;
let spread = prim.shadow.w;
let shadow_origin = origin + shadow_offset - vec2<f32>(spread);
let shadow_size = size + vec2<f32>(spread * 2.0);
// Adjust corner radii for spread (expand corners proportionally)
let shadow_radii = prim.corner_radius + vec4<f32>(spread);
// Use shaped rect SDF (respects squircle corner-shape) instead of plain rounded rect
let shadow_sdf_dist = sd_shaped_rect(sp, shadow_origin, shadow_size, shadow_radii, prim.corner_shape);
var shadow_alpha: f32;
if blur < 0.001 {
shadow_alpha = select(0.0, 1.0, shadow_sdf_dist < 0.0);
} else {
let sigma_d = 0.5 * sqrt(2.0) * blur;
shadow_alpha = 0.5 * (1.0 + erf(-shadow_sdf_dist / sigma_d));
}
let shadow_color = prim.shadow_color * shadow_alpha;
// Premultiply and blend
result = shadow_color;
}
// Calculate main shape SDF
var d: f32;
switch prim_type {
case PRIM_RECT: {
d = sd_shaped_rect(sp, origin, size, prim.corner_radius, prim.corner_shape);
}
case PRIM_CIRCLE: {
let radius = min(size.x, size.y) * 0.5;
d = sd_circle(sp, center, radius);
}
case PRIM_ELLIPSE: {
d = sd_ellipse(sp, center, size * 0.5);
}
case PRIM_SHADOW: {
// Shadow-only primitive - mask out the shape interior
// Shadow should be visible starting from the shape boundary (d >= 0)
// Use constant AA width to avoid discontinuities at triangle seams on Vulkan
let shape_d = sd_shaped_rect(sp, origin, size, prim.corner_radius, prim.corner_shape);
let aa_width = 0.75;
let shape_mask = smoothstep(-aa_width, aa_width, shape_d); // 0 inside, 1 outside, AA at edge
result.a *= shape_mask;
result.a *= clip_alpha;
return result;
}
case PRIM_INNER_SHADOW: {
// Inner shadow - renders INSIDE the shape only
let shape_d = sd_shaped_rect(sp, origin, size, prim.corner_radius, prim.corner_shape);
// Hard clip at shape boundary - only render where d < 0 (inside)
if shape_d > 0.0 {
discard;
}
let blur = max(prim.shadow.z, 0.1);
let spread = prim.shadow.w;
let offset = prim.shadow.xy;
// Inner shadow effect: shadow darkens from outer edge inward
// Use distance from edge (negative shape_d = distance inside)
let edge_dist = -shape_d; // Positive value = how far inside the shape
// Create shadow falloff from edge toward center
// At edge (edge_dist ≈ 0): full shadow
// Further inside (edge_dist > blur + spread): no shadow
let shadow_range = blur + spread;
let shadow_alpha = 1.0 - smoothstep(0.0, shadow_range, edge_dist - spread);
// Apply offset by shifting the shadow calculation
// Offset shifts which "edge" the shadow appears from
let offset_effect = dot(normalize(offset + vec2<f32>(0.001)), sp - center);
let offset_bias = clamp(offset_effect / (length(size) * 0.5), -1.0, 1.0) * length(offset);
let biased_alpha = shadow_alpha * (1.0 + offset_bias * 0.5);
var inner_result = prim.shadow_color;
inner_result.a *= clamp(biased_alpha, 0.0, 1.0) * clip_alpha;
return inner_result;
}
case PRIM_CIRCLE_SHADOW: {
// Circle shadow - radially symmetric Gaussian blur
let radius = min(size.x, size.y) * 0.5;
let blur = prim.shadow.z;
let spread = prim.shadow.w;
let shadow_offset = prim.shadow.xy;
let shadow_center = center + shadow_offset;
let shadow_radius = radius + spread;
let shadow_alpha = shadow_circle(sp, shadow_center, shadow_radius, blur);
// Mask out the circle area so shadow doesn't render under it
// Use constant AA width to avoid discontinuities at triangle seams on Vulkan
let circle_d = sd_circle(sp, center, radius);
let aa_width = 0.75;
let shape_mask = smoothstep(-aa_width, aa_width, circle_d); // 0 inside, 1 outside, AA at edge
var circle_result = prim.shadow_color * shadow_alpha;
circle_result.a *= shape_mask * clip_alpha;
return circle_result;
}
case PRIM_CIRCLE_INNER_SHADOW: {
// Circle inner shadow - renders INSIDE the circle only
let radius = min(size.x, size.y) * 0.5;
let circle_d = sd_circle(sp, center, radius);
// Hard clip at circle boundary
if circle_d > 0.0 {
discard;
}
let blur = max(prim.shadow.z, 0.1);
let spread = prim.shadow.w;
let offset = prim.shadow.xy;
// Inner shadow effect: shadow darkens from outer edge inward
let edge_dist = -circle_d; // How far inside the circle
// Create shadow falloff from edge toward center
let shadow_range = blur + spread;
let shadow_alpha = 1.0 - smoothstep(0.0, shadow_range, edge_dist - spread);
// Apply offset
let offset_effect = dot(normalize(offset + vec2<f32>(0.001)), sp - center);
let offset_bias = clamp(offset_effect / radius, -1.0, 1.0) * length(offset);
let biased_alpha = shadow_alpha * (1.0 + offset_bias * 0.5);
var inner_result = prim.shadow_color;
inner_result.a *= clamp(biased_alpha, 0.0, 1.0) * clip_alpha;
return inner_result;
}
case PRIM_TEXT: {
// Text glyph - sample from glyph atlas.
// UV bounds are stored in gradient_params: (u_min, v_min, u_max, v_max)
// fill_type stores is_color flag (1 = color emoji, 0 = grayscale)
let uv_bounds = prim.gradient_params;
let is_color = fill_type == 1u;
// Calculate UV within the glyph quad
// Use sp (inverse-transformed point) so rotated/skewed text samples correctly
let local_uv = (sp - origin) / size;
// Map to atlas UV coordinates
let atlas_uv = uv_bounds.xy + local_uv * (uv_bounds.zw - uv_bounds.xy);
// Use `textureSampleLevel(..., 0.0)` instead of `textureSample`
// here. WGSL's uniform-control-flow rule applies to
// *implicit-LOD* sampling — `textureSample` derives mip
// level from quad derivatives, which require all four
// pixels in the 2x2 derivative quad to take the same code
// path. We're inside `switch prim_type { case PRIM_TEXT: }`
// and `prim_type` comes from a per-instance buffer lookup
// (`primitives[in.instance_index]`), which Dawn classifies
// as non-uniform — so any implicit-LOD sample inside ANY
// arm of this switch fails validation.
//
// `textureSampleLevel(t, s, uv, 0.0)` takes the LOD as an
// explicit parameter, so no derivatives are needed and the
// uniformity rule doesn't apply. The glyph atlas is a
// single-mip texture (R8Unorm / Rgba8UnormSrgb, no mipmap
// chain), so LOD 0 is the only valid level anyway —
// sampling at LOD 0 is byte-identical to what
// `textureSample` would have produced. Native backends
// (Metal, Vulkan, DX12) accept both forms; Dawn (Chrome's
// WebGPU validator) requires the explicit form here.
var text_result: vec4<f32>;
if is_color {
// Color emoji - sample RGBA directly from color atlas
text_result = textureSampleLevel(color_glyph_atlas, glyph_sampler, atlas_uv, 0.0);
} else {
// Grayscale text - sample coverage from R channel,
// apply gamma correction, tint with primitive color
let coverage = textureSampleLevel(glyph_atlas, glyph_sampler, atlas_uv, 0.0).r;
let gamma_coverage = pow(coverage, 0.7);
text_result = vec4<f32>(prim.color.rgb, prim.color.a * gamma_coverage);
}
// Apply clip alpha
text_result.a *= clip_alpha;
// Soft anti-aliased clipping at edges
let edge_aa = 1.0;
let clip_edge_alpha = smoothstep(0.0, edge_aa, min(
min(p.x - prim.clip_bounds.x, prim.clip_bounds.x + prim.clip_bounds.z - p.x),
min(p.y - prim.clip_bounds.y, prim.clip_bounds.y + prim.clip_bounds.w - p.y)
));
text_result.a *= clip_edge_alpha;
return text_result;
}
default: {
d = sd_shaped_rect(sp, origin, size, prim.corner_radius, prim.corner_shape);
}
}
// Anti-aliasing: smooth transition at edge using screen-space adaptive width.
//
// We reuse `d_fw_screen`, which was computed at the top of fs_main
// from `fwidth(p.x) + fwidth(p.y)` on the uniformly-interpolated
// input position. For an axis-aligned screen-space SDF (which `d`
// is, after the prim_type switch above), this is mathematically
// identical to `fwidth(d)` because `d` is locally 1-Lipschitz in
// pixel space:
//
// - Axis-aligned edges: d_fw_screen ≈ 1.0 → AA width ≈ 0.75px
// - 45° corner curves: d_fw_screen ≈ 1.41 → AA width ≈ 1.06px
//
// The hoist is required for WGSL strict mode (Dawn/Chrome) — the
// direct `fwidth(d)` would be "called from non-uniform control
// flow" because `d` was assigned inside the prim_type switch.
let d_fw = d_fw_screen;
let aa_width = max(d_fw * 0.75, 0.5);
let fill_alpha = 1.0 - smoothstep(-aa_width, aa_width, d);
if fill_alpha < 0.001 {
return result;
}
// Determine fill color
var fill_color: vec4<f32>;
switch fill_type {
case FILL_SOLID: {
fill_color = prim.color;
}
case FILL_LINEAR_GRADIENT: {
// Linear gradient using gradient_params (x1, y1, x2, y2) in user space
let g_start = prim.gradient_params.xy;
let g_end = prim.gradient_params.zw;
let g_dir = g_end - g_start;
let g_len_sq = dot(g_dir, g_dir);
var t: f32;
if (g_len_sq > 0.0001) {
// Project current position onto gradient line
let proj = sp - g_start;
t = clamp(dot(proj, g_dir) / g_len_sq, 0.0, 1.0);
} else {
t = 0.0;
}
fill_color = mix(prim.color, prim.color2, t);
}
case FILL_RADIAL_GRADIENT: {
// Radial gradient using gradient_params (cx, cy, radius, 0) in user space
let g_center = prim.gradient_params.xy;
let g_radius = prim.gradient_params.z;
let dist = length(sp - g_center);
let t = clamp(dist / max(g_radius, 0.001), 0.0, 1.0);
fill_color = mix(prim.color, prim.color2, t);
}
default: {
fill_color = prim.color;
}
}
// Handle border with proper inner corner radii (GPUI-style approach)
// The border is the ring between the outer shape edge and an inner shape
// For asymmetric borders, inner corners become elliptical, not circular
// prim.border = [top, right, bottom, left] for per-side borders, or [uniform, 0, 0, 0] for uniform
let border_top = prim.border.x;
let border_right = prim.border.y;
let border_bottom = prim.border.z;
let border_left = prim.border.w;
// Check if any border is present (using max of all sides)
let max_border = max(max(border_top, border_right), max(border_bottom, border_left));
if max_border > 0.0 {
// For uniform border (legacy: only .x set), use it for all sides
let bt = select(border_top, border_top, border_right > 0.0 || border_bottom > 0.0 || border_left > 0.0);
let br = select(border_top, border_right, border_right > 0.0 || border_bottom > 0.0 || border_left > 0.0);
let bb = select(border_top, border_bottom, border_right > 0.0 || border_bottom > 0.0 || border_left > 0.0);
let bl = select(border_top, border_left, border_right > 0.0 || border_bottom > 0.0 || border_left > 0.0);
let half_size = size * 0.5;
let rel = sp - center; // Position relative to center (signed, in unrotated space)
// Use same AA width as outer edge for consistent anti-aliasing quality
let border_aa = aa_width; // 0.75 — matches outer edge smoothstep
// Select corner radius and corner shape based on quadrant
var corner_radius: f32;
var corner_n: f32;
if rel.y < 0.0 {
if rel.x > 0.0 { corner_radius = prim.corner_radius.y; corner_n = prim.corner_shape.y; } // top-right
else { corner_radius = prim.corner_radius.x; corner_n = prim.corner_shape.x; } // top-left
} else {
if rel.x > 0.0 { corner_radius = prim.corner_radius.z; corner_n = prim.corner_shape.z; } // bottom-right
else { corner_radius = prim.corner_radius.w; corner_n = prim.corner_shape.w; } // bottom-left
}
// Clamp radius to half the minimum dimension (CSS spec)
corner_radius = min(corner_radius, min(half_size.x, half_size.y));
// Select border widths for nearest edges based on quadrant (GPUI approach)
let border = vec2<f32>(
select(br, bl, rel.x < 0.0), // horizontal: left or right
select(bb, bt, rel.y < 0.0) // vertical: top or bottom
);
// Handle zero-width borders (treat as negative for AA purposes)
let reduced_border = vec2<f32>(
select(border.x, -border_aa, border.x == 0.0),
select(border.y, -border_aa, border.y == 0.0)
);
// Calculate position relative to corner
let corner_to_point = abs(rel) - half_size;
let corner_center_to_point = corner_to_point + corner_radius;
// Determine if we're near a rounded corner
let is_near_rounded_corner = corner_center_to_point.x >= 0.0 && corner_center_to_point.y >= 0.0;
// Inner straight border edge
let straight_border_inner = corner_to_point + reduced_border;
// Check if we're clearly inside the inner area (not near border)
let is_within_inner_straight = straight_border_inner.x < -border_aa &&
straight_border_inner.y < -border_aa;
// Fast path: clearly inside inner area, not near rounded corner
if is_within_inner_straight && !is_near_rounded_corner {
// No border here, keep fill_color as-is
} else {
// Calculate inner SDF based on context
var inner_sdf: f32;
if abs(reduced_border.x - reduced_border.y) < 0.001 {
// Uniform border — use exact SDF offset of the outer shape distance.
// For rounded rects (Minkowski sum of box + circle), inset by a constant
// produces another rounded rect. This is branchless, handles both straight
// edges and corners, and is guaranteed continuous with the outer SDF.
inner_sdf = -(d + reduced_border.x);
} else {
// Asymmetric borders — inner corners become elliptical
if corner_center_to_point.x <= 0.0 || corner_center_to_point.y <= 0.0 {
// Not in corner region — straight edge distance
inner_sdf = -max(straight_border_inner.x, straight_border_inner.y);
} else if abs(corner_n - 1.0) < 0.01 {
// Round corner — elliptical inner corner (GPUI approach)
let ellipse_radii = max(vec2<f32>(0.0), vec2<f32>(corner_radius) - reduced_border);
inner_sdf = quarter_ellipse_sdf(corner_center_to_point, ellipse_radii);
} else {
// Superellipse with per-axis reduced radii
let inner_radii = max(vec2<f32>(0.0), vec2<f32>(corner_radius) - reduced_border);
let p_exp = pow(2.0, min(abs(corner_n), 5.0));
let min_inner_r = min(inner_radii.x, inner_radii.y);
if min_inner_r < 0.001 {
inner_sdf = -length(max(vec2<f32>(0.0), corner_center_to_point));
} else {
let inner_t = corner_center_to_point / inner_radii;
let inner_se = pow(max(inner_t.x, 0.0), p_exp) + pow(max(inner_t.y, 0.0), p_exp);
let inner_r_scale = sqrt(inner_radii.x * inner_radii.y);
inner_sdf = -((pow(inner_se, 1.0 / p_exp) - 1.0) * inner_r_scale);
}
}
}
// Use screen-space adaptive AA for the inner border edge.
// fwidth(d) = |dpdx(d)| + |dpdy(d)| adapts to the local SDF gradient direction:
// - Straight edges (axis-aligned): fwidth ≈ 1.0 → AA width ≈ 0.75px
// - 45° diagonal (corner curves): fwidth ≈ 1.414 → AA width ≈ 1.06px
// The wider AA at diagonal angles compensates for pixel grid alignment,
// producing smoother inner border curves on opaque fills.
let inner_aa = max(d_fw * 0.75, 0.5);
let border_blend = smoothstep(-inner_aa, inner_aa, -inner_sdf);
// Only apply border color where we're inside the shape
fill_color = mix(fill_color, prim.border_color, border_blend * step(0.001, fill_alpha));
}
}
// Apply clip alpha to shadow
result.a *= clip_alpha;
// Mask shadow strictly outside the shape boundary
// Use the same aa_width as fill_alpha to prevent gaps at corners
// The shadow should render only where d > 0 (outside the shape)
if result.a > 0.0 {
// Use matching AA width to ensure shadow and fill meet seamlessly
let shadow_mask = smoothstep(-aa_width, aa_width, d);
result.a *= shadow_mask;
}
// Blend fill over shadow at FULL opacity first (fill fully covers shadow)
// This ensures no shadow bleeds through the shape regardless of edge AA
let full_fill = vec4<f32>(fill_color.rgb, fill_color.a * clip_alpha);
result = full_fill + result * (1.0 - full_fill.a);
// NOW apply outer edge anti-aliasing to the combined result
// This gives smooth edges against the background without shadow bleed
result.a *= fill_alpha;
// Apply mask gradient (mask-image: linear-gradient / radial-gradient)
// mask_info.x: 0=none, 1=linear, 2=radial
// mask_params are in OBB (0-1) space relative to element bounds
let mask_type = prim.mask_info.x;
if mask_type > 0.5 {
// Compute normalized UV relative to element bounds
let mask_uv = (sp - origin) / max(size, vec2<f32>(0.001));
var mask_t: f32;
if mask_type < 1.5 {
// Linear mask gradient
let m_start = prim.mask_params.xy;
let m_end = prim.mask_params.zw;
let m_dir = m_end - m_start;
let m_len_sq = dot(m_dir, m_dir);
if m_len_sq > 0.0001 {
mask_t = clamp(dot(mask_uv - m_start, m_dir) / m_len_sq, 0.0, 1.0);
} else {
mask_t = 0.0;
}
} else {
// Radial mask gradient
let m_center = prim.mask_params.xy;
let m_radius = prim.mask_params.z;
mask_t = clamp(length(mask_uv - m_center) / max(m_radius, 0.001), 0.0, 1.0);
}
let mask_alpha = mix(prim.mask_info.y, prim.mask_info.z, mask_t);
result = vec4<f32>(result.rgb * mask_alpha, result.a * mask_alpha);
}
// Apply CSS filters (grayscale, invert, sepia, hue-rotate, brightness, contrast, saturate)
// Skip if all identity (filter_a all zero, filter_b = (1,1,1,0))
let fa = prim.filter_a;
let fb = prim.filter_b;
if fa.x != 0.0 || fa.y != 0.0 || fa.z != 0.0 || abs(fa.w) > 0.001 || fb.x != 1.0 || fb.y != 1.0 || fb.z != 1.0 {
result = apply_css_filter(result, fa, fb);
}
return result;
}
"#;
pub const TEXT_SHADER: &str = r#"
// ============================================================================
// Blinc SDF Text Shader
// ============================================================================
// Supports grayscale text and color emoji via separate atlases
struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) uv: vec2<f32>,
@location(1) color: vec4<f32>,
@location(2) world_pos: vec2<f32>,
@location(3) @interpolate(flat) clip_bounds: vec4<f32>,
@location(4) @interpolate(flat) clip_fade: vec4<f32>,
@location(5) @interpolate(flat) is_color: f32,
}
struct TextUniforms {
viewport_size: vec2<f32>,
_padding: vec2<f32>,
}
struct GlyphInstance {
// Position and size (x, y, width, height)
bounds: vec4<f32>,
// UV coordinates in atlas (u_min, v_min, u_max, v_max)
uv_bounds: vec4<f32>,
// Text color
color: vec4<f32>,
// Clip bounds (x, y, width, height) - set to large values for no clip
clip_bounds: vec4<f32>,
// Overflow fade distances (top, right, bottom, left) in pixels
clip_fade: vec4<f32>,
// Flags: [is_color, unused, unused, unused]
// is_color: 1.0 = color emoji (use color_atlas), 0.0 = grayscale (use glyph_atlas)
flags: vec4<f32>,
}
@group(0) @binding(0) var<uniform> uniforms: TextUniforms;
@group(0) @binding(1) var<storage, read> glyphs: array<GlyphInstance>;
@group(0) @binding(2) var glyph_atlas: texture_2d<f32>;
@group(0) @binding(3) var glyph_sampler: sampler;
@group(0) @binding(4) var color_atlas: texture_2d<f32>;
@vertex
fn vs_main(
@builtin(vertex_index) vertex_index: u32,
@builtin(instance_index) instance_index: u32,
) -> VertexOutput {
var out: VertexOutput;
let glyph = glyphs[instance_index];
// Generate quad vertices
// Quad vertices split along / diagonal (1-3 shared edge)
// PowerVR Vulkan codegen bug workaround — see SDF_SHADER vs_main
// for the rationale. `let array<...>(literal)[runtime]` is broken
// for indices 3..5 on the Pixel 10 Pro PowerVR driver; an explicit
// `switch` keeps the same vertex layout and renders correctly.
var local_uv: vec2<f32>;
switch vertex_index {
case 0u: { local_uv = vec2<f32>(0.0, 0.0); } // 0 - top-left
case 1u: { local_uv = vec2<f32>(1.0, 0.0); } // 1 - top-right
case 2u: { local_uv = vec2<f32>(0.0, 1.0); } // 3 - bottom-left
case 3u: { local_uv = vec2<f32>(1.0, 0.0); } // 1 - top-right
case 4u: { local_uv = vec2<f32>(1.0, 1.0); } // 2 - bottom-right
default: { local_uv = vec2<f32>(0.0, 1.0); } // 3 - bottom-left
}
// Position in screen space
let pos = vec2<f32>(
glyph.bounds.x + local_uv.x * glyph.bounds.z,
glyph.bounds.y + local_uv.y * glyph.bounds.w
);
// UV in atlas
let uv = vec2<f32>(
glyph.uv_bounds.x + local_uv.x * (glyph.uv_bounds.z - glyph.uv_bounds.x),
glyph.uv_bounds.y + local_uv.y * (glyph.uv_bounds.w - glyph.uv_bounds.y)
);
// Convert to clip space
let clip_pos = vec2<f32>(
(pos.x / uniforms.viewport_size.x) * 2.0 - 1.0,
1.0 - (pos.y / uniforms.viewport_size.y) * 2.0
);
out.position = vec4<f32>(clip_pos, 0.0, 1.0);
out.uv = uv;
out.color = glyph.color;
out.world_pos = pos;
out.clip_bounds = glyph.clip_bounds;
out.clip_fade = glyph.clip_fade;
out.is_color = glyph.flags.x;
return out;
}
// Calculate clip alpha for rectangular clip region
fn calculate_clip_alpha(p: vec2<f32>, clip_bounds: vec4<f32>, clip_fade: vec4<f32>) -> f32 {
// Check if clipping is active (default bounds are very large negative values)
if clip_bounds.x < -5000.0 {
return 1.0;
}
// Clip bounds are (x, y, width, height)
let clip_min = clip_bounds.xy;
let clip_max = clip_bounds.xy + clip_bounds.zw;
// Calculate signed distance to clip rect edges
let d_left = p.x - clip_min.x;
let d_right = clip_max.x - p.x;
let d_top = p.y - clip_min.y;
let d_bottom = clip_max.y - p.y;
// Minimum distance to any edge (negative = outside)
let d = min(min(d_left, d_right), min(d_top, d_bottom));
// Soft anti-aliased edge (1 pixel transition)
var alpha = clamp(d + 0.5, 0.0, 1.0);
// Apply overflow fade
if clip_fade.x > 0.0 { alpha *= saturate(d_top / clip_fade.x); }
if clip_fade.y > 0.0 { alpha *= saturate(d_right / clip_fade.y); }
if clip_fade.z > 0.0 { alpha *= saturate(d_bottom / clip_fade.z); }
if clip_fade.w > 0.0 { alpha *= saturate(d_left / clip_fade.w); }
return alpha;
}
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
// Calculate clip alpha first - discard if completely outside
let clip_alpha = calculate_clip_alpha(in.world_pos, in.clip_bounds, in.clip_fade);
if clip_alpha < 0.001 {
discard;
}
// Use `textureSampleLevel(..., 0.0)` instead of `textureSample`
// for both atlas paths. WGSL's uniform-control-flow rule applies
// to *implicit-LOD* sampling — `textureSample` derives the mip
// level from quad derivatives, which need all four pixels in the
// 2x2 derivative quad to take the same code path. The `is_color`
// flag is per-glyph (`@interpolate(flat)`) and can vary between
// adjacent quads, so Dawn classifies the `if in.is_color > 0.5`
// branch as non-uniform and rejects implicit-LOD samples inside.
//
// The glyph atlases are single-mip textures (R8Unorm and
// Rgba8UnormSrgb, no mipmap chain), so LOD 0 is the only valid
// level — `textureSampleLevel(..., 0.0)` is byte-identical to
// what `textureSample` would have produced. Native backends
// (Metal, Vulkan, DX12) accept both forms; Dawn requires the
// explicit form here.
if in.is_color > 0.5 {
// Color emoji: sample RGBA from color atlas, use texture color directly
let emoji_color = textureSampleLevel(color_atlas, glyph_sampler, in.uv, 0.0);
return vec4<f32>(emoji_color.rgb, emoji_color.a * clip_alpha);
} else {
// Grayscale text: sample coverage from glyph atlas, apply tint color
let coverage = textureSampleLevel(glyph_atlas, glyph_sampler, in.uv, 0.0).r;
// Use coverage directly with slight gamma correction for cleaner edges
// pow(x, 0.7) brightens mid-tones, making strokes appear crisper
let aa_alpha = pow(coverage, 0.7);
return vec4<f32>(in.color.rgb, in.color.a * aa_alpha * clip_alpha);
}
}
"#;
pub const GLASS_SHADER: &str = r#"
// ============================================================================
// Blinc Glass/Vibrancy Shader
// ============================================================================
// Creates frosted glass effects similar to Apple's vibrancy system
struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) uv: vec2<f32>,
@location(1) screen_uv: vec2<f32>,
@location(2) @interpolate(flat) instance_index: u32,
}
struct GlassUniforms {
viewport_size: vec2<f32>,
time: f32,
_padding: f32,
}
// Glass material types (matching Apple's vibrancy styles)
const GLASS_ULTRA_THIN: u32 = 0u;
const GLASS_THIN: u32 = 1u;
const GLASS_REGULAR: u32 = 2u;
const GLASS_THICK: u32 = 3u;
const GLASS_CHROME: u32 = 4u;
const GLASS_SIMPLE: u32 = 5u; // Simple frosted glass - no liquid effects
struct GlassPrimitive {
// Bounds (x, y, width, height)
bounds: vec4<f32>,
// Corner radii (top-left, top-right, bottom-right, bottom-left)
corner_radius: vec4<f32>,
// Tint color (RGBA)
tint_color: vec4<f32>,
// Glass parameters (blur_radius, saturation, brightness, noise_amount)
params: vec4<f32>,
// Glass parameters 2 (border_thickness, light_angle, shadow_blur, shadow_opacity)
params2: vec4<f32>,
// Type info (glass_type, shadow_offset_x_bits, shadow_offset_y_bits, 0)
type_info: vec4<u32>,
// Clip bounds (x, y, width, height) for clamping blur samples
clip_bounds: vec4<f32>,
// Clip corner radii (for rounded rect clips)
clip_radius: vec4<f32>,
// Border color (RGBA) - when alpha > 0, renders a solid border instead of light-based highlights
border_color: vec4<f32>,
}
@group(0) @binding(0) var<uniform> uniforms: GlassUniforms;
@group(0) @binding(1) var<storage, read> primitives: array<GlassPrimitive>;
@group(0) @binding(2) var backdrop_texture: texture_2d<f32>;
@group(0) @binding(3) var backdrop_sampler: sampler;
// ============================================================================
// Vertex Shader
// ============================================================================
@vertex
fn vs_main(
@builtin(vertex_index) vertex_index: u32,
@builtin(instance_index) instance_index: u32,
) -> VertexOutput {
var out: VertexOutput;
let prim = primitives[instance_index];
// Expand bounds for shadow blur
let shadow_blur = prim.params2.z;
let shadow_offset_x = bitcast<f32>(prim.type_info.y);
let shadow_offset_y = bitcast<f32>(prim.type_info.z);
let shadow_expand = shadow_blur * 3.0 + abs(shadow_offset_x) + abs(shadow_offset_y);
let bounds = vec4<f32>(
prim.bounds.x - shadow_expand,
prim.bounds.y - shadow_expand,
prim.bounds.z + shadow_expand * 2.0,
prim.bounds.w + shadow_expand * 2.0
);
// Generate quad vertices split along / diagonal (1-3 shared edge)
// PowerVR Vulkan codegen bug workaround — see SDF_SHADER vs_main
// for the rationale. `let array<...>(literal)[runtime]` is broken
// for indices 3..5 on the Pixel 10 Pro PowerVR driver; an explicit
// `switch` keeps the same vertex layout and renders correctly.
var local_uv: vec2<f32>;
switch vertex_index {
case 0u: { local_uv = vec2<f32>(0.0, 0.0); } // 0 - top-left
case 1u: { local_uv = vec2<f32>(1.0, 0.0); } // 1 - top-right
case 2u: { local_uv = vec2<f32>(0.0, 1.0); } // 3 - bottom-left
case 3u: { local_uv = vec2<f32>(1.0, 0.0); } // 1 - top-right
case 4u: { local_uv = vec2<f32>(1.0, 1.0); } // 2 - bottom-right
default: { local_uv = vec2<f32>(0.0, 1.0); } // 3 - bottom-left
}
let pos = vec2<f32>(
bounds.x + local_uv.x * bounds.z,
bounds.y + local_uv.y * bounds.w
);
// Convert to clip space
let clip_pos = vec2<f32>(
(pos.x / uniforms.viewport_size.x) * 2.0 - 1.0,
1.0 - (pos.y / uniforms.viewport_size.y) * 2.0
);
out.position = vec4<f32>(clip_pos, 0.0, 1.0);
out.uv = pos;
out.screen_uv = pos / uniforms.viewport_size;
out.instance_index = instance_index;
return out;
}
// ============================================================================
// SDF and Blur Functions
// ============================================================================
// Error function approximation for Gaussian blur
fn erf(x: f32) -> f32 {
let s = sign(x);
let a = abs(x);
let t = 1.0 / (1.0 + 0.3275911 * a);
let y = 1.0 - (((((1.061405429 * t - 1.453152027) * t) + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t * exp(-a * a);
return s * y;
}
// Gaussian shadow for rounded rectangle using SDF
// This properly respects corner radii for accurate rounded rect shadows
fn shadow_rounded_rect(p: vec2<f32>, origin: vec2<f32>, size: vec2<f32>, radius: vec4<f32>, sigma: f32) -> f32 {
// Get SDF distance (negative inside, positive outside)
let d = sd_rounded_rect(p, origin, size, radius);
if sigma < 0.001 {
// No blur - hard edge
return select(0.0, 1.0, d < 0.0);
}
// Use SDF for Gaussian-like falloff
// erf-based smooth transition from inside to outside
// This creates a proper soft shadow that follows the rounded rect shape
let blur_factor = 0.5 * sqrt(2.0) * sigma;
return 0.5 * (1.0 - erf(d / blur_factor));
}
fn sd_rounded_rect(p: vec2<f32>, origin: vec2<f32>, size: vec2<f32>, radius: vec4<f32>) -> f32 {
let half_size = size * 0.5;
let center = origin + half_size;
let rel = p - center;
let q = abs(rel) - half_size;
// Select corner radius based on quadrant
// radius: (top-left, top-right, bottom-right, bottom-left)
// In screen coords: Y increases downward, so rel.y < 0 means top half
var r: f32;
if rel.y < 0.0 {
if rel.x > 0.0 {
r = radius.y; // top-right
} else {
r = radius.x; // top-left
}
} else {
if rel.x > 0.0 {
r = radius.z; // bottom-right
} else {
r = radius.w; // bottom-left
}
}
r = min(r, min(half_size.x, half_size.y));
let q_adjusted = q + vec2<f32>(r);
return length(max(q_adjusted, vec2<f32>(0.0))) + min(max(q_adjusted.x, q_adjusted.y), 0.0) - r;
}
// Hash function for noise
fn hash(p: vec2<f32>) -> f32 {
let h = dot(p, vec2<f32>(127.1, 311.7));
return fract(sin(h) * 43758.5453123);
}
// Smooth noise
fn noise(p: vec2<f32>) -> f32 {
let i = floor(p);
let f = fract(p);
let u = f * f * (3.0 - 2.0 * f);
return mix(
mix(hash(i + vec2<f32>(0.0, 0.0)), hash(i + vec2<f32>(1.0, 0.0)), u.x),
mix(hash(i + vec2<f32>(0.0, 1.0)), hash(i + vec2<f32>(1.0, 1.0)), u.x),
u.y
);
}
// Gaussian weight function
fn gaussian_weight(x: f32, sigma: f32) -> f32 {
return exp(-(x * x) / (2.0 * sigma * sigma));
}
// Calculate clip alpha for rectangular clip region (for scroll containers)
fn calculate_glass_clip_alpha(p: vec2<f32>, clip_bounds: vec4<f32>) -> f32 {
// Check if clipping is active (default bounds are very large negative values)
if clip_bounds.x < -5000.0 {
return 1.0;
}
// Clip bounds are (x, y, width, height)
let clip_min = clip_bounds.xy;
let clip_max = clip_bounds.xy + clip_bounds.zw;
// Calculate signed distance to clip rect edges
let d_left = p.x - clip_min.x;
let d_right = clip_max.x - p.x;
let d_top = p.y - clip_min.y;
let d_bottom = clip_max.y - p.y;
// Minimum distance to any edge (negative = outside)
let d = min(min(d_left, d_right), min(d_top, d_bottom));
// Soft anti-aliased edge (1 pixel transition)
return clamp(d + 0.5, 0.0, 1.0);
}
// High quality blur using golden-angle spiral sampling
// CSS spec: blur(Npx) means standard deviation = N pixels
//
// Uses `textureSampleLevel(.., .., uv, 0.0)` instead of `textureSample` so
// the call is legal from non-uniform control flow. The fragment shader
// `discard`s for shadow-only fragments before reaching the blur path,
// which makes flow non-uniform across the quad — and WGSL/WebGPU bans
// implicit-derivative samples (`textureSample`) in that case. The
// backdrop has `mip_level_count: 1`, so explicitly sampling LOD 0 is
// functionally identical to the implicit form.
fn blur_backdrop(uv: vec2<f32>, blur_radius: f32) -> vec4<f32> {
if blur_radius < 0.5 {
return textureSampleLevel(backdrop_texture, backdrop_sampler, uv, 0.0);
}
let texel_size = 1.0 / uniforms.viewport_size;
let sigma = blur_radius; // CSS spec: blur radius IS the standard deviation
// Start with center sample (highest weight)
var color = textureSampleLevel(backdrop_texture, backdrop_sampler, uv, 0.0);
var total_weight = 1.0;
let golden_angle = 2.39996323; // 137.5 degrees in radians
// Sample out to 2.5 sigma for proper Gaussian coverage
let sample_extent = blur_radius * 2.5;
// 6 rings with 12 samples each = 72 samples, linear spacing
let num_rings = 6;
let samples_per_ring = 12;
for (var ring = 1; ring <= num_rings; ring++) {
let ring_t = f32(ring) / f32(num_rings);
let ring_radius = sample_extent * ring_t; // Linear spacing
let ring_offset = ring_radius * texel_size;
for (var i = 0; i < samples_per_ring; i++) {
let angle = f32(i) * (6.283185 / f32(samples_per_ring)) + f32(ring) * golden_angle;
let offset = vec2<f32>(cos(angle), sin(angle)) * ring_offset;
let sample_pos = uv + offset;
let weight = gaussian_weight(ring_radius, sigma);
color += textureSampleLevel(backdrop_texture, backdrop_sampler, sample_pos, 0.0) * weight;
total_weight += weight;
}
}
return color / total_weight;
}
// High quality blur with clip bounds for scroll containers
// CSS spec: blur(Npx) means standard deviation = N pixels
//
// Uses `textureSampleLevel` for the same uniform-control-flow reason
// documented on `blur_backdrop` above.
fn blur_backdrop_clipped(uv: vec2<f32>, blur_radius: f32, clip_bounds: vec4<f32>) -> vec4<f32> {
let clip_min = clip_bounds.xy / uniforms.viewport_size;
let clip_max = (clip_bounds.xy + clip_bounds.zw) / uniforms.viewport_size;
let has_clip = clip_bounds.x > -5000.0;
if blur_radius < 0.5 {
let clamped_uv = select(uv, clamp(uv, clip_min, clip_max), has_clip);
return textureSampleLevel(backdrop_texture, backdrop_sampler, clamped_uv, 0.0);
}
let texel_size = 1.0 / uniforms.viewport_size;
let sigma = blur_radius; // CSS spec: blur radius IS the standard deviation
// Start with center sample (highest weight)
let center_uv = select(uv, clamp(uv, clip_min, clip_max), has_clip);
var color = textureSampleLevel(backdrop_texture, backdrop_sampler, center_uv, 0.0);
var total_weight = 1.0;
let golden_angle = 2.39996323; // 137.5 degrees in radians
// Sample out to 2.5 sigma for proper Gaussian coverage
let sample_extent = blur_radius * 2.5;
// 6 rings with 12 samples each = 72 samples, linear spacing
let num_rings = 6;
let samples_per_ring = 12;
for (var ring = 1; ring <= num_rings; ring++) {
let ring_t = f32(ring) / f32(num_rings);
let ring_radius = sample_extent * ring_t; // Linear spacing
let ring_offset = ring_radius * texel_size;
for (var i = 0; i < samples_per_ring; i++) {
let angle = f32(i) * (6.283185 / f32(samples_per_ring)) + f32(ring) * golden_angle;
let offset = vec2<f32>(cos(angle), sin(angle)) * ring_offset;
var sample_pos = uv + offset;
sample_pos = select(sample_pos, clamp(sample_pos, clip_min, clip_max), has_clip);
let weight = gaussian_weight(ring_radius, sigma);
color += textureSampleLevel(backdrop_texture, backdrop_sampler, sample_pos, 0.0) * weight;
total_weight += weight;
}
}
return color / total_weight;
}
// Apply saturation adjustment
fn adjust_saturation(color: vec3<f32>, saturation: f32) -> vec3<f32> {
let luminance = dot(color, vec3<f32>(0.299, 0.587, 0.114));
return mix(vec3<f32>(luminance), color, saturation);
}
// Calculate SDF gradient (normal direction pointing outward from shape)
fn sdf_gradient(p: vec2<f32>, origin: vec2<f32>, size: vec2<f32>, radius: vec4<f32>) -> vec2<f32> {
let eps = 0.5;
let d = sd_rounded_rect(p, origin, size, radius);
let dx = sd_rounded_rect(p + vec2<f32>(eps, 0.0), origin, size, radius) - d;
let dy = sd_rounded_rect(p + vec2<f32>(0.0, eps), origin, size, radius) - d;
let g = vec2<f32>(dx, dy);
let len = length(g);
if len < 0.001 {
return vec2<f32>(0.0, -1.0);
}
return g / len;
}
// ============================================================================
// Fragment Shader - iOS 26 Liquid Glass Effect
// ============================================================================
// Liquid glass = smooth rounded bevel, NOT hard edge lines
// The "liquid" feel comes from wide, gentle transitions that look organic
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
let prim = primitives[in.instance_index];
let p = in.uv;
// Calculate clip alpha first - discard if completely outside clip bounds
let clip_alpha = calculate_glass_clip_alpha(p, prim.clip_bounds);
if clip_alpha < 0.001 {
discard;
}
let origin = prim.bounds.xy;
let size = prim.bounds.zw;
// Shadow parameters
let shadow_blur = prim.params2.z;
let shadow_opacity = prim.params2.w;
let shadow_offset_x = bitcast<f32>(prim.type_info.y);
let shadow_offset_y = bitcast<f32>(prim.type_info.z);
// Calculate SDF with smooth anti-aliasing
let d = sd_rounded_rect(p, origin, size, prim.corner_radius);
let aa = 1.5; // Wide AA for smooth edges (constant to avoid Vulkan triangle seam artifacts)
// Smooth mask - combine with clip alpha
let mask = (1.0 - smoothstep(-aa, aa, d)) * clip_alpha;
// ========================================================================
// DROP SHADOW (rendered as pure shadow, no glass effects)
// ========================================================================
// Shadow is a simple soft rectangle behind the glass - no bevel, no refraction
let has_shadow = shadow_opacity > 0.001 && shadow_blur > 0.001;
var shadow_color_premult = vec4<f32>(0.0);
if has_shadow {
let shadow_origin = origin + vec2<f32>(shadow_offset_x, shadow_offset_y);
let shadow_alpha = shadow_rounded_rect(p, shadow_origin, size, prim.corner_radius, shadow_blur);
// Apply clip alpha to shadow as well
shadow_color_premult = vec4<f32>(0.0, 0.0, 0.0, shadow_alpha * shadow_opacity * clip_alpha);
// If we're completely outside the glass panel, just render the shadow
if mask < 0.001 {
if shadow_alpha > 0.001 && clip_alpha > 0.001 {
return shadow_color_premult;
}
discard;
}
} else {
// No shadow - discard if outside glass
if mask < 0.001 {
discard;
}
}
// Glass parameters
let blur_radius = prim.params.x;
let saturation = prim.params.y;
let brightness = prim.params.z;
let noise_amount = prim.params.w;
let glass_type = prim.type_info.x;
// ========================================================================
// SIMPLE FROSTED GLASS (no liquid effects)
// ========================================================================
// Pure frosted glass: blur + tint + saturation/brightness
// No refraction, no edge bevels, no light reflections
if glass_type == GLASS_SIMPLE {
// Sample and blur the backdrop directly at screen UV (no refraction)
var simple_backdrop = blur_backdrop_clipped(in.screen_uv, blur_radius, prim.clip_bounds);
// Apply saturation and brightness adjustments
var result_rgb = adjust_saturation(simple_backdrop.rgb, saturation);
result_rgb = result_rgb * brightness;
// Apply tint as a subtle additive overlay (not heavy mixing)
// This keeps the backdrop colors visible while adding a light tint
let tint = prim.tint_color;
if tint.a > 0.001 {
// Soft light blend: backdrop + tint * tint_alpha (additive overlay)
result_rgb = result_rgb + tint.rgb * tint.a * 0.5;
}
// Optional noise for frosted texture
if noise_amount > 0.0 {
let n = noise(p * 0.3);
result_rgb = result_rgb + vec3<f32>((n - 0.5) * noise_amount * 0.02);
}
result_rgb = clamp(result_rgb, vec3<f32>(0.0), vec3<f32>(1.0));
// Blend shadow underneath the glass
if has_shadow && shadow_color_premult.a > 0.001 {
let shadow_contrib = shadow_color_premult.a * (1.0 - mask);
let final_alpha = mask + shadow_contrib;
if final_alpha > 0.001 {
let final_rgb = (result_rgb * mask + shadow_color_premult.rgb * shadow_contrib) / final_alpha;
return vec4<f32>(final_rgb, final_alpha);
}
}
return vec4<f32>(result_rgb, mask);
}
// Distance from edge (0 at edge, positive going inward)
let inner_dist = max(0.0, -d);
// ========================================================================
// TWO-LAYER LIQUID GLASS (Apple-style)
// ========================================================================
// Layer 1: EDGE BEVEL - wider rim with strong light bending for liquid effect
// Layer 2: FLAT CENTER - undistorted frosted glass surface
// The edge seamlessly connects to the flat center.
// Edge bevel thickness - concentrated near edge for sharp liquid bevel
let edge_thickness = min(25.0, min(size.x, size.y) * 0.2);
// Progress through edge zone: 0 = at glass edge, 1 = into flat center
let edge_progress = clamp(inner_dist / edge_thickness, 0.0, 1.0);
// For depth shading (used later)
let bevel = 1.0 - edge_progress;
// ========================================================================
// EDGE BEVEL REFRACTION - Liquid Glass Effect
// ========================================================================
// The refraction follows the edge NORMAL direction, not radial from center.
// This creates proper glass rim bending where light bends perpendicular to the edge.
// Get SDF gradient (points outward from shape - this IS the edge normal)
let edge_normal = sdf_gradient(p, origin, size, prim.corner_radius);
// Refraction strength: strongest at outer edge, fades smoothly to center
// Using quadratic falloff concentrated at edge for visible bevel effect
let refract_strength = bevel * bevel;
// Refraction multiplier from type_info.w (0.0 = no refraction, 1.0 = full refraction)
// We use a sentinel value: if type_info.w == 0 (unset), default to 1.0 (full refraction)
// To disable refraction, set type_info.w to the bits of a small negative number like -1.0
// This way 0 (unset) = full refraction, any other value = that value's refraction
let refraction_mult = bitcast<f32>(prim.type_info.w);
// Check if explicitly set (non-zero bits) - if unset (0), use 1.0 for backwards compat
// If set to 0.0f (which has bits 0x00000000), we need a different sentinel
// Solution: use -1.0 as "use explicit value" flag in the sign bit
let is_explicitly_set = (prim.type_info.w & 0x80000000u) != 0u; // Check sign bit
let explicit_value = abs(refraction_mult); // Remove sign to get actual value
let effective_refract_mult = select(1.0, explicit_value, is_explicitly_set);
// Offset UV along edge normal - sample backdrop from OUTSIDE the shape
// This creates the "looking through curved glass rim" effect where
// content appears pulled inward at the bevel
// The offset is in PIXELS, then converted to UV space
// Strong distortion for clearly visible bevel curve
let refract_pixels = refract_strength * 60.0 * effective_refract_mult; // Up to 60 pixels of displacement at edge
let refract_offset = edge_normal * refract_pixels;
// Apply refraction - ADD offset to sample from outside (pulls content inward visually)
let refracted_uv = in.screen_uv + refract_offset / uniforms.viewport_size;
// ========================================================================
// APPLE LIQUID GLASS EFFECT (WWDC25 Style)
// ========================================================================
// Key characteristics from reference:
// 1. Nearly transparent interior - minimal blur/frost
// 2. Crisp bright edge highlight line along perimeter
// 3. Subtle edge shadow just inside the highlight
// 4. Very subtle refraction - background barely distorted
// 5. Optional chromatic aberration at edges
// ========================================================================
// BACKDROP - Blur based on blur_radius parameter
// ========================================================================
// Use blur_radius directly - user controls the blur amount
// The blur is applied to the interior, edges remain clear due to refraction
let effective_blur = blur_radius; // Direct control - user sets exact blur amount
// Use clipped blur to prevent sampling outside scroll containers
var backdrop = blur_backdrop_clipped(refracted_uv, effective_blur, prim.clip_bounds);
backdrop = vec4<f32>(adjust_saturation(backdrop.rgb, saturation), 1.0);
backdrop = vec4<f32>(backdrop.rgb * brightness, 1.0);
var result = backdrop.rgb;
// ========================================================================
// EDGE HIGHLIGHT / BORDER - Configurable thin line with angle-based light reflection
// ========================================================================
// When border_color.a > 0, use the explicit border color.
// Otherwise, use the signature light-based edge highlight.
let edge_line_width = prim.params2.x; // User-configurable border thickness
let light_angle = prim.params2.y; // Light source angle in radians
let bc = prim.border_color;
let edge_line = smoothstep(0.0, edge_line_width * 0.3, inner_dist) *
(1.0 - smoothstep(edge_line_width, edge_line_width * 1.5, inner_dist));
// Compute light-based highlight strength (used by glass type variants too)
let light_dir = vec2<f32>(cos(light_angle), sin(light_angle));
let facing_light = dot(edge_normal, -light_dir);
let light_factor = 0.2 + 0.8 * max(0.0, facing_light);
let highlight_strength = edge_line * 0.6 * light_factor * mask;
if bc.a > 0.001 {
// Solid border color mode: blend the user-specified color at the edge
result = mix(result, bc.rgb, edge_line * bc.a * mask);
} else {
// Light-based highlight mode (default liquid glass look)
result = result + vec3<f32>(highlight_strength);
}
// ========================================================================
// INNER EDGE SHADOW - Very subtle depth
// ========================================================================
let shadow_start = edge_line_width * 2.5;
let shadow_end = edge_line_width * 8.0;
let inner_shadow = smoothstep(shadow_start, shadow_end, inner_dist) *
(1.0 - smoothstep(shadow_end, shadow_end * 3.0, inner_dist));
result = result - vec3<f32>(inner_shadow * 0.04 * mask); // More subtle, masked
// ========================================================================
// VERY SUBTLE TINT - Almost invisible
// ========================================================================
let tint = prim.tint_color;
let tint_strength = tint.a * 0.08; // Even more subtle
result = mix(result, tint.rgb, tint_strength);
// Optional subtle noise
if noise_amount > 0.0 {
let n = noise(p * 0.3);
result = result + vec3<f32>((n - 0.5) * noise_amount * 0.005);
}
// Glass type variants - adjust edge highlight intensity
switch glass_type {
case GLASS_ULTRA_THIN: {
// Even more transparent
result = mix(backdrop.rgb, result, 0.7);
}
case GLASS_THIN: {
// Slightly more visible
}
case GLASS_REGULAR: {
// Default - as designed above
}
case GLASS_THICK: {
// Stronger edge highlight
result = result + vec3<f32>(highlight_strength * 0.3);
}
case GLASS_CHROME: {
// Add slight metallic tint
let chrome = vec3<f32>(0.96, 0.97, 0.99);
result = mix(result, chrome, 0.1);
}
default: {}
}
result = clamp(result, vec3<f32>(0.0), vec3<f32>(1.0));
// Blend shadow underneath the glass
// Glass is rendered on top of shadow using standard alpha compositing
// Final = glass_color * glass_alpha + shadow_color * shadow_alpha * (1 - glass_alpha)
if has_shadow && shadow_color_premult.a > 0.001 {
let glass_color = vec4<f32>(result, mask);
let shadow_contrib = shadow_color_premult.a * (1.0 - mask);
let final_alpha = mask + shadow_contrib;
if final_alpha > 0.001 {
let final_rgb = (result * mask + shadow_color_premult.rgb * shadow_contrib) / final_alpha;
return vec4<f32>(final_rgb, final_alpha);
}
}
return vec4<f32>(result, mask);
}
"#;
pub const SIMPLE_GLASS_SHADER: &str = r#"
// ============================================================================
// Simple Frosted Glass Shader
// ============================================================================
//
// Pure backdrop blur without liquid glass effects.
// More performant and suitable for subtle UI backgrounds.
struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) uv: vec2<f32>,
@location(1) screen_uv: vec2<f32>,
@location(2) @interpolate(flat) instance_index: u32,
}
struct SimpleGlassUniforms {
viewport_size: vec2<f32>,
time: f32,
_padding: f32,
}
struct SimpleGlassPrimitive {
bounds: vec4<f32>,
corner_radius: vec4<f32>,
tint_color: vec4<f32>,
params: vec4<f32>, // blur, saturation, brightness, noise
params2: vec4<f32>, // border_thickness, light_angle, shadow_blur, shadow_opacity
type_info: vec4<u32>, // glass_type, shadow_offset_x_bits, shadow_offset_y_bits, clip_type
clip_bounds: vec4<f32>,
clip_radius: vec4<f32>,
border_color: vec4<f32>, // Border color (RGBA) - when alpha > 0, renders solid border
}
@group(0) @binding(0) var<uniform> uniforms: SimpleGlassUniforms;
@group(0) @binding(1) var<storage, read> primitives: array<SimpleGlassPrimitive>;
@group(0) @binding(2) var backdrop_texture: texture_2d<f32>;
@group(0) @binding(3) var backdrop_sampler: sampler;
// ============================================================================
// Vertex Shader
// ============================================================================
@vertex
fn vs_main(
@builtin(vertex_index) vertex_index: u32,
@builtin(instance_index) instance_index: u32,
) -> VertexOutput {
var out: VertexOutput;
let prim = primitives[instance_index];
// Expand bounds for shadow blur
let shadow_blur = prim.params2.z;
let shadow_offset_x = bitcast<f32>(prim.type_info.y);
let shadow_offset_y = bitcast<f32>(prim.type_info.z);
let shadow_expand = shadow_blur * 3.0 + abs(shadow_offset_x) + abs(shadow_offset_y);
let bounds = vec4<f32>(
prim.bounds.x - shadow_expand,
prim.bounds.y - shadow_expand,
prim.bounds.z + shadow_expand * 2.0,
prim.bounds.w + shadow_expand * 2.0
);
// Generate quad vertices split along / diagonal (1-3 shared edge)
// PowerVR Vulkan codegen bug workaround — see SDF_SHADER vs_main
// for the rationale. `let array<...>(literal)[runtime]` is broken
// for indices 3..5 on the Pixel 10 Pro PowerVR driver; an explicit
// `switch` keeps the same vertex layout and renders correctly.
var local_uv: vec2<f32>;
switch vertex_index {
case 0u: { local_uv = vec2<f32>(0.0, 0.0); } // 0 - top-left
case 1u: { local_uv = vec2<f32>(1.0, 0.0); } // 1 - top-right
case 2u: { local_uv = vec2<f32>(0.0, 1.0); } // 3 - bottom-left
case 3u: { local_uv = vec2<f32>(1.0, 0.0); } // 1 - top-right
case 4u: { local_uv = vec2<f32>(1.0, 1.0); } // 2 - bottom-right
default: { local_uv = vec2<f32>(0.0, 1.0); } // 3 - bottom-left
}
let pos = vec2<f32>(
bounds.x + local_uv.x * bounds.z,
bounds.y + local_uv.y * bounds.w
);
// Convert to clip space
let clip_pos = vec2<f32>(
(pos.x / uniforms.viewport_size.x) * 2.0 - 1.0,
1.0 - (pos.y / uniforms.viewport_size.y) * 2.0
);
out.position = vec4<f32>(clip_pos, 0.0, 1.0);
out.uv = pos;
out.screen_uv = pos / uniforms.viewport_size;
out.instance_index = instance_index;
return out;
}
// ============================================================================
// SDF and Blur Functions
// ============================================================================
fn sd_rounded_rect(p: vec2<f32>, origin: vec2<f32>, size: vec2<f32>, radius: vec4<f32>) -> f32 {
let half_size = size * 0.5;
let center = origin + half_size;
let rel = p - center;
let q = abs(rel) - half_size;
var r: f32;
if (rel.x < 0.0 && rel.y < 0.0) { r = radius.x; }
else if (rel.x >= 0.0 && rel.y < 0.0) { r = radius.y; }
else if (rel.x >= 0.0 && rel.y >= 0.0) { r = radius.z; }
else { r = radius.w; }
// Clamp radius to half the minimum dimension (CSS spec)
r = min(r, min(half_size.x, half_size.y));
let outer_dist = length(max(q + r, vec2<f32>(0.0)));
let inner_dist = min(max(q.x + r, q.y + r), 0.0);
return outer_dist + inner_dist - r;
}
fn shadow_rounded_rect(p: vec2<f32>, origin: vec2<f32>, size: vec2<f32>, radius: vec4<f32>, blur: f32) -> f32 {
let d = sd_rounded_rect(p, origin, size, radius);
let sigma = blur * 0.5;
return 1.0 - smoothstep(-sigma * 2.0, sigma * 2.0, d);
}
fn calculate_clip_alpha(p: vec2<f32>, clip_bounds: vec4<f32>) -> f32 {
let clip_min = clip_bounds.xy;
let clip_max = clip_bounds.xy + clip_bounds.zw;
let edge_dist = min(
min(p.x - clip_min.x, clip_max.x - p.x),
min(p.y - clip_min.y, clip_max.y - p.y)
);
return smoothstep(-0.5, 0.5, edge_dist);
}
fn adjust_saturation(color: vec3<f32>, saturation: f32) -> vec3<f32> {
let luminance = dot(color, vec3<f32>(0.299, 0.587, 0.114));
return mix(vec3<f32>(luminance), color, saturation);
}
// Gaussian weight function
fn gaussian_weight(x: f32, sigma: f32) -> f32 {
return exp(-(x * x) / (2.0 * sigma * sigma));
}
// High quality blur using golden-angle spiral sampling
// CSS spec: blur(Npx) means standard deviation = N pixels
//
// Uses `textureSampleLevel(.., .., uv, 0.0)` for the same uniform-control-flow
// reason documented on the equivalent function in GLASS_SHADER above:
// the fragment shader `discard`s shadow-only fragments, so plain
// `textureSample` (which needs implicit derivatives) is illegal here.
// The backdrop has `mip_level_count: 1`, so explicit LOD 0 is identical.
fn blur_backdrop(uv: vec2<f32>, radius: f32, clip_bounds: vec4<f32>) -> vec4<f32> {
if radius < 0.5 {
return textureSampleLevel(backdrop_texture, backdrop_sampler, uv, 0.0);
}
let texel_size = 1.0 / uniforms.viewport_size;
let sigma = radius; // CSS spec: blur radius IS the standard deviation
// Clip bounds in UV space
let clip_min = clip_bounds.xy / uniforms.viewport_size;
let clip_max = (clip_bounds.xy + clip_bounds.zw) / uniforms.viewport_size;
let has_clip = clip_bounds.x > -5000.0;
// Start with center sample (highest weight)
let center_uv = select(uv, clamp(uv, clip_min, clip_max), has_clip);
var color = textureSampleLevel(backdrop_texture, backdrop_sampler, center_uv, 0.0);
var total_weight = 1.0;
// Golden angle spiral for smooth sample distribution
let golden_angle = 2.39996323; // 137.5 degrees in radians
// Sample out to 2.5 sigma for proper Gaussian coverage (captures ~99% of kernel)
let sample_extent = radius * 2.5;
// 6 rings with 12 samples each = 72 samples, linear spacing
let num_rings = 6;
let samples_per_ring = 12;
for (var ring = 1; ring <= num_rings; ring++) {
let ring_t = f32(ring) / f32(num_rings);
let ring_radius = sample_extent * ring_t; // Linear spacing for uniform coverage
let ring_offset = ring_radius * texel_size;
for (var i = 0; i < samples_per_ring; i++) {
let angle = f32(i) * (6.283185 / f32(samples_per_ring)) + f32(ring) * golden_angle;
let offset = vec2<f32>(cos(angle), sin(angle)) * ring_offset;
var sample_pos = uv + offset;
sample_pos = select(sample_pos, clamp(sample_pos, clip_min, clip_max), has_clip);
let weight = gaussian_weight(ring_radius, sigma);
color += textureSampleLevel(backdrop_texture, backdrop_sampler, sample_pos, 0.0) * weight;
total_weight += weight;
}
}
return color / total_weight;
}
// Noise function for frosted texture
fn noise(p: vec2<f32>) -> f32 {
let i = floor(p);
let f = fract(p);
let u = f * f * (3.0 - 2.0 * f);
let a = fract(sin(dot(i, vec2<f32>(127.1, 311.7))) * 43758.5453);
let b = fract(sin(dot(i + vec2<f32>(1.0, 0.0), vec2<f32>(127.1, 311.7))) * 43758.5453);
let c = fract(sin(dot(i + vec2<f32>(0.0, 1.0), vec2<f32>(127.1, 311.7))) * 43758.5453);
let d = fract(sin(dot(i + vec2<f32>(1.0, 1.0), vec2<f32>(127.1, 311.7))) * 43758.5453);
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}
// ============================================================================
// Fragment Shader
// ============================================================================
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
let prim = primitives[in.instance_index];
let p = in.uv;
// Calculate clip alpha
let clip_alpha = calculate_clip_alpha(p, prim.clip_bounds);
if clip_alpha < 0.001 {
discard;
}
let origin = prim.bounds.xy;
let size = prim.bounds.zw;
// Shadow parameters
let shadow_blur = prim.params2.z;
let shadow_opacity = prim.params2.w;
let shadow_offset_x = bitcast<f32>(prim.type_info.y);
let shadow_offset_y = bitcast<f32>(prim.type_info.z);
// Calculate SDF
let d = sd_rounded_rect(p, origin, size, prim.corner_radius);
let aa = 1.5; // Constant AA to avoid Vulkan triangle seam artifacts
let mask = (1.0 - smoothstep(-aa, aa, d)) * clip_alpha;
// Drop shadow
let has_shadow = shadow_opacity > 0.001 && shadow_blur > 0.001;
var shadow_color_premult = vec4<f32>(0.0);
if has_shadow {
let shadow_origin = origin + vec2<f32>(shadow_offset_x, shadow_offset_y);
let shadow_alpha = shadow_rounded_rect(p, shadow_origin, size, prim.corner_radius, shadow_blur);
shadow_color_premult = vec4<f32>(0.0, 0.0, 0.0, shadow_alpha * shadow_opacity * clip_alpha);
if mask < 0.001 {
if shadow_alpha > 0.001 && clip_alpha > 0.001 {
return shadow_color_premult;
}
discard;
}
} else {
if mask < 0.001 {
discard;
}
}
// Glass parameters
let blur_radius = prim.params.x;
let saturation = prim.params.y;
let brightness = prim.params.z;
let noise_amount = prim.params.w;
// Sample and blur backdrop directly (NO refraction, NO distortion)
var backdrop = blur_backdrop(in.screen_uv, blur_radius, prim.clip_bounds);
// Apply saturation and brightness
var result_rgb = adjust_saturation(backdrop.rgb, saturation);
result_rgb = result_rgb * brightness;
// Apply tint as subtle additive overlay
let tint = prim.tint_color;
if tint.a > 0.001 {
result_rgb = result_rgb + tint.rgb * tint.a * 0.5;
}
// Optional noise for frosted texture
if noise_amount > 0.0 {
let n = noise(p * 0.3);
result_rgb = result_rgb + vec3<f32>((n - 0.5) * noise_amount * 0.02);
}
// Border color support
let bc = prim.border_color;
if bc.a > 0.001 {
let border_width = prim.params2.x;
let inner_dist = -d; // Distance from edge into the shape
let edge_band = smoothstep(0.0, border_width * 0.3, inner_dist) *
(1.0 - smoothstep(border_width, border_width * 1.5, inner_dist));
result_rgb = mix(result_rgb, bc.rgb, edge_band * bc.a * mask);
}
result_rgb = clamp(result_rgb, vec3<f32>(0.0), vec3<f32>(1.0));
// Blend shadow underneath
if has_shadow && shadow_color_premult.a > 0.001 {
let shadow_contrib = shadow_color_premult.a * (1.0 - mask);
let final_alpha = mask + shadow_contrib;
if final_alpha > 0.001 {
let final_rgb = (result_rgb * mask + shadow_color_premult.rgb * shadow_contrib) / final_alpha;
return vec4<f32>(final_rgb, final_alpha);
}
}
return vec4<f32>(result_rgb, mask);
}
"#;
pub const COMPOSITE_SHADER: &str = r#"
// ============================================================================
// Blinc Compositor Shader
// ============================================================================
struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) uv: vec2<f32>,
}
struct CompositeUniforms {
opacity: f32,
blend_mode: u32,
_padding: vec2<f32>,
}
// Blend modes
const BLEND_NORMAL: u32 = 0u;
const BLEND_MULTIPLY: u32 = 1u;
const BLEND_SCREEN: u32 = 2u;
const BLEND_OVERLAY: u32 = 3u;
const BLEND_DARKEN: u32 = 4u;
const BLEND_LIGHTEN: u32 = 5u;
@group(0) @binding(0) var<uniform> uniforms: CompositeUniforms;
@group(0) @binding(1) var source_texture: texture_2d<f32>;
@group(0) @binding(2) var source_sampler: sampler;
@vertex
fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput {
var out: VertexOutput;
// Fullscreen triangle
let uv = vec2<f32>(
f32((vertex_index << 1u) & 2u),
f32(vertex_index & 2u)
);
out.position = vec4<f32>(uv * 2.0 - 1.0, 0.0, 1.0);
out.uv = vec2<f32>(uv.x, 1.0 - uv.y);
return out;
}
fn blend_overlay(base: vec3<f32>, blend: vec3<f32>) -> vec3<f32> {
return select(
2.0 * base * blend,
1.0 - 2.0 * (1.0 - base) * (1.0 - blend),
base > vec3<f32>(0.5)
);
}
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
let color = textureSample(source_texture, source_sampler, in.uv);
// Apply opacity
var result = color;
result.a *= uniforms.opacity;
// Note: actual blending with destination happens in the blend state
// This shader just prepares the source color
return result;
}
"#;
pub const PATH_SHADER: &str = r#"
// ============================================================================
// Path Rendering Shader
// ============================================================================
//
// Renders tessellated vector paths as colored triangles.
// Supports solid colors and gradients via per-vertex UV coordinates.
// Supports multi-stop gradients via 1D texture lookup.
// Supports clipping via rect/circle/ellipse shapes.
// Clip type constants
const CLIP_NONE: u32 = 0u;
const CLIP_RECT: u32 = 1u;
const CLIP_CIRCLE: u32 = 2u;
const CLIP_ELLIPSE: u32 = 3u;
const CLIP_POLYGON: u32 = 4u;
struct Uniforms {
// viewport_size (vec2) + padding (vec2) = 16 bytes, offset 0
viewport_size: vec2<f32>,
opacity: f32,
_pad0: f32,
// 3x3 transform stored as 3 vec4s (xyz used, w is padding) = 48 bytes, offset 16
transform_row0: vec4<f32>,
transform_row1: vec4<f32>,
transform_row2: vec4<f32>,
// Clip parameters = 32 bytes, offset 64
clip_bounds: vec4<f32>, // (x, y, width, height) or (cx, cy, rx, ry)
clip_radius: vec4<f32>, // corner radii or (rx, ry, 0, 0)
// clip_type + flags = 16 bytes, offset 96
clip_type: u32,
use_gradient_texture: u32, // 0=use vertex colors, 1=sample gradient texture
use_image_texture: u32, // 0=no image, 1=sample image texture
use_glass_effect: u32, // 0=no glass, 1=glass effect on path
// Image UV bounds = 16 bytes, offset 112
image_uv_bounds: vec4<f32>, // (u_min, v_min, u_max, v_max)
// Glass parameters = 16 bytes, offset 128
glass_params: vec4<f32>, // (blur_radius, saturation, tint_strength, opacity)
// Glass tint color = 16 bytes, offset 144
glass_tint: vec4<f32>, // RGBA tint color
}
// Total: 160 bytes
@group(0) @binding(0) var<uniform> uniforms: Uniforms;
@group(0) @binding(1) var gradient_texture: texture_1d<f32>;
@group(0) @binding(2) var gradient_sampler: sampler;
@group(0) @binding(3) var image_texture: texture_2d<f32>;
@group(0) @binding(4) var image_sampler: sampler;
@group(0) @binding(5) var backdrop_texture: texture_2d<f32>;
@group(0) @binding(6) var backdrop_sampler: sampler;
struct VertexInput {
@location(0) position: vec2<f32>,
@location(1) color: vec4<f32>, // start color for gradients, solid color otherwise
@location(2) end_color: vec4<f32>, // end color for gradients
@location(3) uv: vec2<f32>,
@location(4) gradient_params: vec4<f32>, // linear: (x1,y1,x2,y2); radial: (cx,cy,r,0)
@location(5) gradient_type: u32,
@location(6) edge_distance: f32, // distance to nearest edge (for AA)
// Per-vertex clip data — populated by push_path_with_brush_info so multiple
// path submissions with different clips can coexist in one VBO/draw call.
@location(7) clip_bounds: vec4<f32>,
@location(8) clip_radius: vec4<f32>,
@location(9) clip_type: u32,
}
struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) color: vec4<f32>,
@location(1) end_color: vec4<f32>,
@location(2) uv: vec2<f32>,
@location(3) @interpolate(flat) gradient_params: vec4<f32>,
@location(4) @interpolate(flat) gradient_type: u32,
@location(5) edge_distance: f32,
@location(6) screen_pos: vec2<f32>, // screen position for clip calculations
@location(7) @interpolate(flat) v_clip_bounds: vec4<f32>,
@location(8) @interpolate(flat) v_clip_radius: vec4<f32>,
@location(9) @interpolate(flat) v_clip_type: u32,
}
// ============================================================================
// SDF Functions for Clipping
// ============================================================================
// Rounded rectangle SDF
fn sd_rounded_rect(p: vec2<f32>, origin: vec2<f32>, size: vec2<f32>, radius: vec4<f32>) -> f32 {
let half_size = size * 0.5;
let center = origin + half_size;
let rel = p - center;
let q = abs(rel) - half_size;
// Select corner radius based on quadrant
// radius: (top-left, top-right, bottom-right, bottom-left)
var r: f32;
if rel.y < 0.0 {
if rel.x > 0.0 {
r = radius.y; // top-right
} else {
r = radius.x; // top-left
}
} else {
if rel.x > 0.0 {
r = radius.z; // bottom-right
} else {
r = radius.w; // bottom-left
}
}
r = min(r, min(half_size.x, half_size.y));
let q_adjusted = q + vec2<f32>(r);
return length(max(q_adjusted, vec2<f32>(0.0))) + min(max(q_adjusted.x, q_adjusted.y), 0.0) - r;
}
// Circle SDF
fn sd_circle(p: vec2<f32>, center: vec2<f32>, radius: f32) -> f32 {
return length(p - center) - radius;
}
// Ellipse SDF (approximation)
fn sd_ellipse(p: vec2<f32>, center: vec2<f32>, radii: vec2<f32>) -> f32 {
let p_centered = p - center;
let p_norm = p_centered / radii;
let dist = length(p_norm);
return (dist - 1.0) * min(radii.x, radii.y);
}
// Calculate clip alpha (1.0 = inside clip, 0.0 = outside)
// For non-rect clips: clip_bounds = rect scissor, clip_radius = shape data
fn calculate_clip_alpha(p: vec2<f32>, clip_bounds: vec4<f32>, clip_radius: vec4<f32>, clip_type: u32) -> f32 {
if clip_type == CLIP_NONE {
return 1.0;
}
let aa_width = 0.75;
switch clip_type {
case CLIP_RECT: {
let clip_origin = clip_bounds.xy;
let clip_size = clip_bounds.zw;
let clip_d = sd_rounded_rect(p, clip_origin, clip_size, clip_radius);
return 1.0 - smoothstep(-aa_width, aa_width, clip_d);
}
case CLIP_CIRCLE: {
let scissor_d = sd_rounded_rect(p, clip_bounds.xy, clip_bounds.zw, vec4<f32>(0.0));
let scissor_alpha = 1.0 - smoothstep(-aa_width, aa_width, scissor_d);
let center = clip_radius.xy;
let radius = clip_radius.z;
let clip_d = sd_circle(p, center, radius);
let shape_alpha = 1.0 - smoothstep(-aa_width, aa_width, clip_d);
return scissor_alpha * shape_alpha;
}
case CLIP_ELLIPSE: {
let scissor_d = sd_rounded_rect(p, clip_bounds.xy, clip_bounds.zw, vec4<f32>(0.0));
let scissor_alpha = 1.0 - smoothstep(-aa_width, aa_width, scissor_d);
let center = clip_radius.xy;
let radii = clip_radius.zw;
let clip_d = sd_ellipse(p, center, radii);
let shape_alpha = 1.0 - smoothstep(-aa_width, aa_width, clip_d);
return scissor_alpha * shape_alpha;
}
default: {
return 1.0;
}
}
}
// ============================================================================
// Vertex Shader
// ============================================================================
@vertex
fn vs_main(in: VertexInput) -> VertexOutput {
var out: VertexOutput;
// Reconstruct transform matrix and apply
let p = vec3<f32>(in.position, 1.0);
let transformed = vec3<f32>(
dot(uniforms.transform_row0.xyz, p),
dot(uniforms.transform_row1.xyz, p),
dot(uniforms.transform_row2.xyz, p)
);
// Store screen position for clip calculations
out.screen_pos = transformed.xy;
// Convert to clip space (-1 to 1)
let clip_pos = vec2<f32>(
(transformed.x / uniforms.viewport_size.x) * 2.0 - 1.0,
1.0 - (transformed.y / uniforms.viewport_size.y) * 2.0
);
out.position = vec4<f32>(clip_pos, 0.0, 1.0);
out.color = in.color;
out.end_color = in.end_color;
out.uv = in.uv;
out.gradient_params = in.gradient_params;
out.gradient_type = in.gradient_type;
out.edge_distance = in.edge_distance;
out.v_clip_bounds = in.clip_bounds;
out.v_clip_radius = in.clip_radius;
out.v_clip_type = in.clip_type;
return out;
}
// ============================================================================
// Fragment Shader
// ============================================================================
// Simple box blur for glass effect (samples backdrop in a small radius)
//
// Uses `textureSampleLevel` instead of `textureSample` so this is legal
// to call from non-uniform control flow. The path-pipeline fragment
// shader takes glass-effect branches based on per-vertex clip flags
// (`use_glass_effect`), which are not provably uniform across the
// rasterized triangle, so WGSL/WebGPU rejects implicit-derivative
// samples here.
fn sample_blur(uv: vec2<f32>, blur_radius: f32, viewport_size: vec2<f32>) -> vec4<f32> {
let pixel_size = 1.0 / viewport_size;
var total = vec4<f32>(0.0);
var samples = 0.0;
// Simple 5x5 box blur
let sample_radius = i32(clamp(blur_radius * 0.1, 1.0, 3.0));
for (var x = -sample_radius; x <= sample_radius; x++) {
for (var y = -sample_radius; y <= sample_radius; y++) {
let offset = vec2<f32>(f32(x), f32(y)) * pixel_size * blur_radius * 0.5;
let sample_uv = clamp(uv + offset, vec2<f32>(0.0), vec2<f32>(1.0));
total += textureSampleLevel(backdrop_texture, backdrop_sampler, sample_uv, 0.0);
samples += 1.0;
}
}
return total / samples;
}
// Adjust saturation of a color
fn adjust_saturation(color: vec3<f32>, saturation: f32) -> vec3<f32> {
let gray = dot(color, vec3<f32>(0.299, 0.587, 0.114));
return mix(vec3<f32>(gray), color, saturation);
}
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
// Calculate clip alpha from per-vertex clip data so multiple paths with
// different clips can share a single VBO/draw without clobbering each
// other. The uniform clip_* fields are kept for legacy callers but no
// longer used here.
let clip_alpha = calculate_clip_alpha(
in.screen_pos,
in.v_clip_bounds,
in.v_clip_radius,
in.v_clip_type
);
// Early out if fully clipped
if clip_alpha < 0.001 {
discard;
}
// Use `textureSampleLevel(..., 0.0)` instead of `textureSample`
// for the gradient and image lookups. WGSL's uniform-control-flow
// rule applies to *implicit-LOD* sampling — `textureSample`
// derives mip level from quad derivatives, which need all four
// pixels in the 2x2 derivative quad to take the same code path.
// The brush type is selected by `in.gradient_type` (per-vertex)
// and `uniforms.use_image_texture` / `use_gradient_texture` /
// `use_glass_effect` (uniform), but Dawn classifies the per-vertex
// value as non-uniform — so any implicit-LOD sample inside a
// branch keyed on it fails validation.
//
// Both the gradient texture (1D ramp) and the image brush texture
// are created with `mip_level_count: 1` (see
// `gradient_texture.rs:222` and `image.rs:39`), so LOD 0 is the
// only valid level. `textureSampleLevel(t, s, uv, 0.0)` is
// byte-identical to what `textureSample` would have produced.
// Native backends (Metal, Vulkan, DX12) accept both forms; Dawn
// requires the explicit form here.
var color: vec4<f32>;
// Check for glass effect first
if (uniforms.use_glass_effect == 1u) {
// Glass effect: sample and blur backdrop, apply tint
let screen_uv = in.screen_pos / uniforms.viewport_size;
let blur_radius = uniforms.glass_params.x;
let saturation = uniforms.glass_params.y;
let tint_strength = uniforms.glass_params.z;
let glass_opacity = uniforms.glass_params.w;
// Sample blurred backdrop
var backdrop = sample_blur(screen_uv, blur_radius, uniforms.viewport_size);
// Adjust saturation
backdrop = vec4<f32>(adjust_saturation(backdrop.rgb, saturation), backdrop.a);
// Apply tint
let tinted = mix(backdrop.rgb, uniforms.glass_tint.rgb, tint_strength * uniforms.glass_tint.a);
// Final color with glass opacity
color = vec4<f32>(tinted, glass_opacity);
} else if (uniforms.use_image_texture == 1u) {
// Image brush: sample from image texture using UV coordinates
// Map the path UV (0-1 in bounding box) to image UV bounds
let uv_min = uniforms.image_uv_bounds.xy;
let uv_max = uniforms.image_uv_bounds.zw;
let image_uv = uv_min + in.uv * (uv_max - uv_min);
color = textureSampleLevel(image_texture, image_sampler, image_uv, 0.0);
// Apply tint from vertex color (multiply)
color = vec4<f32>(color.rgb * in.color.rgb, color.a * in.color.a);
} else if (in.gradient_type == 0u) {
// Solid color
color = in.color;
} else if (in.gradient_type == 1u) {
// Linear gradient - use gradient_params for direction
// params: (x1, y1, x2, y2) in ObjectBoundingBox space (0-1)
let g_start = in.gradient_params.xy;
let g_end = in.gradient_params.zw;
let g_dir = g_end - g_start;
let g_len_sq = dot(g_dir, g_dir);
// Project UV onto gradient line
var t: f32;
if (g_len_sq > 0.0001) {
let p_lin = in.uv - g_start;
t = clamp(dot(p_lin, g_dir) / g_len_sq, 0.0, 1.0);
} else {
t = 0.0;
}
// Sample from gradient texture or mix vertex colors
if (uniforms.use_gradient_texture == 1u) {
// Multi-stop gradient: sample from 1D texture
color = textureSampleLevel(gradient_texture, gradient_sampler, t, 0.0);
} else {
// 2-stop fast path: mix vertex colors
color = mix(in.color, in.end_color, t);
}
} else {
// Radial gradient - params: (cx, cy, r, 0) in ObjectBoundingBox space
let center = in.gradient_params.xy;
let radius = in.gradient_params.z;
let dist = length(in.uv - center);
let t = clamp(dist / max(radius, 0.001), 0.0, 1.0);
// Sample from gradient texture or mix vertex colors
if (uniforms.use_gradient_texture == 1u) {
// Multi-stop gradient: sample from 1D texture
color = textureSampleLevel(gradient_texture, gradient_sampler, t, 0.0);
} else {
// 2-stop fast path: mix vertex colors
color = mix(in.color, in.end_color, t);
}
}
// Apply opacity and clip alpha
// Note: edge-distance AA disabled - tessellated geometry has vertices ON path edges
// (edge_distance = 0), which causes entire shape to fade. Need different AA approach.
color.a *= uniforms.opacity * clip_alpha;
return color;
}
"#;
pub const IMAGE_SHADER: &str = include_str!("shaders/image.wgsl");
pub const LAYER_COMPOSITE_SHADER: &str = r#"
// ============================================================================
// Layer Composition Shader
// ============================================================================
//
// Composites a layer texture onto a destination with blend modes and opacity.
// Blend mode constants (matching blinc_core::BlendMode)
const BLEND_NORMAL: u32 = 0u;
const BLEND_MULTIPLY: u32 = 1u;
const BLEND_SCREEN: u32 = 2u;
const BLEND_OVERLAY: u32 = 3u;
const BLEND_DARKEN: u32 = 4u;
const BLEND_LIGHTEN: u32 = 5u;
const BLEND_COLOR_DODGE: u32 = 6u;
const BLEND_COLOR_BURN: u32 = 7u;
const BLEND_HARD_LIGHT: u32 = 8u;
const BLEND_SOFT_LIGHT: u32 = 9u;
const BLEND_DIFFERENCE: u32 = 10u;
const BLEND_EXCLUSION: u32 = 11u;
struct LayerUniforms {
// Source rectangle in layer texture (normalized 0-1)
source_rect: vec4<f32>, // x, y, width, height
// Destination rectangle in viewport (pixels)
dest_rect: vec4<f32>, // x, y, width, height
// Viewport size for coordinate conversion
viewport_size: vec2<f32>,
// Layer opacity (0.0 - 1.0)
opacity: f32,
// Blend mode (see constants above)
blend_mode: u32,
// Clip bounds (x, y, width, height) in pixels
clip_bounds: vec4<f32>,
// Clip corner radii (top-left, top-right, bottom-right, bottom-left)
clip_radius: vec4<f32>,
// Clip type: 0=none, 1=rect with optional rounded corners
clip_type: u32,
// 3D perspective transform (0 = disabled)
perspective_d: f32,
sin_rx: f32,
cos_rx: f32,
sin_ry: f32,
cos_ry: f32,
// Padding
_pad: vec2<f32>,
}
@group(0) @binding(0) var<uniform> uniforms: LayerUniforms;
@group(0) @binding(1) var layer_texture: texture_2d<f32>;
@group(0) @binding(2) var layer_sampler: sampler;
@group(0) @binding(3) var dest_texture: texture_2d<f32>;
@group(0) @binding(4) var dest_sampler: sampler;
struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) uv: vec2<f32>,
@location(1) frag_pos: vec2<f32>, // Fragment position in viewport pixels
}
// SDF for rounded rectangle clipping
fn sd_rounded_rect_clip(p: vec2<f32>, rect: vec4<f32>, radii: vec4<f32>) -> f32 {
// rect: x, y, width, height
// radii: top-left, top-right, bottom-right, bottom-left
let center = rect.xy + rect.zw * 0.5;
let half_size = rect.zw * 0.5;
let q = abs(p - center) - half_size;
// Select corner radius based on quadrant
var r: f32;
if (p.x < center.x) {
if (p.y < center.y) {
r = radii.x; // top-left
} else {
r = radii.w; // bottom-left
}
} else {
if (p.y < center.y) {
r = radii.y; // top-right
} else {
r = radii.z; // bottom-right
}
}
// Clamp radius to half the minimum dimension (CSS spec)
r = min(r, min(half_size.x, half_size.y));
let adjusted_q = q + r;
return length(max(adjusted_q, vec2<f32>(0.0))) + min(max(adjusted_q.x, adjusted_q.y), 0.0) - r;
}
// Full-screen quad vertices
@vertex
fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput {
// Generate quad vertices from vertex index (0-5 for two triangles)
var positions = array<vec2<f32>, 6>(
vec2<f32>(0.0, 0.0), // Top-left
vec2<f32>(1.0, 0.0), // Top-right
vec2<f32>(0.0, 1.0), // Bottom-left
vec2<f32>(1.0, 0.0), // Top-right
vec2<f32>(1.0, 1.0), // Bottom-right
vec2<f32>(0.0, 1.0), // Bottom-left
);
let local_pos = positions[vertex_index];
// Map to source rectangle UV
let uv = uniforms.source_rect.xy + local_pos * uniforms.source_rect.zw;
var out: VertexOutput;
if (uniforms.perspective_d > 0.0) {
// 3D perspective compositing: distort quad with perspective projection.
// The layer was rendered flat; we now apply rotate-x/rotate-y to the composite.
let center = uniforms.dest_rect.xy + uniforms.dest_rect.zw * 0.5;
let half = uniforms.dest_rect.zw * 0.5;
// Local position relative to center (in pixels)
var p = vec3<f32>(
(local_pos.x - 0.5) * 2.0 * half.x,
(local_pos.y - 0.5) * 2.0 * half.y,
0.0
);
// Rotate around Y axis
let ry_x = p.x * uniforms.cos_ry - p.z * uniforms.sin_ry;
let ry_z = p.x * uniforms.sin_ry + p.z * uniforms.cos_ry;
p.x = ry_x;
p.z = ry_z;
// Rotate around X axis
let rx_y = p.y * uniforms.cos_rx - p.z * uniforms.sin_rx;
let rx_z = p.y * uniforms.sin_rx + p.z * uniforms.cos_rx;
p.y = rx_y;
p.z = rx_z;
// Perspective projection
let d = uniforms.perspective_d;
let w = (d + p.z) / d;
let screen = center + p.xy / w;
// Convert to NDC
let ndc = (screen / uniforms.viewport_size) * 2.0 - 1.0;
// Output with perspective w for correct UV interpolation
out.position = vec4<f32>(ndc.x * w, -ndc.y * w, 0.0, w);
out.frag_pos = screen;
} else {
// Standard flat compositing (no perspective)
let dest_pos = uniforms.dest_rect.xy + local_pos * uniforms.dest_rect.zw;
let ndc = (dest_pos / uniforms.viewport_size) * 2.0 - 1.0;
out.position = vec4<f32>(ndc.x, -ndc.y, 0.0, 1.0);
out.frag_pos = dest_pos;
}
out.uv = uv;
return out;
}
// ============================================================================
// Blend Mode Functions
// ============================================================================
fn blend_normal(src: vec3<f32>, dst: vec3<f32>) -> vec3<f32> {
return src;
}
fn blend_multiply(src: vec3<f32>, dst: vec3<f32>) -> vec3<f32> {
return src * dst;
}
fn blend_screen(src: vec3<f32>, dst: vec3<f32>) -> vec3<f32> {
return 1.0 - (1.0 - src) * (1.0 - dst);
}
fn blend_overlay_channel(s: f32, d: f32) -> f32 {
if (d < 0.5) {
return 2.0 * s * d;
} else {
return 1.0 - 2.0 * (1.0 - s) * (1.0 - d);
}
}
fn blend_overlay(src: vec3<f32>, dst: vec3<f32>) -> vec3<f32> {
return vec3<f32>(
blend_overlay_channel(src.r, dst.r),
blend_overlay_channel(src.g, dst.g),
blend_overlay_channel(src.b, dst.b)
);
}
fn blend_darken(src: vec3<f32>, dst: vec3<f32>) -> vec3<f32> {
return min(src, dst);
}
fn blend_lighten(src: vec3<f32>, dst: vec3<f32>) -> vec3<f32> {
return max(src, dst);
}
fn blend_color_dodge_channel(s: f32, d: f32) -> f32 {
if (s >= 1.0) {
return 1.0;
}
return min(1.0, d / (1.0 - s));
}
fn blend_color_dodge(src: vec3<f32>, dst: vec3<f32>) -> vec3<f32> {
return vec3<f32>(
blend_color_dodge_channel(src.r, dst.r),
blend_color_dodge_channel(src.g, dst.g),
blend_color_dodge_channel(src.b, dst.b)
);
}
fn blend_color_burn_channel(s: f32, d: f32) -> f32 {
if (s <= 0.0) {
return 0.0;
}
return 1.0 - min(1.0, (1.0 - d) / s);
}
fn blend_color_burn(src: vec3<f32>, dst: vec3<f32>) -> vec3<f32> {
return vec3<f32>(
blend_color_burn_channel(src.r, dst.r),
blend_color_burn_channel(src.g, dst.g),
blend_color_burn_channel(src.b, dst.b)
);
}
fn blend_hard_light(src: vec3<f32>, dst: vec3<f32>) -> vec3<f32> {
// Hard light is overlay with src/dst swapped
return vec3<f32>(
blend_overlay_channel(dst.r, src.r),
blend_overlay_channel(dst.g, src.g),
blend_overlay_channel(dst.b, src.b)
);
}
fn blend_soft_light_channel(s: f32, d: f32) -> f32 {
if (s <= 0.5) {
return d - (1.0 - 2.0 * s) * d * (1.0 - d);
} else {
var g: f32;
if (d <= 0.25) {
g = ((16.0 * d - 12.0) * d + 4.0) * d;
} else {
g = sqrt(d);
}
return d + (2.0 * s - 1.0) * (g - d);
}
}
fn blend_soft_light(src: vec3<f32>, dst: vec3<f32>) -> vec3<f32> {
return vec3<f32>(
blend_soft_light_channel(src.r, dst.r),
blend_soft_light_channel(src.g, dst.g),
blend_soft_light_channel(src.b, dst.b)
);
}
fn blend_difference(src: vec3<f32>, dst: vec3<f32>) -> vec3<f32> {
return abs(src - dst);
}
fn blend_exclusion(src: vec3<f32>, dst: vec3<f32>) -> vec3<f32> {
return src + dst - 2.0 * src * dst;
}
// Apply blend mode to colors
fn apply_blend_mode(src: vec3<f32>, dst: vec3<f32>, mode: u32) -> vec3<f32> {
switch (mode) {
case BLEND_MULTIPLY: { return blend_multiply(src, dst); }
case BLEND_SCREEN: { return blend_screen(src, dst); }
case BLEND_OVERLAY: { return blend_overlay(src, dst); }
case BLEND_DARKEN: { return blend_darken(src, dst); }
case BLEND_LIGHTEN: { return blend_lighten(src, dst); }
case BLEND_COLOR_DODGE: { return blend_color_dodge(src, dst); }
case BLEND_COLOR_BURN: { return blend_color_burn(src, dst); }
case BLEND_HARD_LIGHT: { return blend_hard_light(src, dst); }
case BLEND_SOFT_LIGHT: { return blend_soft_light(src, dst); }
case BLEND_DIFFERENCE: { return blend_difference(src, dst); }
case BLEND_EXCLUSION: { return blend_exclusion(src, dst); }
default: { return blend_normal(src, dst); } // BLEND_NORMAL
}
}
// ============================================================================
// Fragment Shader
// ============================================================================
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
// Apply clip if enabled
if (uniforms.clip_type == 1u) {
let dist = sd_rounded_rect_clip(in.frag_pos, uniforms.clip_bounds, uniforms.clip_radius);
if (dist > 0.5) {
discard;
}
}
// Sample layer texture (LOD 0 — non-uniform flow from discard above)
let src = textureSampleLevel(layer_texture, layer_sampler, in.uv, 0.0);
// Apply opacity
var src_alpha = src.a * uniforms.opacity;
// Apply anti-aliased clip edge
if (uniforms.clip_type == 1u) {
let dist = sd_rounded_rect_clip(in.frag_pos, uniforms.clip_bounds, uniforms.clip_radius);
let clip_alpha = 1.0 - smoothstep(-0.5, 0.5, dist);
src_alpha *= clip_alpha;
}
// Early out for fully transparent pixels
if (src_alpha < 0.001) {
discard;
}
// For non-Normal blend modes, sample the destination texture (pre-copied snapshot)
// and apply the CSS blend function. The result is output as premultiplied alpha
// so hardware blending (src + dst * (1-srcA)) produces the correct composite:
// final = blended * srcA + dst * (1-srcA) = mix(dst, blended, srcA)
if (uniforms.blend_mode != BLEND_NORMAL) {
// Compute screen UV from fragment position
let screen_uv = in.frag_pos / uniforms.viewport_size;
let dst = textureSampleLevel(dest_texture, dest_sampler, screen_uv, 0.0);
// Unpremultiply source (src.a > 0 since src_alpha > 0.001 and opacity <= 1.0)
let src_c = src.rgb / src.a;
// Unpremultiply destination
let dst_c = select(dst.rgb / dst.a, vec3<f32>(0.0, 0.0, 0.0), dst.a < 0.001);
// Apply CSS blend mode
let blended = apply_blend_mode(src_c, dst_c, uniforms.blend_mode);
// Output premultiplied for hardware alpha compositing
return vec4<f32>(blended * src_alpha, src_alpha);
}
// Normal blend: premultiplied alpha for hardware blending
let premultiplied = vec4<f32>(src.rgb * src_alpha, src_alpha);
return premultiplied;
}
"#;
pub const BLUR_SHADER: &str = r#"
// ============================================================================
// Kawase Blur Shader (Layer Effects)
// ============================================================================
//
// Multi-pass blur using Kawase algorithm for efficient Gaussian approximation.
// Run multiple passes with increasing iteration values for stronger blur.
struct BlurUniforms {
// Inverse texture size (1/width, 1/height)
texel_size: vec2<f32>,
// Base blur radius
radius: f32,
// Current iteration (0, 1, 2, ...) - affects sample offset
iteration: u32,
// Whether to blur alpha (1) or preserve original alpha (0)
blur_alpha: u32,
// Padding for 16-byte alignment
_pad1: f32,
_pad2: f32,
_pad3: f32,
}
@group(0) @binding(0) var<uniform> uniforms: BlurUniforms;
@group(0) @binding(1) var input_texture: texture_2d<f32>;
@group(0) @binding(2) var input_sampler: sampler;
struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) uv: vec2<f32>,
}
// Full-screen quad vertices
@vertex
fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput {
var positions = 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),
);
var uvs = array<vec2<f32>, 6>(
vec2<f32>(0.0, 1.0),
vec2<f32>(1.0, 1.0),
vec2<f32>(0.0, 0.0),
vec2<f32>(1.0, 1.0),
vec2<f32>(1.0, 0.0),
vec2<f32>(0.0, 0.0),
);
var out: VertexOutput;
out.position = vec4<f32>(positions[vertex_index], 0.0, 1.0);
out.uv = uvs[vertex_index];
return out;
}
@fragment
fn fs_kawase_blur(in: VertexOutput) -> @location(0) vec4<f32> {
// Kawase blur: each pass samples at increasing offsets scaled by the radius
// More passes spread the blur further; radius controls per-pass spread
let base_offset = f32(uniforms.iteration) + 0.5;
let spread = max(uniforms.radius * 0.4, 1.0);
let offset = base_offset * spread;
let pixel_offset = offset * uniforms.texel_size;
// Sample in + pattern (up, down, left, right) instead of X pattern
let uv_up = clamp(in.uv + vec2<f32>(0.0, -pixel_offset.y), vec2<f32>(0.0), vec2<f32>(1.0));
let uv_down = clamp(in.uv + vec2<f32>(0.0, pixel_offset.y), vec2<f32>(0.0), vec2<f32>(1.0));
let uv_left = clamp(in.uv + vec2<f32>(-pixel_offset.x, 0.0), vec2<f32>(0.0), vec2<f32>(1.0));
let uv_right = clamp(in.uv + vec2<f32>(pixel_offset.x, 0.0), vec2<f32>(0.0), vec2<f32>(1.0));
// Sample 5 points in + pattern (center, up, down, left, right)
let s0 = textureSample(input_texture, input_sampler, in.uv);
let s1 = textureSample(input_texture, input_sampler, uv_up);
let s2 = textureSample(input_texture, input_sampler, uv_down);
let s3 = textureSample(input_texture, input_sampler, uv_left);
let s4 = textureSample(input_texture, input_sampler, uv_right);
if (uniforms.blur_alpha == 0u) {
// CSS filter blur mode: blur all RGBA for visible effect on solid-color elements.
// Alpha-weighted RGB averaging prevents dark fringing at transparent edges.
// The alpha-restore pass (mode 2) will fix corner softening after all blur passes.
let total_alpha = s0.a + s1.a + s2.a + s3.a + s4.a;
let avg_alpha = total_alpha / 5.0;
if (avg_alpha < 0.001) {
return vec4<f32>(0.0, 0.0, 0.0, 0.0);
}
let weighted_rgb = s0.rgb * s0.a + s1.rgb * s1.a + s2.rgb * s2.a + s3.rgb * s3.a + s4.rgb * s4.a;
let avg_rgb = weighted_rgb / total_alpha;
return vec4<f32>(avg_rgb, avg_alpha);
} else if (uniforms.blur_alpha == 1u) {
// Shadow blur mode: only blur alpha for shadow shape
// Output white RGB since drop shadow shader uses uniform color, not texture RGB
let total_alpha = s0.a + s1.a + s2.a + s3.a + s4.a;
let avg_alpha = total_alpha / 5.0;
return vec4<f32>(1.0, 1.0, 1.0, avg_alpha);
} else {
// Mode 2: passthrough — sample center pixel only (used for alpha restore pass)
return s0;
}
}
// Single-pass box blur for low quality mode
@fragment
fn fs_box_blur(in: VertexOutput) -> @location(0) vec4<f32> {
let radius = i32(uniforms.radius);
let center = textureSample(input_texture, input_sampler, in.uv);
if (uniforms.blur_alpha == 0u) {
// Element blur mode: preserve alpha, blur RGB with alpha weighting
var weighted_rgb = vec3<f32>(0.0);
var total_alpha = 0.0;
for (var x = -radius; x <= radius; x++) {
for (var y = -radius; y <= radius; y++) {
let offset = vec2<f32>(f32(x), f32(y)) * uniforms.texel_size;
let sample_uv = clamp(in.uv + offset, vec2<f32>(0.0), vec2<f32>(1.0));
let s = textureSample(input_texture, input_sampler, sample_uv);
weighted_rgb += s.rgb * s.a;
total_alpha += s.a;
}
}
if (total_alpha < 0.001) {
return vec4<f32>(0.0, 0.0, 0.0, 0.0);
}
let avg_rgb = weighted_rgb / total_alpha;
return vec4<f32>(avg_rgb, center.a);
} else {
// Shadow blur mode: only blur alpha for shadow shape
// Output white RGB since drop shadow shader uses uniform color, not texture RGB
var total_alpha = 0.0;
var samples = 0.0;
for (var x = -radius; x <= radius; x++) {
for (var y = -radius; y <= radius; y++) {
let offset = vec2<f32>(f32(x), f32(y)) * uniforms.texel_size;
let sample_uv = clamp(in.uv + offset, vec2<f32>(0.0), vec2<f32>(1.0));
let s = textureSample(input_texture, input_sampler, sample_uv);
total_alpha += s.a;
samples += 1.0;
}
}
let avg_alpha = total_alpha / samples;
return vec4<f32>(1.0, 1.0, 1.0, avg_alpha);
}
}
"#;
pub const COLOR_MATRIX_SHADER: &str = r#"
// ============================================================================
// Color Matrix Shader (Layer Effects)
// ============================================================================
//
// Applies a 4x5 color transformation matrix:
// [R'] [m0 m1 m2 m3 m4 ] [R]
// [G'] = [m5 m6 m7 m8 m9 ] * [G]
// [B'] [m10 m11 m12 m13 m14] [B]
// [A'] [m15 m16 m17 m18 m19] [A]
// [1]
struct ColorMatrixUniforms {
// 4x5 matrix stored as 5 vec4s (rows)
row0: vec4<f32>, // [m0, m1, m2, m3 ]
row1: vec4<f32>, // [m5, m6, m7, m8 ]
row2: vec4<f32>, // [m10, m11, m12, m13]
row3: vec4<f32>, // [m15, m16, m17, m18]
// Offset column (m4, m9, m14, m19)
offset: vec4<f32>,
}
@group(0) @binding(0) var<uniform> uniforms: ColorMatrixUniforms;
@group(0) @binding(1) var input_texture: texture_2d<f32>;
@group(0) @binding(2) var input_sampler: sampler;
struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) uv: vec2<f32>,
}
// Full-screen quad vertices
@vertex
fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput {
var positions = 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),
);
var uvs = array<vec2<f32>, 6>(
vec2<f32>(0.0, 1.0),
vec2<f32>(1.0, 1.0),
vec2<f32>(0.0, 0.0),
vec2<f32>(1.0, 1.0),
vec2<f32>(1.0, 0.0),
vec2<f32>(0.0, 0.0),
);
var out: VertexOutput;
out.position = vec4<f32>(positions[vertex_index], 0.0, 1.0);
out.uv = uvs[vertex_index];
return out;
}
@fragment
fn fs_color_matrix(in: VertexOutput) -> @location(0) vec4<f32> {
let src = textureSample(input_texture, input_sampler, in.uv);
// Apply 4x4 matrix multiplication + offset
var result: vec4<f32>;
result.r = dot(uniforms.row0, src) + uniforms.offset.r;
result.g = dot(uniforms.row1, src) + uniforms.offset.g;
result.b = dot(uniforms.row2, src) + uniforms.offset.b;
result.a = dot(uniforms.row3, src) + uniforms.offset.a;
// Clamp to valid range
return clamp(result, vec4<f32>(0.0), vec4<f32>(1.0));
}
"#;
pub const MASK_IMAGE_SHADER: &str = r#"
// ============================================================================
// Mask Image Shader (Layer Effects)
// ============================================================================
//
// Applies a mask image to a layer: output.a = input.a * mask_value
// mask_mode: 0 = alpha (use mask.a), 1 = luminance (use dot(mask.rgb, luma))
struct MaskUniforms {
// 0 = alpha, 1 = luminance
mask_mode: u32,
_pad: vec3<f32>,
}
@group(0) @binding(0) var<uniform> uniforms: MaskUniforms;
@group(0) @binding(1) var input_texture: texture_2d<f32>;
@group(0) @binding(2) var input_sampler: sampler;
@group(0) @binding(3) var mask_texture: texture_2d<f32>;
@group(0) @binding(4) var mask_sampler: sampler;
struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) uv: vec2<f32>,
}
// Full-screen quad vertices
@vertex
fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput {
var positions = 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),
);
var uvs = array<vec2<f32>, 6>(
vec2<f32>(0.0, 1.0),
vec2<f32>(1.0, 1.0),
vec2<f32>(0.0, 0.0),
vec2<f32>(1.0, 1.0),
vec2<f32>(1.0, 0.0),
vec2<f32>(0.0, 0.0),
);
var out: VertexOutput;
out.position = vec4<f32>(positions[vertex_index], 0.0, 1.0);
out.uv = uvs[vertex_index];
return out;
}
@fragment
fn fs_mask(in: VertexOutput) -> @location(0) vec4<f32> {
let src = textureSample(input_texture, input_sampler, in.uv);
let mask = textureSample(mask_texture, mask_sampler, in.uv);
// Compute mask value based on mode
var mask_alpha: f32;
if (uniforms.mask_mode == 1u) {
// Luminance mode: use weighted RGB as alpha
mask_alpha = dot(mask.rgb, vec3<f32>(0.2126, 0.7152, 0.0722)) * mask.a;
} else {
// Alpha mode: use mask alpha channel directly
mask_alpha = mask.a;
}
// Multiply source by mask value (premultiplied alpha)
return vec4<f32>(src.rgb * mask_alpha, src.a * mask_alpha);
}
"#;
pub const DROP_SHADOW_SHADER: &str = r#"
// ============================================================================
// Shadow Colorize Shader (Layer Effects)
// ============================================================================
//
// Takes a pre-blurred texture and:
// 1. Samples the blurred alpha at offset position for shadow shape
// 2. Colorizes with shadow color
// 3. Composites shadow behind original content
//
// This shader expects the input to already be blurred using Kawase blur passes.
struct DropShadowUniforms {
// Shadow offset in pixels
offset: vec2<f32>,
// Blur radius (stored but not used - blur is pre-applied)
blur_radius: f32,
// Spread (expand/contract)
spread: f32,
// Shadow color (RGBA)
color: vec4<f32>,
// Texture size for offset calculation
texel_size: vec2<f32>,
// Padding
_pad: vec2<f32>,
}
@group(0) @binding(0) var<uniform> uniforms: DropShadowUniforms;
@group(0) @binding(1) var input_texture: texture_2d<f32>;
@group(0) @binding(2) var input_sampler: sampler;
// Original (unblurred) texture for compositing
@group(0) @binding(3) var original_texture: texture_2d<f32>;
struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) uv: vec2<f32>,
}
// Full-screen quad vertices
@vertex
fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput {
var positions = 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),
);
var uvs = array<vec2<f32>, 6>(
vec2<f32>(0.0, 1.0),
vec2<f32>(1.0, 1.0),
vec2<f32>(0.0, 0.0),
vec2<f32>(1.0, 1.0),
vec2<f32>(1.0, 0.0),
vec2<f32>(0.0, 0.0),
);
var out: VertexOutput;
out.position = vec4<f32>(positions[vertex_index], 0.0, 1.0);
out.uv = uvs[vertex_index];
return out;
}
// Calculate minimum distance to an opaque pixel by sampling in a pattern
// This preserves the shape (including rounded corners) unlike blur-based approaches
fn sample_min_distance(uv: vec2<f32>, radius: f32, texel_size: vec2<f32>) -> f32 {
// Check center first - if opaque, distance is 0
let center = textureSampleLevel(original_texture, input_sampler, uv, 0.0);
if (center.a > 0.5) {
return 0.0;
}
// Sample in concentric rings to find nearest opaque pixel
// Start with small radius and expand - this gives good quality with fewer samples
var min_dist = radius + 1.0;
// Sample at multiple distances and angles
// Balanced for performance (8x8 = 64 samples max, with early exit)
let num_angles = 8;
let num_rings = 8;
for (var ring = 1; ring <= num_rings; ring++) {
let dist = (f32(ring) / f32(num_rings)) * radius;
let pixel_dist = dist;
for (var i = 0; i < num_angles; i++) {
let angle = f32(i) * 6.28318530718 / f32(num_angles);
let offset = vec2<f32>(cos(angle), sin(angle)) * pixel_dist * texel_size;
let sample_uv = clamp(uv + offset, vec2<f32>(0.0), vec2<f32>(1.0));
let s = textureSampleLevel(original_texture, input_sampler, sample_uv, 0.0);
if (s.a > 0.5) {
min_dist = min(min_dist, dist);
}
}
// Early exit if we found an opaque pixel in this ring
if (min_dist <= dist) {
break;
}
}
return min_dist;
}
@fragment
fn fs_drop_shadow(in: VertexOutput) -> @location(0) vec4<f32> {
// Calculate shadow UV with offset
let shadow_uv = clamp(in.uv - uniforms.offset * uniforms.texel_size, vec2<f32>(0.0), vec2<f32>(1.0));
// Find minimum distance to the original shape
let dist = sample_min_distance(shadow_uv, uniforms.blur_radius, uniforms.texel_size);
// Convert distance to alpha using smooth falloff
// At distance 0, alpha = 1. At distance = blur_radius, alpha ≈ 0
var alpha = 1.0 - smoothstep(0.0, uniforms.blur_radius, dist);
// Apply spread (expand/contract the shape)
if (uniforms.spread != 0.0) {
// Positive spread = larger shadow, negative = smaller
let adjusted_dist = dist - uniforms.spread;
alpha = 1.0 - smoothstep(0.0, uniforms.blur_radius, max(adjusted_dist, 0.0));
}
// Shadow color with computed alpha
let shadow_a = uniforms.color.a * alpha;
let shadow_rgb = uniforms.color.rgb;
// Sample original (unblurred) content at current position
let original = textureSampleLevel(original_texture, input_sampler, in.uv, 0.0);
// Composite shadow behind original using porter-duff "over" for non-premultiplied colors
let result_a = original.a + shadow_a * (1.0 - original.a);
if (result_a < 0.001) {
return vec4<f32>(0.0, 0.0, 0.0, 0.0);
}
let result_rgb = (original.rgb * original.a + shadow_rgb * shadow_a * (1.0 - original.a)) / result_a;
return vec4<f32>(result_rgb, result_a);
}
"#;
pub const GLOW_SHADER: &str = r#"
// ============================================================================
// Glow Effect Shader (Layer Effects)
// ============================================================================
//
// Creates an outer glow around shapes by:
// 1. Sampling to find distance to nearest opaque pixel
// 2. Applying Gaussian-like falloff from the shape edge
// 3. Compositing glow behind the original content
struct GlowUniforms {
// Glow color (RGBA)
color: vec4<f32>,
// Blur softness (affects falloff smoothness)
blur: f32,
// Glow range (how far the glow extends)
range: f32,
// Glow opacity (0-1)
opacity: f32,
// Padding for alignment
_pad0: f32,
// Texture size for distance calculation
texel_size: vec2<f32>,
// Padding
_pad1: vec2<f32>,
}
@group(0) @binding(0) var<uniform> uniforms: GlowUniforms;
@group(0) @binding(1) var source_texture: texture_2d<f32>;
@group(0) @binding(2) var source_sampler: sampler;
struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) uv: vec2<f32>,
}
// Full-screen quad vertices
@vertex
fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput {
var positions = 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),
);
var uvs = array<vec2<f32>, 6>(
vec2<f32>(0.0, 1.0),
vec2<f32>(1.0, 1.0),
vec2<f32>(0.0, 0.0),
vec2<f32>(1.0, 1.0),
vec2<f32>(1.0, 0.0),
vec2<f32>(0.0, 0.0),
);
var out: VertexOutput;
out.position = vec4<f32>(positions[vertex_index], 0.0, 1.0);
out.uv = uvs[vertex_index];
return out;
}
// Find minimum distance to an opaque pixel within search_radius
fn find_edge_distance(uv: vec2<f32>, search_radius: f32, texel_size: vec2<f32>) -> f32 {
// Check center first - if opaque, distance is 0
let center = textureSampleLevel(source_texture, source_sampler, uv, 0.0);
if (center.a > 0.5) {
return 0.0;
}
// Sample in concentric rings to find nearest opaque pixel
var min_dist = search_radius + 1.0; // Start with "not found" value
// Sample at multiple distances and angles
// Balanced for performance (8x8 = 64 samples max, with early exit)
let num_angles = 8;
let num_rings = 8;
for (var ring = 1; ring <= num_rings; ring++) {
let dist = (f32(ring) / f32(num_rings)) * search_radius;
let pixel_dist = dist;
for (var i = 0; i < num_angles; i++) {
let angle = f32(i) * 6.28318530718 / f32(num_angles);
let offset = vec2<f32>(cos(angle), sin(angle)) * pixel_dist * texel_size;
let sample_uv = clamp(uv + offset, vec2<f32>(0.0), vec2<f32>(1.0));
let s = textureSampleLevel(source_texture, source_sampler, sample_uv, 0.0);
if (s.a > 0.5) {
min_dist = min(min_dist, dist);
}
}
// Early exit if we found an opaque pixel in this ring
if (min_dist <= dist) {
break;
}
}
return min_dist;
}
@fragment
fn fs_glow(in: VertexOutput) -> @location(0) vec4<f32> {
// Total search distance = blur + range
let search_radius = uniforms.blur + uniforms.range;
// Find distance to nearest opaque pixel
let dist = find_edge_distance(in.uv, search_radius, uniforms.texel_size);
// Calculate glow alpha with Gaussian-like falloff
// - At distance 0: we're inside the shape, no glow needed (original shows)
// - At distance <= range: full glow intensity
// - At distance > range: fade out over 'blur' distance
var glow_alpha = 0.0;
if (dist > 0.0 && dist <= search_radius) {
// Distance from the extended glow edge
// If dist <= range, we're in the "full glow" zone
// If dist > range, we're in the "fade" zone
if (dist <= uniforms.range) {
// Inside the glow range - full intensity
glow_alpha = 1.0;
} else {
// Fade zone: distance beyond range, fading over 'blur' distance
let fade_dist = dist - uniforms.range;
// Smooth Gaussian-like falloff
let sigma = uniforms.blur * 0.5;
glow_alpha = exp(-(fade_dist * fade_dist) / (2.0 * sigma * sigma));
}
}
// Apply opacity
glow_alpha *= uniforms.opacity * uniforms.color.a;
// Sample original content
let original = textureSampleLevel(source_texture, source_sampler, in.uv, 0.0);
// Glow color (premultiplied)
let glow_rgb = uniforms.color.rgb;
// Composite glow behind original using porter-duff "over"
// Result = Original + Glow * (1 - Original.a)
let result_a = original.a + glow_alpha * (1.0 - original.a);
if (result_a < 0.001) {
return vec4<f32>(0.0, 0.0, 0.0, 0.0);
}
let result_rgb = (original.rgb * original.a + glow_rgb * glow_alpha * (1.0 - original.a)) / result_a;
return vec4<f32>(result_rgb, result_a);
}
"#;