842 lines
34 KiB
WebGPU Shading Language
842 lines
34 KiB
WebGPU Shading Language
// Glyph rendering shader for terminal emulator
|
|
// Supports both legacy quad-based rendering and new instanced cell rendering
|
|
// Uses Kitty-style "legacy" gamma-incorrect text blending for crisp rendering
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
// GAMMA CONVERSION FUNCTIONS (for legacy text rendering)
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
// Luminance weights for perceived brightness (ITU-R BT.709)
|
|
const Y: vec3<f32> = vec3<f32>(0.2126, 0.7152, 0.0722);
|
|
|
|
// Convert linear RGB to sRGB
|
|
fn linear2srgb(x: f32) -> f32 {
|
|
if x <= 0.0031308 {
|
|
return 12.92 * x;
|
|
} else {
|
|
return 1.055 * pow(x, 1.0 / 2.4) - 0.055;
|
|
}
|
|
}
|
|
|
|
// Convert sRGB to linear RGB
|
|
fn srgb2linear(x: f32) -> f32 {
|
|
if x <= 0.04045 {
|
|
return x / 12.92;
|
|
} else {
|
|
return pow((x + 0.055) / 1.055, 2.4);
|
|
}
|
|
}
|
|
|
|
// Kitty's legacy gamma-incorrect text blending
|
|
// This simulates how text was blended before gamma-correct rendering became standard.
|
|
// It makes dark text on light backgrounds appear thicker and light text on dark
|
|
// backgrounds appear thinner, which many users prefer for readability.
|
|
//
|
|
// The input colors are in sRGB space. We convert to linear for the luminance
|
|
// calculation, then simulate gamma-incorrect blending.
|
|
fn foreground_contrast_legacy(over_srgb: vec3<f32>, over_alpha: f32, under_srgb: vec3<f32>) -> f32 {
|
|
// Convert sRGB colors to linear for luminance calculation
|
|
let over_linear = vec3<f32>(srgb2linear(over_srgb.r), srgb2linear(over_srgb.g), srgb2linear(over_srgb.b));
|
|
let under_linear = vec3<f32>(srgb2linear(under_srgb.r), srgb2linear(under_srgb.g), srgb2linear(under_srgb.b));
|
|
|
|
let under_luminance = dot(under_linear, Y);
|
|
let over_luminance = dot(over_linear, Y);
|
|
|
|
// Avoid division by zero when luminances are equal
|
|
let luminance_diff = over_luminance - under_luminance;
|
|
if abs(luminance_diff) < 0.001 {
|
|
return over_alpha;
|
|
}
|
|
|
|
// Kitty's formula: simulate gamma-incorrect blending
|
|
// This is the solution to:
|
|
// linear2srgb(over * alpha2 + under * (1 - alpha2)) = linear2srgb(over) * alpha + linear2srgb(under) * (1 - alpha)
|
|
// ^ gamma correct blending with new alpha ^ gamma incorrect blending with old alpha
|
|
let blended_srgb = linear2srgb(over_luminance) * over_alpha + linear2srgb(under_luminance) * (1.0 - over_alpha);
|
|
let blended_linear = srgb2linear(blended_srgb);
|
|
let new_alpha = (blended_linear - under_luminance) / luminance_diff;
|
|
|
|
return clamp(new_alpha, 0.0, 1.0);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
// LEGACY QUAD-BASED RENDERING (for backwards compatibility)
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
struct VertexInput {
|
|
@location(0) position: vec2<f32>,
|
|
@location(1) uv: vec2<f32>,
|
|
@location(2) color: vec4<f32>,
|
|
@location(3) bg_color: vec4<f32>,
|
|
}
|
|
|
|
struct VertexOutput {
|
|
@builtin(position) clip_position: vec4<f32>,
|
|
@location(0) uv: vec2<f32>,
|
|
@location(1) color: vec4<f32>,
|
|
@location(2) bg_color: vec4<f32>,
|
|
}
|
|
|
|
@vertex
|
|
fn vs_main(in: VertexInput) -> VertexOutput {
|
|
var out: VertexOutput;
|
|
out.clip_position = vec4<f32>(in.position, 0.0, 1.0);
|
|
out.uv = in.uv;
|
|
out.color = in.color;
|
|
out.bg_color = in.bg_color;
|
|
return out;
|
|
}
|
|
|
|
@group(0) @binding(0)
|
|
var atlas_textures: binding_array<texture_2d<f32>>;
|
|
@group(0) @binding(1)
|
|
var atlas_sampler: sampler;
|
|
|
|
@fragment
|
|
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
|
|
// If UV is at origin (0,0), this is a background-only quad
|
|
let is_background_only = in.uv.x == 0.0 && in.uv.y == 0.0;
|
|
|
|
if is_background_only {
|
|
// Just render the background color (fully opaque)
|
|
return in.bg_color;
|
|
}
|
|
|
|
// Sample from RGBA atlas (layer 0 for legacy rendering)
|
|
let glyph_sample = textureSample(atlas_textures[0], atlas_sampler, in.uv);
|
|
|
|
// Detect color glyphs: regular glyphs are stored as white (1,1,1) with alpha
|
|
// Color glyphs have actual RGB colors. Check if any RGB channel is not white.
|
|
let is_color_glyph = glyph_sample.r < 0.99 || glyph_sample.g < 0.99 || glyph_sample.b < 0.99;
|
|
|
|
if is_color_glyph {
|
|
// Color glyph (emoji) - use atlas color directly
|
|
return glyph_sample;
|
|
}
|
|
|
|
// Regular glyph - use alpha with foreground color
|
|
let glyph_alpha = glyph_sample.a;
|
|
|
|
// Apply legacy gamma-incorrect blending for crisp text
|
|
let adjusted_alpha = foreground_contrast_legacy(in.color.rgb, glyph_alpha, in.bg_color.rgb);
|
|
|
|
// Output foreground color with adjusted alpha for blending
|
|
return vec4<f32>(in.color.rgb, in.color.a * adjusted_alpha);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
// KITTY-STYLE INSTANCED CELL RENDERING
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
// Color table uniform containing 256 indexed colors + default fg/bg
|
|
struct ColorTable {
|
|
// 256 indexed colors + default_fg (256) + default_bg (257)
|
|
colors: array<vec4<f32>, 258>,
|
|
}
|
|
|
|
// Grid parameters uniform
|
|
// Uses Kitty-style NDC positioning: the viewport is set per-pane, so the shader
|
|
// works in pure NDC space (-1 to +1) without needing pixel offsets.
|
|
// Cell dimensions are integers like Kitty for pixel-perfect rendering.
|
|
struct GridParams {
|
|
// Grid dimensions in cells
|
|
cols: u32,
|
|
rows: u32,
|
|
// Cell dimensions in pixels (integers like Kitty)
|
|
cell_width: u32,
|
|
cell_height: u32,
|
|
// Cursor position (-1 if hidden)
|
|
cursor_col: i32,
|
|
cursor_row: i32,
|
|
// Cursor style: 0=block, 1=underline, 2=bar
|
|
cursor_style: u32,
|
|
// Background opacity for transparency (0.0 = transparent, 1.0 = opaque)
|
|
background_opacity: f32,
|
|
// Selection range (-1 values mean no selection)
|
|
selection_start_col: i32,
|
|
selection_start_row: i32,
|
|
selection_end_col: i32,
|
|
selection_end_row: i32,
|
|
selection_row_max_col: array<i32, 256>,
|
|
}
|
|
|
|
// GPUCell instance data (matches Rust GPUCell struct)
|
|
struct GPUCell {
|
|
fg: u32,
|
|
bg: u32,
|
|
decoration_fg: u32,
|
|
sprite_idx: u32,
|
|
attrs: u32,
|
|
}
|
|
|
|
// Sprite info for glyph positioning
|
|
// In Kitty's model, sprites are always cell-sized and glyphs are pre-positioned
|
|
// within the sprite at the correct baseline. No offset math needed.
|
|
struct SpriteInfo {
|
|
// UV coordinates in atlas (x, y, width, height) - normalized 0-1
|
|
uv: vec4<f32>,
|
|
// Atlas layer index (z-coordinate for texture array)
|
|
layer: f32,
|
|
// Padding for alignment
|
|
_padding: f32,
|
|
// Size in pixels (width, height) - always matches cell dimensions
|
|
size: vec2<f32>,
|
|
}
|
|
|
|
// Uniforms and storage buffers for instanced rendering
|
|
@group(1) @binding(0)
|
|
var<uniform> color_table: ColorTable;
|
|
|
|
@group(1) @binding(1)
|
|
var<storage, read> grid_params: GridParams;
|
|
|
|
@group(1) @binding(2)
|
|
var<storage, read> cells: array<GPUCell>;
|
|
|
|
@group(1) @binding(3)
|
|
var<storage, read> sprites: array<SpriteInfo>;
|
|
|
|
// Constants for packed color decoding
|
|
const COLOR_TYPE_DEFAULT: u32 = 0u;
|
|
const COLOR_TYPE_INDEXED: u32 = 1u;
|
|
const COLOR_TYPE_RGB: u32 = 2u;
|
|
|
|
// Constants for cell attributes
|
|
const ATTR_DECORATION_MASK: u32 = 0x7u;
|
|
const ATTR_BOLD_BIT: u32 = 0x8u;
|
|
const ATTR_ITALIC_BIT: u32 = 0x10u;
|
|
const ATTR_REVERSE_BIT: u32 = 0x20u;
|
|
const ATTR_STRIKE_BIT: u32 = 0x40u;
|
|
const ATTR_DIM_BIT: u32 = 0x80u;
|
|
const ATTR_SELECTED_BIT: u32 = 0x100u;
|
|
|
|
// Colored glyph flag
|
|
const COLORED_GLYPH_FLAG: u32 = 0x80000000u;
|
|
|
|
// Cursor shape constants (matches terminal cursor_style values)
|
|
const CURSOR_BLOCK: u32 = 0u;
|
|
const CURSOR_UNDERLINE: u32 = 1u;
|
|
const CURSOR_BAR: u32 = 2u;
|
|
|
|
// Pre-rendered cursor sprite indices (must match Rust CURSOR_SPRITE_* constants)
|
|
// These sprites are created at fixed indices in the sprite array.
|
|
const CURSOR_SPRITE_BEAM: u32 = 1u; // Bar/beam cursor (vertical line on left)
|
|
const CURSOR_SPRITE_UNDERLINE: u32 = 2u; // Underline cursor (horizontal line at bottom)
|
|
const CURSOR_SPRITE_HOLLOW: u32 = 3u; // Hollow/unfocused cursor (outline rectangle)
|
|
|
|
// Pre-rendered decoration sprite indices (must match Rust DECORATION_SPRITE_* constants)
|
|
const DECORATION_SPRITE_STRIKETHROUGH: u32 = 4u; // Strikethrough line
|
|
const DECORATION_SPRITE_UNDERLINE: u32 = 5u; // Single underline
|
|
const DECORATION_SPRITE_DOUBLE_UNDERLINE: u32 = 6u; // Double underline
|
|
const DECORATION_SPRITE_UNDERCURL: u32 = 7u; // Wavy/curly underline
|
|
const DECORATION_SPRITE_DOTTED: u32 = 8u; // Dotted underline
|
|
const DECORATION_SPRITE_DASHED: u32 = 9u; // Dashed underline
|
|
|
|
// Decoration type values from ATTR_DECORATION_MASK (lower 3 bits of attrs)
|
|
const DECORATION_NONE: u32 = 0u;
|
|
const DECORATION_SINGLE: u32 = 1u;
|
|
const DECORATION_DOUBLE: u32 = 2u;
|
|
const DECORATION_CURLY: u32 = 3u;
|
|
const DECORATION_DOTTED: u32 = 4u;
|
|
const DECORATION_DASHED: u32 = 5u;
|
|
|
|
// Map cursor style to cursor sprite index
|
|
fn cursor_style_to_sprite(style: u32) -> u32 {
|
|
if style == CURSOR_BAR {
|
|
return CURSOR_SPRITE_BEAM;
|
|
} else if style == CURSOR_UNDERLINE {
|
|
return CURSOR_SPRITE_UNDERLINE;
|
|
} else {
|
|
// CURSOR_BLOCK uses solid fill, not a sprite
|
|
return 0u;
|
|
}
|
|
}
|
|
|
|
// Map decoration type (from attrs lower 3 bits) to underline sprite index
|
|
// Returns 0 if no underline decoration
|
|
fn decoration_type_to_sprite(decoration_type: u32) -> u32 {
|
|
if decoration_type == DECORATION_SINGLE {
|
|
return DECORATION_SPRITE_UNDERLINE;
|
|
} else if decoration_type == DECORATION_DOUBLE {
|
|
return DECORATION_SPRITE_DOUBLE_UNDERLINE;
|
|
} else if decoration_type == DECORATION_CURLY {
|
|
return DECORATION_SPRITE_UNDERCURL;
|
|
} else if decoration_type == DECORATION_DOTTED {
|
|
return DECORATION_SPRITE_DOTTED;
|
|
} else if decoration_type == DECORATION_DASHED {
|
|
return DECORATION_SPRITE_DASHED;
|
|
} else {
|
|
return 0u; // DECORATION_NONE
|
|
}
|
|
}
|
|
|
|
// Check if a cell is within the selection range
|
|
// Selection is specified as (start_col, start_row) to (end_col, end_row), normalized
|
|
// so start <= end in reading order
|
|
fn is_cell_selected(col: u32, row: u32) -> bool {
|
|
// Check if selection is active (-1 values mean no selection)
|
|
if grid_params.selection_start_col < 0 || grid_params.selection_start_row < 0 {
|
|
return false;
|
|
}
|
|
|
|
// Only highlight cells that have content in them or to their right on this row
|
|
if grid_params.selection_row_max_col[row] < 0 || col > u32(grid_params.selection_row_max_col[row]) {
|
|
return false;
|
|
}
|
|
|
|
let sel_start_col = u32(grid_params.selection_start_col);
|
|
let sel_start_row = u32(grid_params.selection_start_row);
|
|
let sel_end_col = u32(grid_params.selection_end_col);
|
|
let sel_end_row = u32(grid_params.selection_end_row);
|
|
|
|
// Check if cell is within row range
|
|
if row < sel_start_row || row > sel_end_row {
|
|
return false;
|
|
}
|
|
|
|
// Single row selection
|
|
if sel_start_row == sel_end_row {
|
|
return col >= sel_start_col && col <= sel_end_col;
|
|
}
|
|
|
|
// Multi-row selection
|
|
if row == sel_start_row {
|
|
// First row: from start_col to end of line
|
|
return col >= sel_start_col;
|
|
} else if row == sel_end_row {
|
|
// Last row: from start of line to end_col
|
|
return col <= sel_end_col;
|
|
} else {
|
|
// Middle rows: entire row is selected
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Vertex output for instanced cell rendering
|
|
struct CellVertexOutput {
|
|
@builtin(position) clip_position: vec4<f32>,
|
|
@location(0) uv: vec2<f32>,
|
|
@location(1) fg_color: vec4<f32>,
|
|
@location(2) bg_color: vec4<f32>,
|
|
@location(3) @interpolate(flat) is_background: u32,
|
|
@location(4) @interpolate(flat) is_colored_glyph: u32,
|
|
@location(5) @interpolate(flat) is_cursor: u32,
|
|
@location(6) @interpolate(flat) cursor_shape: u32,
|
|
@location(7) cursor_color: vec4<f32>,
|
|
@location(8) cursor_uv: vec2<f32>, // UV coordinates for cursor sprite (interpolated)
|
|
@location(9) @interpolate(flat) cell_size: vec2<f32>, // Cell width/height in pixels
|
|
@location(10) underline_uv: vec2<f32>, // UV for underline sprite
|
|
@location(11) strike_uv: vec2<f32>, // UV for strikethrough sprite
|
|
@location(12) @interpolate(flat) decoration_fg: vec4<f32>, // Decoration color
|
|
@location(13) @interpolate(flat) has_underline: u32, // Underline sprite index (0 = none)
|
|
@location(14) @interpolate(flat) has_strikethrough: u32, // 1 if strikethrough, 0 otherwise
|
|
// Atlas layer indices for texture array sampling
|
|
@location(15) @interpolate(flat) glyph_layer: i32, // Layer for main glyph sprite
|
|
@location(16) @interpolate(flat) cursor_layer: i32, // Layer for cursor sprite
|
|
@location(17) @interpolate(flat) underline_layer: i32, // Layer for underline decoration
|
|
@location(18) @interpolate(flat) strike_layer: i32, // Layer for strikethrough decoration
|
|
}
|
|
|
|
// Resolve a packed color to RGBA (in linear space for GPU rendering)
|
|
fn resolve_color(packed: u32, is_foreground: bool) -> vec4<f32> {
|
|
let color_type = packed & 0xFFu;
|
|
|
|
if color_type == COLOR_TYPE_DEFAULT {
|
|
// Default color - use color table entry 256 (fg) or 257 (bg)
|
|
// Color table is already in linear space
|
|
if is_foreground {
|
|
return color_table.colors[256];
|
|
} else {
|
|
return color_table.colors[257];
|
|
}
|
|
} else if color_type == COLOR_TYPE_INDEXED {
|
|
// Indexed color - look up in color table
|
|
// Color table is already in linear space
|
|
let index = (packed >> 8u) & 0xFFu;
|
|
return color_table.colors[index];
|
|
} else {
|
|
// RGB color - extract components and convert sRGB to linear
|
|
let r = f32((packed >> 8u) & 0xFFu) / 255.0;
|
|
let g = f32((packed >> 16u) & 0xFFu) / 255.0;
|
|
let b = f32((packed >> 24u) & 0xFFu) / 255.0;
|
|
return vec4<f32>(srgb_to_linear(r), srgb_to_linear(g), srgb_to_linear(b), 1.0);
|
|
}
|
|
}
|
|
|
|
// Convert sRGB to linear (for GPU rendering to sRGB surface)
|
|
fn srgb_to_linear(c: f32) -> f32 {
|
|
if c <= 0.04045 {
|
|
return c / 12.92;
|
|
} else {
|
|
return pow((c + 0.055) / 1.055, 2.4);
|
|
}
|
|
}
|
|
|
|
// Background vertex shader (renders cell backgrounds)
|
|
// vertex_index: 0-3 for quad corners
|
|
// instance_index: cell index in row-major order
|
|
//
|
|
// Uses Kitty-style NDC positioning: the viewport is set per-pane, so we work
|
|
// directly in NDC space (-1 to +1). This avoids floating-point precision issues
|
|
// that cause wobbly/misaligned text when cell dimensions aren't integer pixels.
|
|
@vertex
|
|
fn vs_cell_bg(
|
|
@builtin(vertex_index) vertex_index: u32,
|
|
@builtin(instance_index) instance_index: u32
|
|
) -> CellVertexOutput {
|
|
let col = instance_index % grid_params.cols;
|
|
let row = instance_index / grid_params.cols;
|
|
|
|
// Skip if out of bounds - place vertex outside clip volume (z=2 is beyond far plane)
|
|
if row >= grid_params.rows {
|
|
var out: CellVertexOutput;
|
|
out.clip_position = vec4<f32>(0.0, 0.0, 2.0, 1.0);
|
|
out.uv = vec2<f32>(0.0, 0.0);
|
|
out.fg_color = vec4<f32>(0.0, 0.0, 0.0, 0.0);
|
|
out.bg_color = vec4<f32>(0.0, 0.0, 0.0, 0.0);
|
|
out.is_background = 1u;
|
|
out.is_colored_glyph = 0u;
|
|
out.is_cursor = 0u;
|
|
out.cursor_shape = 0u;
|
|
out.cursor_color = vec4<f32>(0.0, 0.0, 0.0, 0.0);
|
|
out.cursor_uv = vec2<f32>(0.0, 0.0);
|
|
out.cell_size = vec2<f32>(0.0, 0.0);
|
|
out.underline_uv = vec2<f32>(0.0, 0.0);
|
|
out.strike_uv = vec2<f32>(0.0, 0.0);
|
|
out.decoration_fg = vec4<f32>(0.0, 0.0, 0.0, 0.0);
|
|
out.has_underline = 0u;
|
|
out.has_strikethrough = 0u;
|
|
out.glyph_layer = 0;
|
|
out.cursor_layer = 0;
|
|
out.underline_layer = 0;
|
|
out.strike_layer = 0;
|
|
return out;
|
|
}
|
|
|
|
// Get cell data
|
|
let cell = cells[instance_index];
|
|
|
|
// Kitty-style NDC positioning: calculate cell size in NDC space
|
|
// NDC ranges from -1 to +1, so total range is 2.0
|
|
let dx = 2.0 / f32(grid_params.cols);
|
|
let dy = 2.0 / f32(grid_params.rows);
|
|
|
|
// Calculate cell position in NDC (origin at top-left: -1, +1)
|
|
let left = -1.0 + f32(col) * dx;
|
|
let top = 1.0 - f32(row) * dy;
|
|
|
|
// Quad vertex positions for TriangleStrip (0=top-left, 1=top-right, 2=bottom-left, 3=bottom-right)
|
|
// TriangleStrip produces triangles: (0,1,2) and (1,2,3)
|
|
var ndc_positions: array<vec2<f32>, 4>;
|
|
ndc_positions[0] = vec2<f32>(left, top); // top-left
|
|
ndc_positions[1] = vec2<f32>(left + dx, top); // top-right
|
|
ndc_positions[2] = vec2<f32>(left, top - dy); // bottom-left
|
|
ndc_positions[3] = vec2<f32>(left + dx, top - dy); // bottom-right
|
|
|
|
let ndc_pos = ndc_positions[vertex_index];
|
|
|
|
// Convert integer cell dimensions to float for calculations
|
|
let cell_width_f = f32(grid_params.cell_width);
|
|
let cell_height_f = f32(grid_params.cell_height);
|
|
|
|
// Resolve colors
|
|
let attrs = cell.attrs;
|
|
let is_reverse = (attrs & ATTR_REVERSE_BIT) != 0u;
|
|
|
|
var fg = resolve_color(cell.fg, true);
|
|
var bg = resolve_color(cell.bg, false);
|
|
|
|
// Handle reverse video
|
|
if is_reverse {
|
|
let tmp = fg;
|
|
fg = bg;
|
|
bg = tmp;
|
|
}
|
|
|
|
// Check if this cell is selected using GridParams selection range
|
|
let is_selected = is_cell_selected(col, row);
|
|
if is_selected {
|
|
fg = vec4<f32>(0.0, 0.0, 0.0, 1.0); // Black foreground
|
|
bg = vec4<f32>(1.0, 1.0, 1.0, 1.0); // White background
|
|
}
|
|
|
|
// Check if this cell is the cursor
|
|
let is_cursor_cell = (i32(col) == grid_params.cursor_col) && (i32(row) == grid_params.cursor_row);
|
|
|
|
// For default background (type 0), use fully transparent so the window's
|
|
// clear color (which has background_opacity applied) shows through.
|
|
// UNLESS the grid params specify an opaque background (e.g. alternate screen).
|
|
// Only non-default backgrounds should be opaque.
|
|
// But NOT if the cell is selected (selection always has white bg)
|
|
let bg_type = cell.bg & 0xFFu;
|
|
if bg_type == COLOR_TYPE_DEFAULT && !is_reverse && !is_selected {
|
|
if grid_params.background_opacity < 1.0 {
|
|
bg.a = 0.0;
|
|
} else {
|
|
bg.a = 1.0;
|
|
}
|
|
}
|
|
|
|
// Calculate cursor color
|
|
// If the cell is empty (no glyph), use default foreground color for cursor
|
|
// Otherwise use the cell's foreground color
|
|
var cursor_color: vec4<f32>;
|
|
let sprite_idx = cell.sprite_idx & ~COLORED_GLYPH_FLAG;
|
|
if sprite_idx == 0u {
|
|
// Empty cell - use default foreground color for cursor
|
|
cursor_color = color_table.colors[256]; // default_fg
|
|
} else {
|
|
// Cell has a glyph - use its foreground color
|
|
cursor_color = fg;
|
|
}
|
|
cursor_color.a = 1.0;
|
|
|
|
// Calculate cursor sprite UV coordinates (for non-block cursors)
|
|
// Look up the pre-rendered cursor sprite based on cursor style
|
|
var cursor_uv = vec2<f32>(0.0, 0.0);
|
|
var cursor_layer: i32 = 0;
|
|
if is_cursor_cell && grid_params.cursor_style != CURSOR_BLOCK {
|
|
let cursor_sprite_idx = cursor_style_to_sprite(grid_params.cursor_style);
|
|
if cursor_sprite_idx > 0u {
|
|
let cursor_sprite = sprites[cursor_sprite_idx];
|
|
cursor_layer = i32(cursor_sprite.layer);
|
|
// Calculate UV for this vertex (matching quad corners)
|
|
// vertex_index: 0=top-left, 1=top-right, 2=bottom-left, 3=bottom-right
|
|
var cursor_uvs: array<vec2<f32>, 4>;
|
|
cursor_uvs[0] = vec2<f32>(cursor_sprite.uv.x, cursor_sprite.uv.y);
|
|
cursor_uvs[1] = vec2<f32>(cursor_sprite.uv.x + cursor_sprite.uv.z, cursor_sprite.uv.y);
|
|
cursor_uvs[2] = vec2<f32>(cursor_sprite.uv.x, cursor_sprite.uv.y + cursor_sprite.uv.w);
|
|
cursor_uvs[3] = vec2<f32>(cursor_sprite.uv.x + cursor_sprite.uv.z, cursor_sprite.uv.y + cursor_sprite.uv.w);
|
|
cursor_uv = cursor_uvs[vertex_index];
|
|
}
|
|
}
|
|
|
|
var out: CellVertexOutput;
|
|
out.clip_position = vec4<f32>(ndc_pos, 0.0, 1.0);
|
|
out.uv = vec2<f32>(0.0, 0.0); // Not used for background
|
|
out.fg_color = fg;
|
|
out.bg_color = bg;
|
|
out.is_background = 1u;
|
|
out.is_colored_glyph = 0u;
|
|
out.is_cursor = select(0u, 1u, is_cursor_cell);
|
|
out.cursor_shape = grid_params.cursor_style;
|
|
out.cursor_color = cursor_color;
|
|
out.cursor_uv = cursor_uv;
|
|
out.cell_size = vec2<f32>(cell_width_f, cell_height_f);
|
|
out.underline_uv = vec2<f32>(0.0, 0.0); // Not used for background
|
|
out.strike_uv = vec2<f32>(0.0, 0.0); // Not used for background
|
|
out.decoration_fg = vec4<f32>(0.0, 0.0, 0.0, 0.0); // Not used for background
|
|
out.has_underline = 0u;
|
|
out.has_strikethrough = 0u;
|
|
// Layer indices for atlas sampling
|
|
out.glyph_layer = 0; // Not used for background
|
|
out.cursor_layer = cursor_layer;
|
|
out.underline_layer = 0; // Not used for background
|
|
out.strike_layer = 0; // Not used for background
|
|
|
|
return out;
|
|
}
|
|
|
|
// Glyph vertex shader (renders cell glyphs)
|
|
// Uses Kitty-style NDC positioning for alignment, viewport handles pane offset.
|
|
@vertex
|
|
fn vs_cell_glyph(
|
|
@builtin(vertex_index) vertex_index: u32,
|
|
@builtin(instance_index) instance_index: u32
|
|
) -> CellVertexOutput {
|
|
let col = instance_index % grid_params.cols;
|
|
let row = instance_index / grid_params.cols;
|
|
|
|
// Skip if out of bounds - use off-screen position with valid W to avoid undefined behavior
|
|
if row >= grid_params.rows {
|
|
var out: CellVertexOutput;
|
|
out.clip_position = vec4<f32>(0.0, 0.0, 2.0, 1.0);
|
|
out.uv = vec2<f32>(0.0, 0.0);
|
|
out.fg_color = vec4<f32>(0.0, 0.0, 0.0, 0.0);
|
|
out.bg_color = vec4<f32>(0.0, 0.0, 0.0, 0.0);
|
|
out.is_background = 0u;
|
|
out.is_colored_glyph = 0u;
|
|
out.is_cursor = 0u;
|
|
out.cursor_shape = 0u;
|
|
out.cursor_color = vec4<f32>(0.0, 0.0, 0.0, 0.0);
|
|
out.cursor_uv = vec2<f32>(0.0, 0.0);
|
|
out.cell_size = vec2<f32>(0.0, 0.0);
|
|
out.underline_uv = vec2<f32>(0.0, 0.0);
|
|
out.strike_uv = vec2<f32>(0.0, 0.0);
|
|
out.decoration_fg = vec4<f32>(0.0, 0.0, 0.0, 0.0);
|
|
out.has_underline = 0u;
|
|
out.has_strikethrough = 0u;
|
|
out.glyph_layer = 0;
|
|
out.cursor_layer = 0;
|
|
out.underline_layer = 0;
|
|
out.strike_layer = 0;
|
|
return out;
|
|
}
|
|
|
|
// Get cell data
|
|
let cell = cells[instance_index];
|
|
let sprite_idx = cell.sprite_idx & ~COLORED_GLYPH_FLAG;
|
|
let is_colored = (cell.sprite_idx & COLORED_GLYPH_FLAG) != 0u;
|
|
let attrs = cell.attrs;
|
|
|
|
// Check for decorations (like Kitty: decorations render even for empty cells)
|
|
let decoration_type = attrs & ATTR_DECORATION_MASK;
|
|
let has_strike = (attrs & ATTR_STRIKE_BIT) != 0u;
|
|
let underline_sprite_idx = decoration_type_to_sprite(decoration_type);
|
|
let has_decorations = underline_sprite_idx > 0u || has_strike;
|
|
|
|
// Skip if no glyph AND no decorations - use off-screen position with valid W
|
|
if sprite_idx == 0u && !has_decorations {
|
|
var out: CellVertexOutput;
|
|
out.clip_position = vec4<f32>(0.0, 0.0, 2.0, 1.0);
|
|
out.uv = vec2<f32>(0.0, 0.0);
|
|
out.fg_color = vec4<f32>(0.0, 0.0, 0.0, 0.0);
|
|
out.bg_color = vec4<f32>(0.0, 0.0, 0.0, 0.0);
|
|
out.is_background = 0u;
|
|
out.is_colored_glyph = 0u;
|
|
out.is_cursor = 0u;
|
|
out.cursor_shape = 0u;
|
|
out.cursor_color = vec4<f32>(0.0, 0.0, 0.0, 0.0);
|
|
out.cursor_uv = vec2<f32>(0.0, 0.0);
|
|
out.cell_size = vec2<f32>(0.0, 0.0);
|
|
out.underline_uv = vec2<f32>(0.0, 0.0);
|
|
out.strike_uv = vec2<f32>(0.0, 0.0);
|
|
out.decoration_fg = vec4<f32>(0.0, 0.0, 0.0, 0.0);
|
|
out.has_underline = 0u;
|
|
out.has_strikethrough = 0u;
|
|
out.glyph_layer = 0;
|
|
out.cursor_layer = 0;
|
|
out.underline_layer = 0;
|
|
out.strike_layer = 0;
|
|
return out;
|
|
}
|
|
|
|
// Kitty-style NDC positioning: calculate cell size in NDC space
|
|
// NDC ranges from -1 to +1, so total range is 2.0
|
|
let dx = 2.0 / f32(grid_params.cols);
|
|
let dy = 2.0 / f32(grid_params.rows);
|
|
|
|
// Convert integer cell dimensions to float for calculations
|
|
let cell_width_f = f32(grid_params.cell_width);
|
|
let cell_height_f = f32(grid_params.cell_height);
|
|
|
|
// Calculate cell position in NDC (origin at top-left: -1, +1)
|
|
let left = -1.0 + f32(col) * dx;
|
|
let top = 1.0 - f32(row) * dy;
|
|
|
|
// Determine sprite dimensions - for decoration-only cells, use cell size
|
|
var sprite_dx = dx;
|
|
var sprite_dy = dy;
|
|
var glyph_uv = vec2<f32>(0.0, 0.0); // Default: no glyph
|
|
var glyph_layer: i32 = 0;
|
|
|
|
// If we have a glyph, use sprite dimensions and compute glyph UV
|
|
if sprite_idx > 0u {
|
|
let sprite = sprites[sprite_idx];
|
|
glyph_layer = i32(sprite.layer);
|
|
if sprite.size.x > 0.0 && sprite.size.y > 0.0 {
|
|
// Scale NDC size proportionally for wide chars, emoji, etc.
|
|
sprite_dx = dx * (sprite.size.x / cell_width_f);
|
|
sprite_dy = dy * (sprite.size.y / cell_height_f);
|
|
}
|
|
}
|
|
|
|
// Quad vertex positions for TriangleStrip (0=top-left, 1=top-right, 2=bottom-left, 3=bottom-right)
|
|
var ndc_positions: array<vec2<f32>, 4>;
|
|
ndc_positions[0] = vec2<f32>(left, top); // top-left
|
|
ndc_positions[1] = vec2<f32>(left + sprite_dx, top); // top-right
|
|
ndc_positions[2] = vec2<f32>(left, top - sprite_dy); // bottom-left
|
|
ndc_positions[3] = vec2<f32>(left + sprite_dx, top - sprite_dy); // bottom-right
|
|
|
|
let ndc_pos = ndc_positions[vertex_index];
|
|
|
|
// UV coordinates for glyph (only if we have a glyph)
|
|
var uvs: array<vec2<f32>, 4>;
|
|
if sprite_idx > 0u {
|
|
let sprite = sprites[sprite_idx];
|
|
uvs[0] = vec2<f32>(sprite.uv.x, sprite.uv.y); // top-left
|
|
uvs[1] = vec2<f32>(sprite.uv.x + sprite.uv.z, sprite.uv.y); // top-right
|
|
uvs[2] = vec2<f32>(sprite.uv.x, sprite.uv.y + sprite.uv.w); // bottom-left
|
|
uvs[3] = vec2<f32>(sprite.uv.x + sprite.uv.z, sprite.uv.y + sprite.uv.w); // bottom-right
|
|
glyph_uv = uvs[vertex_index];
|
|
}
|
|
|
|
// Resolve colors
|
|
let is_reverse = (attrs & ATTR_REVERSE_BIT) != 0u;
|
|
|
|
var fg = resolve_color(cell.fg, true);
|
|
var bg = resolve_color(cell.bg, false);
|
|
|
|
if is_reverse {
|
|
let tmp = fg;
|
|
fg = bg;
|
|
bg = tmp;
|
|
}
|
|
|
|
// Check if this cell is selected using GridParams selection range
|
|
let is_selected = is_cell_selected(col, row);
|
|
if is_selected {
|
|
fg = vec4<f32>(0.0, 0.0, 0.0, 1.0); // Black foreground
|
|
bg = vec4<f32>(1.0, 1.0, 1.0, 1.0); // White background
|
|
}
|
|
|
|
// Check if this cell is the cursor
|
|
let is_cursor_cell = (i32(col) == grid_params.cursor_col) && (i32(row) == grid_params.cursor_row);
|
|
|
|
// For block cursor, invert text color (use bg as fg)
|
|
var cursor_text_color = bg;
|
|
cursor_text_color.a = 1.0;
|
|
if is_cursor_cell && grid_params.cursor_style == CURSOR_BLOCK {
|
|
fg = cursor_text_color;
|
|
}
|
|
|
|
// Calculate underline UV if decoration is present
|
|
// (underline_sprite_idx, has_strike were computed earlier)
|
|
var underline_uv = vec2<f32>(0.0, 0.0);
|
|
var underline_layer: i32 = 0;
|
|
if underline_sprite_idx > 0u {
|
|
let underline_sprite = sprites[underline_sprite_idx];
|
|
underline_layer = i32(underline_sprite.layer);
|
|
var underline_uvs: array<vec2<f32>, 4>;
|
|
underline_uvs[0] = vec2<f32>(underline_sprite.uv.x, underline_sprite.uv.y);
|
|
underline_uvs[1] = vec2<f32>(underline_sprite.uv.x + underline_sprite.uv.z, underline_sprite.uv.y);
|
|
underline_uvs[2] = vec2<f32>(underline_sprite.uv.x, underline_sprite.uv.y + underline_sprite.uv.w);
|
|
underline_uvs[3] = vec2<f32>(underline_sprite.uv.x + underline_sprite.uv.z, underline_sprite.uv.y + underline_sprite.uv.w);
|
|
underline_uv = underline_uvs[vertex_index];
|
|
}
|
|
|
|
// Calculate strikethrough UV if present
|
|
var strike_uv = vec2<f32>(0.0, 0.0);
|
|
var strike_layer: i32 = 0;
|
|
if has_strike {
|
|
let strike_sprite = sprites[DECORATION_SPRITE_STRIKETHROUGH];
|
|
strike_layer = i32(strike_sprite.layer);
|
|
var strike_uvs: array<vec2<f32>, 4>;
|
|
strike_uvs[0] = vec2<f32>(strike_sprite.uv.x, strike_sprite.uv.y);
|
|
strike_uvs[1] = vec2<f32>(strike_sprite.uv.x + strike_sprite.uv.z, strike_sprite.uv.y);
|
|
strike_uvs[2] = vec2<f32>(strike_sprite.uv.x, strike_sprite.uv.y + strike_sprite.uv.w);
|
|
strike_uvs[3] = vec2<f32>(strike_sprite.uv.x + strike_sprite.uv.z, strike_sprite.uv.y + strike_sprite.uv.w);
|
|
strike_uv = strike_uvs[vertex_index];
|
|
}
|
|
|
|
// Resolve decoration color (use decoration_fg if set, otherwise foreground color)
|
|
var decoration_fg_color = resolve_color(cell.decoration_fg, true);
|
|
// If decoration_fg is default (type 0), use foreground color instead
|
|
let deco_fg_type = cell.decoration_fg & 0xFFu;
|
|
if deco_fg_type == COLOR_TYPE_DEFAULT {
|
|
decoration_fg_color = fg;
|
|
}
|
|
|
|
var out: CellVertexOutput;
|
|
out.clip_position = vec4<f32>(ndc_pos, 0.0, 1.0);
|
|
out.uv = glyph_uv;
|
|
out.fg_color = fg;
|
|
out.bg_color = bg; // Pass background for legacy gamma blending
|
|
out.is_background = 0u;
|
|
out.is_colored_glyph = select(0u, 1u, is_colored);
|
|
out.is_cursor = select(0u, 1u, is_cursor_cell);
|
|
out.cursor_shape = grid_params.cursor_style;
|
|
out.cursor_color = cursor_text_color;
|
|
out.cursor_uv = vec2<f32>(0.0, 0.0); // Not used for glyph pass (cursor rendered in bg pass)
|
|
out.cell_size = vec2<f32>(cell_width_f, cell_height_f);
|
|
out.underline_uv = underline_uv;
|
|
out.strike_uv = strike_uv;
|
|
out.decoration_fg = decoration_fg_color;
|
|
out.has_underline = underline_sprite_idx;
|
|
out.has_strikethrough = select(0u, 1u, has_strike);
|
|
// Layer indices for atlas sampling
|
|
out.glyph_layer = glyph_layer;
|
|
out.cursor_layer = 0; // Not used for glyph pass
|
|
out.underline_layer = underline_layer;
|
|
out.strike_layer = strike_layer;
|
|
|
|
return out;
|
|
}
|
|
|
|
// Fragment shader for cell rendering (both background and glyph)
|
|
@fragment
|
|
fn fs_cell(in: CellVertexOutput) -> @location(0) vec4<f32> {
|
|
if in.is_background == 1u {
|
|
// Check if this is a cursor cell
|
|
if in.is_cursor == 1u {
|
|
if in.cursor_shape == CURSOR_BLOCK {
|
|
// Block cursor - fill entire cell with cursor color
|
|
return in.cursor_color;
|
|
} else {
|
|
// Non-block cursors (bar, underline) - sample from pre-rendered cursor sprite
|
|
// The cursor_uv was calculated in the vertex shader
|
|
let cursor_sample = textureSample(atlas_textures[in.cursor_layer], atlas_sampler, in.cursor_uv);
|
|
let cursor_alpha = cursor_sample.a;
|
|
|
|
if cursor_alpha > 0.0 {
|
|
// Blend cursor color with background based on sprite alpha
|
|
return vec4<f32>(in.cursor_color.rgb, cursor_alpha);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Normal background - just output the bg color
|
|
return in.bg_color;
|
|
}
|
|
|
|
// Glyph pass - sample from RGBA atlas
|
|
// Start with glyph color (or transparent if decoration-only cell)
|
|
var result_rgb: vec3<f32>;
|
|
var result_alpha: f32;
|
|
|
|
// Check if this is a decoration-only cell (no glyph, UV at origin)
|
|
let has_glyph = in.uv.x != 0.0 || in.uv.y != 0.0;
|
|
|
|
if has_glyph {
|
|
let glyph_sample = textureSample(atlas_textures[in.glyph_layer], atlas_sampler, in.uv);
|
|
|
|
if in.is_colored_glyph == 1u {
|
|
// Colored glyph (emoji) - use atlas color directly
|
|
result_rgb = glyph_sample.rgb;
|
|
result_alpha = glyph_sample.a;
|
|
} else {
|
|
// Regular glyph - atlas stores white (1,1,1) with alpha in A channel
|
|
let glyph_alpha = glyph_sample.a;
|
|
|
|
// Apply legacy gamma-incorrect blending for crisp text
|
|
let adjusted_alpha = foreground_contrast_legacy(in.fg_color.rgb, glyph_alpha, in.bg_color.rgb);
|
|
|
|
result_rgb = in.fg_color.rgb;
|
|
result_alpha = in.fg_color.a * adjusted_alpha;
|
|
}
|
|
} else {
|
|
// Decoration-only cell - start with transparent
|
|
result_rgb = vec3<f32>(0.0, 0.0, 0.0);
|
|
result_alpha = 0.0;
|
|
}
|
|
|
|
// Sample and blend underline decoration if present
|
|
if in.has_underline > 0u {
|
|
let underline_sample = textureSample(atlas_textures[in.underline_layer], atlas_sampler, in.underline_uv);
|
|
let underline_alpha = underline_sample.a;
|
|
|
|
if underline_alpha > 0.0 {
|
|
// Alpha-blend underline on top of glyph
|
|
// Use decoration_fg color with the sprite's alpha
|
|
let deco_alpha = underline_alpha * in.decoration_fg.a;
|
|
result_rgb = mix(result_rgb, in.decoration_fg.rgb, deco_alpha * (1.0 - result_alpha) + deco_alpha * result_alpha);
|
|
result_alpha = result_alpha + deco_alpha * (1.0 - result_alpha);
|
|
}
|
|
}
|
|
|
|
// Sample and blend strikethrough decoration if present
|
|
if in.has_strikethrough > 0u {
|
|
let strike_sample = textureSample(atlas_textures[in.strike_layer], atlas_sampler, in.strike_uv);
|
|
let strike_alpha = strike_sample.a;
|
|
|
|
if strike_alpha > 0.0 {
|
|
// Alpha-blend strikethrough on top (it should cover the glyph)
|
|
let deco_alpha = strike_alpha * in.decoration_fg.a;
|
|
result_rgb = mix(result_rgb, in.decoration_fg.rgb, deco_alpha);
|
|
result_alpha = max(result_alpha, deco_alpha);
|
|
}
|
|
}
|
|
|
|
return vec4<f32>(result_rgb, result_alpha);
|
|
}
|