From 568ce784f9064cf1c59bfd3f61c488f05ae0e861 Mon Sep 17 00:00:00 2001 From: ddidderr Date: Sun, 11 Oct 2026 22:26:09 +0200 Subject: [PATCH] feat(renderer): port the OpenGL backend, shaders and size math Ports the terminal-independent half of ZIG/src/renderer (OpenGL only): - opengl/*: buffers, frames, pipelines, render passes, samplers, targets, textures and the OpenGL GraphicsAPI backend on top of glow; the GTK GLArea glue is replaced by a get_proc_address loader closure - the GLSL shaders, copied verbatim and embedded; uniform/vertex structs as bytemuck Pod types with std140 offsets asserted in tests - shadertoy custom shaders converted with naga (GLSL in -> validate -> GLSL 430 out) instead of glslang + spirv-cross - size (screen/grid/cell/padding math), cursor style logic, backend health, options and message types The crate opts into unsafe_code = "deny"; unsafe glow calls live only in opengl/gl.rs. A headless EGL integration test builds every pipeline and the converted shadertoy shaders on Mesa and reads back rendered pixels. Note: Zig's shadertoy Uniforms order the selection background/foreground colors opposite to the GLSL block (likely an upstream bug); kept as in Zig for parity and recorded in the notes. Deferred to the next renderer pass: generic, cell, image, link, row, State, Thread, Overlay (need terminal render state). Test Plan: - cargo test -p ghostty-renderer (33 tests incl. tests/egl_headless.rs) --- crates/ghostty-renderer/Cargo.toml | 41 +- .../shaders/glsl/bg_color.f.glsl | 13 + .../shaders/glsl/bg_image.f.glsl | 63 ++ .../shaders/glsl/bg_image.v.glsl | 145 +++ .../shaders/glsl/cell_bg.f.glsl | 61 ++ .../shaders/glsl/cell_text.f.glsl | 100 +++ .../shaders/glsl/cell_text.v.glsl | 146 +++ .../ghostty-renderer/shaders/glsl/common.glsl | 156 ++++ .../shaders/glsl/full_screen.v.glsl | 24 + .../shaders/glsl/image.f.glsl | 21 + .../shaders/glsl/image.v.glsl | 47 + .../shaders/shadertoy_prefix.glsl | 52 ++ .../shaders/test_shadertoy_crt.glsl | 59 ++ .../shaders/test_shadertoy_focus.glsl | 41 + .../shaders/test_shadertoy_invalid.glsl | 12 + crates/ghostty-renderer/src/backend.rs | 29 + crates/ghostty-renderer/src/cursor.rs | 188 ++++ crates/ghostty-renderer/src/lib.rs | 23 +- crates/ghostty-renderer/src/math.rs | 34 + crates/ghostty-renderer/src/message.rs | 117 +++ crates/ghostty-renderer/src/opengl/buffer.rs | 134 +++ crates/ghostty-renderer/src/opengl/frame.rs | 66 ++ crates/ghostty-renderer/src/opengl/gl.rs | 844 ++++++++++++++++++ crates/ghostty-renderer/src/opengl/mod.rs | 412 +++++++++ .../ghostty-renderer/src/opengl/pipeline.rs | 160 ++++ .../src/opengl/render_pass.rs | 196 ++++ crates/ghostty-renderer/src/opengl/sampler.rs | 55 ++ crates/ghostty-renderer/src/opengl/shaders.rs | 572 ++++++++++++ crates/ghostty-renderer/src/opengl/target.rs | 83 ++ crates/ghostty-renderer/src/opengl/texture.rs | 120 +++ crates/ghostty-renderer/src/options.rs | 23 + crates/ghostty-renderer/src/shadertoy.rs | 465 ++++++++++ crates/ghostty-renderer/src/size.rs | 498 +++++++++++ crates/ghostty-renderer/tests/egl_headless.rs | 390 ++++++++ docs/rewrite/notes/ghostty-renderer-R1.md | 92 ++ 35 files changed, 5477 insertions(+), 5 deletions(-) create mode 100644 crates/ghostty-renderer/shaders/glsl/bg_color.f.glsl create mode 100644 crates/ghostty-renderer/shaders/glsl/bg_image.f.glsl create mode 100644 crates/ghostty-renderer/shaders/glsl/bg_image.v.glsl create mode 100644 crates/ghostty-renderer/shaders/glsl/cell_bg.f.glsl create mode 100644 crates/ghostty-renderer/shaders/glsl/cell_text.f.glsl create mode 100644 crates/ghostty-renderer/shaders/glsl/cell_text.v.glsl create mode 100644 crates/ghostty-renderer/shaders/glsl/common.glsl create mode 100644 crates/ghostty-renderer/shaders/glsl/full_screen.v.glsl create mode 100644 crates/ghostty-renderer/shaders/glsl/image.f.glsl create mode 100644 crates/ghostty-renderer/shaders/glsl/image.v.glsl create mode 100644 crates/ghostty-renderer/shaders/shadertoy_prefix.glsl create mode 100644 crates/ghostty-renderer/shaders/test_shadertoy_crt.glsl create mode 100644 crates/ghostty-renderer/shaders/test_shadertoy_focus.glsl create mode 100644 crates/ghostty-renderer/shaders/test_shadertoy_invalid.glsl create mode 100644 crates/ghostty-renderer/src/backend.rs create mode 100644 crates/ghostty-renderer/src/cursor.rs create mode 100644 crates/ghostty-renderer/src/math.rs create mode 100644 crates/ghostty-renderer/src/message.rs create mode 100644 crates/ghostty-renderer/src/opengl/buffer.rs create mode 100644 crates/ghostty-renderer/src/opengl/frame.rs create mode 100644 crates/ghostty-renderer/src/opengl/gl.rs create mode 100644 crates/ghostty-renderer/src/opengl/mod.rs create mode 100644 crates/ghostty-renderer/src/opengl/pipeline.rs create mode 100644 crates/ghostty-renderer/src/opengl/render_pass.rs create mode 100644 crates/ghostty-renderer/src/opengl/sampler.rs create mode 100644 crates/ghostty-renderer/src/opengl/shaders.rs create mode 100644 crates/ghostty-renderer/src/opengl/target.rs create mode 100644 crates/ghostty-renderer/src/opengl/texture.rs create mode 100644 crates/ghostty-renderer/src/options.rs create mode 100644 crates/ghostty-renderer/src/shadertoy.rs create mode 100644 crates/ghostty-renderer/src/size.rs create mode 100644 crates/ghostty-renderer/tests/egl_headless.rs create mode 100644 docs/rewrite/notes/ghostty-renderer-R1.md diff --git a/crates/ghostty-renderer/Cargo.toml b/crates/ghostty-renderer/Cargo.toml index 2ff64f9..e7e02c6 100644 --- a/crates/ghostty-renderer/Cargo.toml +++ b/crates/ghostty-renderer/Cargo.toml @@ -5,9 +5,42 @@ edition.workspace = true license.workspace = true [dependencies] -ghostty-config.workspace = true +bytemuck = { version = "1.25.2", features = ["derive"] } ghostty-font.workspace = true -ghostty-terminal.workspace = true +glow = "0.18.0" +khronos-egl = { version = "6.0.0", features = ["dynamic"] } +log = "0.4.34" +naga = { version = "30.0.1", features = ["glsl-in", "glsl-out", "wgsl-in"] } +thiserror = "2.0.21" -[lints] -workspace = true +[lints.rust] +unsafe_code = "deny" + +[lints.clippy] +pedantic = { level = "warn", priority = -1 } +todo = "warn" +unwrap_used = "warn" +# Porting a low-level terminal emulator involves a lot of intentional +# numeric casts and long state-machine functions; these pedantic lints are +# noise for this codebase. +cast_possible_truncation = "allow" +cast_possible_wrap = "allow" +cast_sign_loss = "allow" +cast_precision_loss = "allow" +cast_lossless = "allow" +module_name_repetitions = "allow" +too_many_lines = "allow" +missing_errors_doc = "allow" +missing_panics_doc = "allow" +similar_names = "allow" +struct_excessive_bools = "allow" +fn_params_excessive_bools = "allow" +match_same_arms = "allow" +unreadable_literal = "allow" +many_single_char_names = "allow" +doc_markdown = "allow" +must_use_candidate = "allow" +return_self_not_must_use = "allow" +used_underscore_binding = "allow" +large_stack_arrays = "allow" +inline_always = "allow" diff --git a/crates/ghostty-renderer/shaders/glsl/bg_color.f.glsl b/crates/ghostty-renderer/shaders/glsl/bg_color.f.glsl new file mode 100644 index 0000000..616c44b --- /dev/null +++ b/crates/ghostty-renderer/shaders/glsl/bg_color.f.glsl @@ -0,0 +1,13 @@ +#include "common.glsl" + +// Must declare this output for some versions of OpenGL. +layout(location = 0) out vec4 out_FragColor; + +void main() { + bool use_linear_blending = (bools & USE_LINEAR_BLENDING) != 0; + + out_FragColor = load_color( + unpack4u8(bg_color_packed_4u8), + use_linear_blending + ); +} diff --git a/crates/ghostty-renderer/shaders/glsl/bg_image.f.glsl b/crates/ghostty-renderer/shaders/glsl/bg_image.f.glsl new file mode 100644 index 0000000..ee1195e --- /dev/null +++ b/crates/ghostty-renderer/shaders/glsl/bg_image.f.glsl @@ -0,0 +1,63 @@ +#include "common.glsl" + +// Position the FragCoord origin to the upper left +// so as to align with our texture's directionality. +layout(origin_upper_left) in vec4 gl_FragCoord; + +layout(binding = 0) uniform sampler2D image; + +flat in vec4 bg_color; +flat in vec2 offset; +flat in vec2 scale; +flat in float opacity; +flat in uint repeat; + +layout(location = 0) out vec4 out_FragColor; + +void main() { + bool use_linear_blending = (bools & USE_LINEAR_BLENDING) != 0; + + // Our texture coordinate is based on the screen position, offset by the + // dest rect origin, and scaled by the ratio between the dest rect size + // and the original texture size, which effectively scales the original + // size of the texture to the dest rect size. + vec2 tex_coord = (gl_FragCoord.xy - offset) * scale; + + vec2 tex_size = textureSize(image, 0); + + // If we need to repeat the texture, wrap the coordinates. + if (repeat != 0) { + tex_coord = mod(mod(tex_coord, tex_size) + tex_size, tex_size); + } + + vec4 rgba; + // If we're out of bounds, we have no color, + // otherwise we sample the texture for it. + if (any(lessThan(tex_coord, vec2(0.0))) || + any(greaterThan(tex_coord, tex_size))) + { + rgba = vec4(0.0); + } else { + // We divide by the texture size to normalize for sampling. + rgba = texture(image, tex_coord / tex_size); + + if (!use_linear_blending) { + rgba = unlinearize(rgba); + } + + rgba.rgb *= rgba.a; + } + + // Multiply it by the configured opacity, but cap it at + // the value that will make it fully opaque relative to + // the background color alpha, so it isn't overexposed. + rgba *= min(opacity, 1.0 / bg_color.a); + + // Blend it on to a fully opaque version of the background color. + rgba += max(vec4(0.0), vec4(bg_color.rgb, 1.0) * vec4(1.0 - rgba.a)); + + // Multiply everything by the background color alpha. + rgba *= bg_color.a; + + out_FragColor = rgba; +} diff --git a/crates/ghostty-renderer/shaders/glsl/bg_image.v.glsl b/crates/ghostty-renderer/shaders/glsl/bg_image.v.glsl new file mode 100644 index 0000000..d55aa17 --- /dev/null +++ b/crates/ghostty-renderer/shaders/glsl/bg_image.v.glsl @@ -0,0 +1,145 @@ +#include "common.glsl" + +layout(binding = 0) uniform sampler2D image; + +layout(location = 0) in float in_opacity; +layout(location = 1) in uint info; + +// 4 bits of info. +const uint BG_IMAGE_POSITION = 15u; +const uint BG_IMAGE_TL = 0u; +const uint BG_IMAGE_TC = 1u; +const uint BG_IMAGE_TR = 2u; +const uint BG_IMAGE_ML = 3u; +const uint BG_IMAGE_MC = 4u; +const uint BG_IMAGE_MR = 5u; +const uint BG_IMAGE_BL = 6u; +const uint BG_IMAGE_BC = 7u; +const uint BG_IMAGE_BR = 8u; + +// 2 bits of info shifted 4. +const uint BG_IMAGE_FIT = 3u << 4; +const uint BG_IMAGE_CONTAIN = 0u << 4; +const uint BG_IMAGE_COVER = 1u << 4; +const uint BG_IMAGE_STRETCH = 2u << 4; +const uint BG_IMAGE_NO_FIT = 3u << 4; + +// 1 bit of info shifted 6. +const uint BG_IMAGE_REPEAT = 1u << 6; + +flat out vec4 bg_color; +flat out vec2 offset; +flat out vec2 scale; +flat out float opacity; +// We use a uint to pass the repeat value because +// bools aren't allowed for vertex outputs in OpenGL. +flat out uint repeat; + +void main() { + bool use_linear_blending = (bools & USE_LINEAR_BLENDING) != 0; + + vec4 position; + position.x = (gl_VertexID == 2) ? 3.0 : -1.0; + position.y = (gl_VertexID == 0) ? -3.0 : 1.0; + position.z = 1.0; + position.w = 1.0; + + // Single triangle is clipped to viewport. + // + // X <- vid == 0: (-1, -3) + // |\ + // | \ + // | \ + // |###\ + // |#+# \ `+` is (0, 0). `#`s are viewport area. + // |### \ + // X------X <- vid == 2: (3, 1) + // ^ + // vid == 1: (-1, 1) + + gl_Position = position; + + opacity = in_opacity; + + repeat = info & BG_IMAGE_REPEAT; + + vec2 screen_size = screen_size; + vec2 tex_size = textureSize(image, 0); + + vec2 dest_size = tex_size; + switch (info & BG_IMAGE_FIT) { + // For `contain` we scale by a factor that makes the image + // width match the screen width or makes the image height + // match the screen height, whichever is smaller. + case BG_IMAGE_CONTAIN: { + float scale = min(screen_size.x / tex_size.x, screen_size.y / tex_size.y); + dest_size = tex_size * scale; + } break; + + // For `cover` we scale by a factor that makes the image + // width match the screen width or makes the image height + // match the screen height, whichever is larger. + case BG_IMAGE_COVER: { + float scale = max(screen_size.x / tex_size.x, screen_size.y / tex_size.y); + dest_size = tex_size * scale; + } break; + + // For `stretch` we stretch the image to the size of + // the screen without worrying about aspect ratio. + case BG_IMAGE_STRETCH: { + dest_size = screen_size; + } break; + + // For `none` we just use the original texture size. + case BG_IMAGE_NO_FIT: { + dest_size = tex_size; + } break; + } + + vec2 start = vec2(0.0); + vec2 mid = (screen_size - dest_size) / vec2(2.0); + vec2 end = screen_size - dest_size; + + vec2 dest_offset = mid; + switch (info & BG_IMAGE_POSITION) { + case BG_IMAGE_TL: { + dest_offset = vec2(start.x, start.y); + } break; + case BG_IMAGE_TC: { + dest_offset = vec2(mid.x, start.y); + } break; + case BG_IMAGE_TR: { + dest_offset = vec2(end.x, start.y); + } break; + case BG_IMAGE_ML: { + dest_offset = vec2(start.x, mid.y); + } break; + case BG_IMAGE_MC: { + dest_offset = vec2(mid.x, mid.y); + } break; + case BG_IMAGE_MR: { + dest_offset = vec2(end.x, mid.y); + } break; + case BG_IMAGE_BL: { + dest_offset = vec2(start.x, end.y); + } break; + case BG_IMAGE_BC: { + dest_offset = vec2(mid.x, end.y); + } break; + case BG_IMAGE_BR: { + dest_offset = vec2(end.x, end.y); + } break; + } + + offset = dest_offset; + scale = tex_size / dest_size; + + // We load a fully opaque version of the bg color and combine it with + // the alpha separately, because we need these as separate values in + // the framgment shader. + uvec4 u_bg_color = unpack4u8(bg_color_packed_4u8); + bg_color = vec4(load_color( + uvec4(u_bg_color.rgb, 255), + use_linear_blending + ).rgb, float(u_bg_color.a) / 255.0); +} diff --git a/crates/ghostty-renderer/shaders/glsl/cell_bg.f.glsl b/crates/ghostty-renderer/shaders/glsl/cell_bg.f.glsl new file mode 100644 index 0000000..fa48c67 --- /dev/null +++ b/crates/ghostty-renderer/shaders/glsl/cell_bg.f.glsl @@ -0,0 +1,61 @@ +#include "common.glsl" + +// Position the origin to the upper left +layout(origin_upper_left) in vec4 gl_FragCoord; + +// Must declare this output for some versions of OpenGL. +layout(location = 0) out vec4 out_FragColor; + +layout(binding = 1, std430) readonly buffer bg_cells { + uint cells[]; +}; + +vec4 cell_bg() { + uvec2 grid_size = unpack2u16(grid_size_packed_2u16); + ivec2 grid_pos = ivec2(floor((gl_FragCoord.xy - grid_padding.wx) / cell_size)); + bool use_linear_blending = (bools & USE_LINEAR_BLENDING) != 0; + + vec4 bg = vec4(0.0); + + // Clamp x position, extends edge bg colors in to padding on sides. + if (grid_pos.x < 0) { + if ((padding_extend & EXTEND_LEFT) != 0) { + grid_pos.x = 0; + } else { + return bg; + } + } else if (grid_pos.x > grid_size.x - 1) { + if ((padding_extend & EXTEND_RIGHT) != 0) { + grid_pos.x = int(grid_size.x) - 1; + } else { + return bg; + } + } + + // Clamp y position if we should extend, otherwise discard if out of bounds. + if (grid_pos.y < 0) { + if ((padding_extend & EXTEND_UP) != 0) { + grid_pos.y = 0; + } else { + return bg; + } + } else if (grid_pos.y > grid_size.y - 1) { + if ((padding_extend & EXTEND_DOWN) != 0) { + grid_pos.y = int(grid_size.y) - 1; + } else { + return bg; + } + } + + // Load the color for the cell. + vec4 cell_color = load_color( + unpack4u8(cells[grid_pos.y * grid_size.x + grid_pos.x]), + use_linear_blending + ); + + return cell_color; +} + +void main() { + out_FragColor = cell_bg(); +} diff --git a/crates/ghostty-renderer/shaders/glsl/cell_text.f.glsl b/crates/ghostty-renderer/shaders/glsl/cell_text.f.glsl new file mode 100644 index 0000000..176efcb --- /dev/null +++ b/crates/ghostty-renderer/shaders/glsl/cell_text.f.glsl @@ -0,0 +1,100 @@ +#include "common.glsl" + +layout(binding = 0) uniform sampler2DRect atlas_grayscale; +layout(binding = 1) uniform sampler2DRect atlas_color; + +in CellTextVertexOut { + flat uint atlas; + flat vec4 color; + flat vec4 bg_color; + vec2 tex_coord; +} in_data; + +// Values `atlas` can take. +const uint ATLAS_GRAYSCALE = 0u; +const uint ATLAS_COLOR = 1u; + +// Must declare this output for some versions of OpenGL. +layout(location = 0) out vec4 out_FragColor; + +void main() { + bool use_linear_blending = (bools & USE_LINEAR_BLENDING) != 0; + bool use_linear_correction = (bools & USE_LINEAR_CORRECTION) != 0; + + switch (in_data.atlas) { + default: + case ATLAS_GRAYSCALE: + { + // Our input color is always linear. + vec4 color = in_data.color; + + // If we're not doing linear blending, then we need to + // re-apply the gamma encoding to our color manually. + // + // Since the alpha is premultiplied, we need to divide + // it out before unlinearizing and re-multiply it after. + if (!use_linear_blending) { + color.rgb /= vec3(color.a); + color = unlinearize(color); + color.rgb *= vec3(color.a); + } + + // Fetch our alpha mask for this pixel. + float a = texture(atlas_grayscale, in_data.tex_coord).r; + + // Linear blending weight correction corrects the alpha value to + // produce blending results which match gamma-incorrect blending. + if (use_linear_correction) { + // Short explanation of how this works: + // + // We get the luminances of the foreground and background colors, + // and then unlinearize them and perform blending on them. This + // gives us our desired luminance, which we derive our new alpha + // value from by mapping the range [bg_l, fg_l] to [0, 1], since + // our final blend will be a linear interpolation from bg to fg. + // + // This yields virtually identical results for grayscale blending, + // and very similar but non-identical results for color blending. + vec4 bg = in_data.bg_color; + float fg_l = luminance(color.rgb); + float bg_l = luminance(bg.rgb); + // To avoid numbers going haywire, we don't apply correction + // when the bg and fg luminances are within 0.001 of each other. + if (abs(fg_l - bg_l) > 0.001) { + float blend_l = linearize(unlinearize(fg_l) * a + unlinearize(bg_l) * (1.0 - a)); + a = clamp((blend_l - bg_l) / (fg_l - bg_l), 0.0, 1.0); + } + } + + // Multiply our whole color by the alpha mask. + // Since we use premultiplied alpha, this is + // the correct way to apply the mask. + color *= a; + + out_FragColor = color; + return; + } + + case ATLAS_COLOR: + { + // For now, we assume that color glyphs + // are already premultiplied linear colors. + vec4 color = texture(atlas_color, in_data.tex_coord); + + // If we are doing linear blending, we can return this right away. + if (use_linear_blending) { + out_FragColor = color; + return; + } + + // Otherwise we need to unlinearize the color. Since the alpha is + // premultiplied, we need to divide it out before unlinearizing. + color.rgb /= vec3(color.a); + color = unlinearize(color); + color.rgb *= vec3(color.a); + + out_FragColor = color; + return; + } + } +} diff --git a/crates/ghostty-renderer/shaders/glsl/cell_text.v.glsl b/crates/ghostty-renderer/shaders/glsl/cell_text.v.glsl new file mode 100644 index 0000000..7e38e2f --- /dev/null +++ b/crates/ghostty-renderer/shaders/glsl/cell_text.v.glsl @@ -0,0 +1,146 @@ +#include "common.glsl" + +// The position of the glyph in the texture (x, y) +layout(location = 0) in uvec2 glyph_pos; + +// The size of the glyph in the texture (w, h) +layout(location = 1) in uvec2 glyph_size; + +// The left and top bearings for the glyph (x, y) +layout(location = 2) in ivec2 bearings; + +// The grid coordinates (x, y) where x < columns and y < rows +layout(location = 3) in uvec2 grid_pos; + +// The color of the rendered text glyph. +layout(location = 4) in uvec4 color; + +// Which atlas this glyph is in. +layout(location = 5) in uint atlas; + +// Misc glyph properties. +layout(location = 6) in uint glyph_bools; + +// Values `atlas` can take. +const uint ATLAS_GRAYSCALE = 0u; +const uint ATLAS_COLOR = 1u; + +// Masks for the `glyph_bools` attribute +const uint NO_MIN_CONTRAST = 1u; +const uint IS_CURSOR_GLYPH = 2u; + +out CellTextVertexOut { + flat uint atlas; + flat vec4 color; + flat vec4 bg_color; + vec2 tex_coord; +} out_data; + +layout(binding = 1, std430) readonly buffer bg_cells { + uint bg_colors[]; +}; + +void main() { + uvec2 grid_size = unpack2u16(grid_size_packed_2u16); + uvec2 cursor_pos = unpack2u16(cursor_pos_packed_2u16); + bool cursor_wide = (bools & CURSOR_WIDE) != 0; + bool use_linear_blending = (bools & USE_LINEAR_BLENDING) != 0; + + // Convert the grid x, y into world space x, y by accounting for cell size + vec2 cell_pos = cell_size * vec2(grid_pos); + + int vid = gl_VertexID; + + // We use a triangle strip with 4 vertices to render quads, + // so we determine which corner of the cell this vertex is in + // based on the vertex ID. + // + // 0 --> 1 + // | .'| + // | / | + // | L | + // 2 --> 3 + // + // 0 = top-left (0, 0) + // 1 = top-right (1, 0) + // 2 = bot-left (0, 1) + // 3 = bot-right (1, 1) + vec2 corner; + corner.x = float(vid == 1 || vid == 3); + corner.y = float(vid == 2 || vid == 3); + + out_data.atlas = atlas; + + // === Grid Cell === + // +X + // 0,0--...-> + // | + // . offset.x = bearings.x + // +Y. .|. + // . | | + // | cell_pos -> +-------+ _. + // v ._| |_. _|- offset.y = cell_size.y - bearings.y + // | | .###. | | + // | | #...# | | + // glyph_size.y -+ | ##### | | + // | | #.... | +- bearings.y + // |_| .#### | | + // | |_| + // +-------+ + // |_._| + // | + // glyph_size.x + // + // In order to get the top left of the glyph, we compute an offset based on + // the bearings. The Y bearing is the distance from the bottom of the cell + // to the top of the glyph, so we subtract it from the cell height to get + // the y offset. The X bearing is the distance from the left of the cell + // to the left of the glyph, so it works as the x offset directly. + + vec2 size = vec2(glyph_size); + vec2 offset = vec2(bearings); + + offset.y = cell_size.y - offset.y; + + // Calculate the final position of the cell which uses our glyph size + // and glyph offset to create the correct bounding box for the glyph. + cell_pos = cell_pos + size * corner + offset; + gl_Position = projection_matrix * vec4(cell_pos.x, cell_pos.y, 0.0f, 1.0f); + + // Calculate the texture coordinate in pixels. This is NOT normalized + // (between 0.0 and 1.0), and does not need to be, since the texture will + // be sampled with pixel coordinate mode. + out_data.tex_coord = vec2(glyph_pos) + vec2(glyph_size) * corner; + + // Get our color. We always fetch a linearized version to + // make it easier to handle minimum contrast calculations. + out_data.color = load_color(color, true); + // Get the BG color + out_data.bg_color = load_color( + unpack4u8(bg_colors[grid_pos.y * grid_size.x + grid_pos.x]), + true + ); + // Blend it with the global bg color + vec4 global_bg = load_color( + unpack4u8(bg_color_packed_4u8), + true + ); + out_data.bg_color += global_bg * vec4(1.0 - out_data.bg_color.a); + + // If we have a minimum contrast, we need to check if we need to + // change the color of the text to ensure it has enough contrast + // with the background. + if (min_contrast > 1.0f && (glyph_bools & NO_MIN_CONTRAST) == 0) { + // Ensure our minimum contrast + out_data.color = contrasted_color(min_contrast, out_data.color, out_data.bg_color); + } + + // Check if current position is under cursor (including wide cursor) + bool is_cursor_pos = ((grid_pos.x == cursor_pos.x) || (cursor_wide && (grid_pos.x == (cursor_pos.x + 1)))) && (grid_pos.y == cursor_pos.y); + + // If this cell is the cursor cell, but we're not processing + // the cursor glyph itself, then we need to change the color. + if ((glyph_bools & IS_CURSOR_GLYPH) == 0 && is_cursor_pos) { + out_data.color = load_color(unpack4u8(cursor_color_packed_4u8), use_linear_blending); + } +} diff --git a/crates/ghostty-renderer/shaders/glsl/common.glsl b/crates/ghostty-renderer/shaders/glsl/common.glsl new file mode 100644 index 0000000..1a7ea03 --- /dev/null +++ b/crates/ghostty-renderer/shaders/glsl/common.glsl @@ -0,0 +1,156 @@ +#version 430 core + +// These are common definitions to be shared across shaders, the first +// line of any shader that needs these should be `#include "common.glsl"`. +// +// Included in this file are: +// - The interface block for the global uniforms. +// - Functions for unpacking values. +// - Functions for working with colors. + +//----------------------------------------------------------------------------// +// Global Uniforms +//----------------------------------------------------------------------------// +layout(binding = 1, std140) uniform Globals { + uniform mat4 projection_matrix; + uniform vec2 screen_size; + uniform vec2 cell_size; + uniform uint grid_size_packed_2u16; + uniform vec4 grid_padding; + uniform uint padding_extend; + uniform float min_contrast; + uniform uint cursor_pos_packed_2u16; + uniform uint cursor_color_packed_4u8; + uniform uint bg_color_packed_4u8; + uniform uint bools; +}; + +// Bools +const uint CURSOR_WIDE = 1u; +const uint USE_DISPLAY_P3 = 2u; +const uint USE_LINEAR_BLENDING = 4u; +const uint USE_LINEAR_CORRECTION = 8u; + +// Padding extend enum +const uint EXTEND_LEFT = 1u; +const uint EXTEND_RIGHT = 2u; +const uint EXTEND_UP = 4u; +const uint EXTEND_DOWN = 8u; + +//----------------------------------------------------------------------------// +// Functions for Unpacking Values +//----------------------------------------------------------------------------// +// NOTE: These unpack functions assume little-endian. +// If this ever becomes a problem... oh dear! + +uvec4 unpack4u8(uint packed_value) { + return uvec4( + uint(packed_value >> 0) & uint(0xFF), + uint(packed_value >> 8) & uint(0xFF), + uint(packed_value >> 16) & uint(0xFF), + uint(packed_value >> 24) & uint(0xFF) + ); +} + +uvec2 unpack2u16(uint packed_value) { + return uvec2( + uint(packed_value >> 0) & uint(0xFFFF), + uint(packed_value >> 16) & uint(0xFFFF) + ); +} + +ivec2 unpack2i16(int packed_value) { + return ivec2( + (packed_value << 16) >> 16, + (packed_value << 0) >> 16 + ); +} + +//----------------------------------------------------------------------------// +// Color Functions +//----------------------------------------------------------------------------// + +// Compute the luminance of the provided color. +// +// Takes colors in linear RGB space. If your colors are gamma +// encoded, linearize them before using them with this function. +float luminance(vec3 color) { + return dot(color, vec3(0.2126f, 0.7152f, 0.0722f)); +} + +// https://www.w3.org/TR/2008/REC-WCAG20-20081211/#contrast-ratiodef +// +// Takes colors in linear RGB space. If your colors are gamma +// encoded, linearize them before using them with this function. +float contrast_ratio(vec3 color1, vec3 color2) { + float luminance1 = luminance(color1) + 0.05; + float luminance2 = luminance(color2) + 0.05; + return max(luminance1, luminance2) / min(luminance1, luminance2); +} + +// Return the fg if the contrast ratio is greater than min, otherwise +// return a color that satisfies the contrast ratio. Currently, the color +// is always white or black, whichever has the highest contrast ratio. +// +// Takes colors in linear RGB space. If your colors are gamma +// encoded, linearize them before using them with this function. +vec4 contrasted_color(float min_ratio, vec4 fg, vec4 bg) { + float ratio = contrast_ratio(fg.rgb, bg.rgb); + if (ratio < min_ratio) { + float white_ratio = contrast_ratio(vec3(1.0, 1.0, 1.0), bg.rgb); + float black_ratio = contrast_ratio(vec3(0.0, 0.0, 0.0), bg.rgb); + if (white_ratio > black_ratio) { + return vec4(1.0); + } else { + return vec4(0.0, 0.0, 0.0, 1.0); + } + } + + return fg; +} + +// Converts a color from sRGB gamma encoding to linear. +vec4 linearize(vec4 srgb) { + bvec3 cutoff = lessThanEqual(srgb.rgb, vec3(0.04045)); + vec3 higher = pow((srgb.rgb + vec3(0.055)) / vec3(1.055), vec3(2.4)); + vec3 lower = srgb.rgb / vec3(12.92); + + return vec4(mix(higher, lower, cutoff), srgb.a); +} +float linearize(float v) { + return v <= 0.04045 ? v / 12.92 : pow((v + 0.055) / 1.055, 2.4); +} + +// Converts a color from linear to sRGB gamma encoding. +vec4 unlinearize(vec4 linear) { + bvec3 cutoff = lessThanEqual(linear.rgb, vec3(0.0031308)); + vec3 higher = pow(linear.rgb, vec3(1.0 / 2.4)) * vec3(1.055) - vec3(0.055); + vec3 lower = linear.rgb * vec3(12.92); + + return vec4(mix(higher, lower, cutoff), linear.a); +} +float unlinearize(float v) { + return v <= 0.0031308 ? v * 12.92 : pow(v, 1.0 / 2.4) * 1.055 - 0.055; +} + +// Load a 4 byte RGBA non-premultiplied color and linearize +// and convert it as necessary depending on the provided info. +// +// `linear` controls whether the returned color is linear or gamma encoded. +vec4 load_color( + uvec4 in_color, + bool linear +) { + // 0 .. 255 -> 0.0 .. 1.0 + vec4 color = vec4(in_color) / vec4(255.0f); + + // Linearize if necessary. + if (linear) color = linearize(color); + + // Premultiply our color by its alpha. + color.rgb *= color.a; + + return color; +} + +//----------------------------------------------------------------------------// diff --git a/crates/ghostty-renderer/shaders/glsl/full_screen.v.glsl b/crates/ghostty-renderer/shaders/glsl/full_screen.v.glsl new file mode 100644 index 0000000..b89cedf --- /dev/null +++ b/crates/ghostty-renderer/shaders/glsl/full_screen.v.glsl @@ -0,0 +1,24 @@ +#version 330 core + +void main() { + vec4 position; + position.x = (gl_VertexID == 2) ? 3.0 : -1.0; + position.y = (gl_VertexID == 0) ? -3.0 : 1.0; + position.z = 1.0; + position.w = 1.0; + + // Single triangle is clipped to viewport. + // + // X <- vid == 0: (-1, -3) + // |\ + // | \ + // | \ + // |###\ + // |#+# \ `+` is (0, 0). `#`s are viewport area. + // |### \ + // X------X <- vid == 2: (3, 1) + // ^ + // vid == 1: (-1, 1) + + gl_Position = position; +} diff --git a/crates/ghostty-renderer/shaders/glsl/image.f.glsl b/crates/ghostty-renderer/shaders/glsl/image.f.glsl new file mode 100644 index 0000000..4f89d7a --- /dev/null +++ b/crates/ghostty-renderer/shaders/glsl/image.f.glsl @@ -0,0 +1,21 @@ +#include "common.glsl" + +layout(binding = 0) uniform sampler2D image; + +in vec2 tex_coord; + +layout(location = 0) out vec4 out_FragColor; + +void main() { + bool use_linear_blending = (bools & USE_LINEAR_BLENDING) != 0; + + vec4 rgba = texture(image, tex_coord); + + if (!use_linear_blending) { + rgba = unlinearize(rgba); + } + + rgba.rgb *= vec3(rgba.a); + + out_FragColor = rgba; +} diff --git a/crates/ghostty-renderer/shaders/glsl/image.v.glsl b/crates/ghostty-renderer/shaders/glsl/image.v.glsl new file mode 100644 index 0000000..779fae3 --- /dev/null +++ b/crates/ghostty-renderer/shaders/glsl/image.v.glsl @@ -0,0 +1,47 @@ +#include "common.glsl" + +layout(binding = 0) uniform sampler2D image; + +layout(location = 0) in vec2 grid_pos; +layout(location = 1) in vec2 cell_offset; +layout(location = 2) in vec4 source_rect; +layout(location = 3) in vec2 dest_size; + +out vec2 tex_coord; + +void main() { + int vid = gl_VertexID; + + // We use a triangle strip with 4 vertices to render quads, + // so we determine which corner of the cell this vertex is in + // based on the vertex ID. + // + // 0 --> 1 + // | .'| + // | / | + // | L | + // 2 --> 3 + // + // 0 = top-left (0, 0) + // 1 = top-right (1, 0) + // 2 = bot-left (0, 1) + // 3 = bot-right (1, 1) + vec2 corner; + corner.x = float(vid == 1 || vid == 3); + corner.y = float(vid == 2 || vid == 3); + + // The texture coordinates start at our source x/y + // and add the width/height depending on the corner. + tex_coord = source_rect.xy; + tex_coord += source_rect.zw * corner; + + // Normalize the coordinates. + tex_coord /= textureSize(image, 0); + + // The position of our image starts at the top-left of the grid cell and + // adds the source rect width/height components. + vec2 image_pos = (cell_size * grid_pos) + cell_offset; + image_pos += dest_size * corner; + + gl_Position = projection_matrix * vec4(image_pos.xy, 1.0, 1.0); +} diff --git a/crates/ghostty-renderer/shaders/shadertoy_prefix.glsl b/crates/ghostty-renderer/shaders/shadertoy_prefix.glsl new file mode 100644 index 0000000..4b6d091 --- /dev/null +++ b/crates/ghostty-renderer/shaders/shadertoy_prefix.glsl @@ -0,0 +1,52 @@ +#version 430 core + +layout(binding = 1, std140) uniform Globals { + uniform vec3 iResolution; + uniform float iTime; + uniform float iTimeDelta; + uniform float iFrameRate; + uniform int iFrame; + uniform float iChannelTime[4]; + uniform vec3 iChannelResolution[4]; + uniform vec4 iMouse; + uniform vec4 iDate; + uniform float iSampleRate; + uniform vec4 iCurrentCursor; + uniform vec4 iPreviousCursor; + uniform vec4 iCurrentCursorColor; + uniform vec4 iPreviousCursorColor; + uniform int iCurrentCursorStyle; + uniform int iPreviousCursorStyle; + uniform int iCursorVisible; + uniform float iTimeCursorChange; + uniform float iTimeFocus; + uniform int iFocus; + uniform vec3 iPalette[256]; + uniform vec3 iBackgroundColor; + uniform vec3 iForegroundColor; + uniform vec3 iCursorColor; + uniform vec3 iCursorText; + uniform vec3 iSelectionForegroundColor; + uniform vec3 iSelectionBackgroundColor; +}; + +#define CURSORSTYLE_BLOCK 0 +#define CURSORSTYLE_BLOCK_HOLLOW 1 +#define CURSORSTYLE_BAR 2 +#define CURSORSTYLE_UNDERLINE 3 +#define CURSORSTYLE_LOCK 4 + +layout(binding = 0) uniform sampler2D iChannel0; + +// These are unused currently by Ghostty: +// layout(binding = 1) uniform sampler2D iChannel1; +// layout(binding = 2) uniform sampler2D iChannel2; +// layout(binding = 3) uniform sampler2D iChannel3; + +layout(location = 0) in vec4 gl_FragCoord; +layout(location = 0) out vec4 _fragColor; + +#define texture2D texture + +void mainImage( out vec4 fragColor, in vec2 fragCoord ); +void main() { mainImage (_fragColor, gl_FragCoord.xy); } diff --git a/crates/ghostty-renderer/shaders/test_shadertoy_crt.glsl b/crates/ghostty-renderer/shaders/test_shadertoy_crt.glsl new file mode 100644 index 0000000..b8d6dbb --- /dev/null +++ b/crates/ghostty-renderer/shaders/test_shadertoy_crt.glsl @@ -0,0 +1,59 @@ +// This shader is NOT BUILD INTO Ghostty. This is only here for unit tests. + +// Loosely based on postprocessing shader by inigo quilez, License Creative +// Commons Attribution-NonCommercial-ShareAlike 3.0 Unported License. + +vec2 curve(vec2 uv) +{ + uv = (uv - 0.5) * 2.0; + uv *= 1.1; + uv.x *= 1.0 + pow((abs(uv.y) / 5.0), 2.0); + uv.y *= 1.0 + pow((abs(uv.x) / 4.0), 2.0); + uv = (uv / 2.0) + 0.5; + uv = uv *0.92 + 0.04; + return uv; +} +void mainImage( out vec4 fragColor, in vec2 fragCoord ) +{ + vec2 q = fragCoord.xy / iResolution.xy; + vec2 uv = q; + uv = curve( uv ); + vec3 oricol = texture( iChannel0, vec2(q.x,q.y) ).xyz; + vec3 col; + float x = sin(0.3*iTime+uv.y*21.0)*sin(0.7*iTime+uv.y*29.0)*sin(0.3+0.33*iTime+uv.y*31.0)*0.0017; + + col.r = texture(iChannel0,vec2(x+uv.x+0.001,uv.y+0.001)).x+0.05; + col.g = texture(iChannel0,vec2(x+uv.x+0.000,uv.y-0.002)).y+0.05; + col.b = texture(iChannel0,vec2(x+uv.x-0.002,uv.y+0.000)).z+0.05; + col.r += 0.08*texture(iChannel0,0.75*vec2(x+0.025, -0.027)+vec2(uv.x+0.001,uv.y+0.001)).x; + col.g += 0.05*texture(iChannel0,0.75*vec2(x+-0.022, -0.02)+vec2(uv.x+0.000,uv.y-0.002)).y; + col.b += 0.08*texture(iChannel0,0.75*vec2(x+-0.02, -0.018)+vec2(uv.x-0.002,uv.y+0.000)).z; + + col = clamp(col*0.6+0.4*col*col*1.0,0.0,1.0); + + float vig = (0.0 + 1.0*16.0*uv.x*uv.y*(1.0-uv.x)*(1.0-uv.y)); + col *= vec3(pow(vig,0.3)); + + col *= vec3(0.95,1.05,0.95); + col *= 2.8; + + float scans = clamp( 0.35+0.35*sin(3.5*iTime+uv.y*iResolution.y*1.5), 0.0, 1.0); + + float s = pow(scans,1.7); + col = col*vec3( 0.4+0.7*s) ; + + col *= 1.0+0.01*sin(110.0*iTime); + if (uv.x < 0.0 || uv.x > 1.0) + col *= 0.0; + if (uv.y < 0.0 || uv.y > 1.0) + col *= 0.0; + + col*=1.0-0.65*vec3(clamp((mod(fragCoord.x, 2.0)-1.0)*2.0,0.0,1.0)); + + float comp = smoothstep( 0.1, 0.9, sin(iTime) ); + + // Remove the next line to stop cross-fade between original and postprocess +// col = mix( col, oricol, comp ); + + fragColor = vec4(col,1.0); +} diff --git a/crates/ghostty-renderer/shaders/test_shadertoy_focus.glsl b/crates/ghostty-renderer/shaders/test_shadertoy_focus.glsl new file mode 100644 index 0000000..9fc2304 --- /dev/null +++ b/crates/ghostty-renderer/shaders/test_shadertoy_focus.glsl @@ -0,0 +1,41 @@ +// Test shader for iTimeFocus and iFocus +// Shows border when focused, green fade that restarts on each focus gain +void mainImage(out vec4 fragColor, in vec2 fragCoord) { + vec2 uv = fragCoord / iResolution.xy; + + // Sample the terminal content + vec4 terminal = texture2D(iChannel0, uv); + vec3 color = terminal.rgb; + + if (iFocus > 0) { + // FOCUSED: Add border and fading green overlay + + // Calculate time since focus was gained + float timeSinceFocus = iTime - iTimeFocus; + + // Green fade: starts at 1.0 (full green), fades to 0.0 over 3 seconds + float fadeOut = max(0.0, 1.0 - (timeSinceFocus / 3.0)); + + // Add green overlay that fades out + color = mix(color, vec3(0.0, 1.0, 0.0), fadeOut * 0.4); + + // Add border (5 pixels) + float borderSize = 5.0; + vec2 pixelCoord = fragCoord; + bool isBorder = pixelCoord.x < borderSize || + pixelCoord.x > iResolution.x - borderSize || + pixelCoord.y < borderSize || + pixelCoord.y > iResolution.y - borderSize; + + if (isBorder) { + // Bright cyan border that pulses subtly + float pulse = sin(timeSinceFocus * 2.0) * 0.1 + 0.9; + color = vec3(0.0, 1.0, 1.0) * pulse; + } + } else { + // UNFOCUSED: Solid red overlay (no border) + color = mix(color, vec3(1.0, 0.0, 0.0), 0.3); + } + + fragColor = vec4(color, 1.0); +} diff --git a/crates/ghostty-renderer/shaders/test_shadertoy_invalid.glsl b/crates/ghostty-renderer/shaders/test_shadertoy_invalid.glsl new file mode 100644 index 0000000..1cff1c7 --- /dev/null +++ b/crates/ghostty-renderer/shaders/test_shadertoy_invalid.glsl @@ -0,0 +1,12 @@ +vec2 curve(vec2 uv) +{ + uv = (uv - 0.5) * 2.0; + uv *= 1.1; + uv.x *= 1.0 + pow((abs(uv.y) / 5.0), 2.0); + uv.y *= 1.0 + pow((abs(uv.x) / 4.0), 2.0); + uv = (uv / 2.0) + 0.5; + uv = uv *0.92 + 0.04; + return uv; +} + +// Missing mainImage! diff --git a/crates/ghostty-renderer/src/backend.rs b/crates/ghostty-renderer/src/backend.rs new file mode 100644 index 0000000..e474595 --- /dev/null +++ b/crates/ghostty-renderer/src/backend.rs @@ -0,0 +1,29 @@ +//! Renderer backend selection (`renderer/backend.zig`). + +/// Possible implementations, used for build options. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Backend { + Opengl, + Metal, + Webgl, +} + +impl Backend { + /// The default backend for the target. Metal/WebGL are out of scope for + /// the Rust port (Linux only), so this is always OpenGL on non-Darwin. + pub fn default_for_target() -> Backend { + if cfg!(target_vendor = "apple") { + Backend::Metal + } else { + Backend::Opengl + } + } +} + +/// The health status of a renderer (`renderer.Health`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(C)] +pub enum Health { + Healthy, + Unhealthy, +} diff --git a/crates/ghostty-renderer/src/cursor.rs b/crates/ghostty-renderer/src/cursor.rs new file mode 100644 index 0000000..2b7bed1 --- /dev/null +++ b/crates/ghostty-renderer/src/cursor.rs @@ -0,0 +1,188 @@ +//! Cursor style decision logic (`renderer/cursor.zig`). + +/// Terminal cursor styles (`terminal.CursorStyle`). +// TODO(integration): replace with ghostty_terminal's CursorStyle. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum TerminalCursorStyle { + Bar, + #[default] + Block, + BlockHollow, + Underline, +} + +/// The subset of `terminal.RenderState.cursor` that the cursor style +/// decision needs. +// TODO(integration): replace with `&ghostty_terminal::RenderState` (use its +// `cursor` field) once the terminal crate is a dependency. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RenderStateCursor { + /// Viewport position of the cursor (`None` if it isn't in the viewport). + pub viewport: Option<(u16, u16)>, + /// Whether the terminal is at a password prompt. + pub password_input: bool, + /// Cursor visibility via terminal mode. + pub visible: bool, + /// Whether the cursor is blinking. + pub blinking: bool, + /// The visual style requested by the terminal. + pub visual_style: TerminalCursorStyle, +} + +/// Available cursor styles for drawing that renderers must support. +/// This is a superset of terminal cursor styles since the renderer supports +/// some additional cursor states such as the hollow block. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Style { + // Typical cursor input styles + Block, + BlockHollow, + Bar, + Underline, + + // Special cursor styles + Lock, +} + +impl Style { + /// Create a cursor style from the terminal style request. + pub fn from_terminal(term: TerminalCursorStyle) -> Style { + match term { + TerminalCursorStyle::Bar => Style::Bar, + TerminalCursorStyle::Block => Style::Block, + TerminalCursorStyle::BlockHollow => Style::BlockHollow, + TerminalCursorStyle::Underline => Style::Underline, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct StyleOptions { + pub preedit: bool, + pub focused: bool, + pub blink_visible: bool, +} + +/// Returns the cursor style to use for the current render state or None +/// if a cursor should not be rendered at all. +pub fn style(state: &RenderStateCursor, opts: StyleOptions) -> Option