From 2d052a4dca028295534e9eebcaee1e150257cc45 Mon Sep 17 00:00:00 2001 From: yunusey Date: Tue, 31 Dec 2024 16:50:47 -0500 Subject: [PATCH] feat?: Add background image support for OpenGL Unmerged: ghostty-org/ghostty#4226 --- src/config.zig | 2 + src/config/Config.zig | 259 +++++++++++++----- src/renderer/OpenGL.zig | 205 +++++++++++++- .../opengl/BackgroundImageProgram.zig | 116 ++++++++ src/renderer/shaders/bgimage.f.glsl | 16 ++ src/renderer/shaders/bgimage.v.glsl | 92 +++++++ 6 files changed, 616 insertions(+), 74 deletions(-) create mode 100644 src/renderer/opengl/BackgroundImageProgram.zig create mode 100644 src/renderer/shaders/bgimage.f.glsl create mode 100644 src/renderer/shaders/bgimage.v.glsl diff --git a/src/config.zig b/src/config.zig index 75dbaae02b..81d36abf2f 100644 --- a/src/config.zig +++ b/src/config.zig @@ -28,8 +28,10 @@ pub const RepeatableCodepointMap = Config.RepeatableCodepointMap; pub const RepeatableFontVariation = Config.RepeatableFontVariation; pub const RepeatableString = Config.RepeatableString; pub const RepeatablePath = Config.RepeatablePath; +pub const SinglePath = Config.SinglePath; pub const ShellIntegrationFeatures = Config.ShellIntegrationFeatures; pub const WindowPaddingColor = Config.WindowPaddingColor; +pub const BackgroundImageMode = Config.BackgroundImageMode; // Alternate APIs pub const CAPI = @import("config/CAPI.zig"); diff --git a/src/config/Config.zig b/src/config/Config.zig index 3010b87d1e..401b0d23d3 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -20,6 +20,7 @@ const global_state = &@import("../global.zig").state; const fontpkg = @import("../font/main.zig"); const inputpkg = @import("../input.zig"); const terminal = @import("../terminal/main.zig"); +const BackgroundImageProgram = @import("../renderer/opengl/BackgroundImageProgram.zig"); const internal_os = @import("../os/main.zig"); const cli = @import("../cli.zig"); const Command = @import("../Command.zig"); @@ -455,6 +456,32 @@ background: Color = .{ .r = 0x28, .g = 0x2C, .b = 0x34 }, /// Specified as either hex (`#RRGGBB` or `RRGGBB`) or a named X11 color. foreground: Color = .{ .r = 0xFF, .g = 0xFF, .b = 0xFF }, +/// Background image for the window. +@"background-image": SinglePath = .{}, + +/// Background image opacity +@"background-image-opacity": f32 = 1.0, + +/// Background image mode to use. +/// +/// Valid values are: +/// +/// * `zoomed` - Image is scaled to fit the window, preserving aspect ratio. +/// * `scaled` - Image is scaled to fill the window, not preserving aspect ratio. +/// * `tiled` - Image is repeated horizontally and vertically to fill the window. +/// * `centered` - Image is centered in the window and displayed 1-to-1 pixel +/// scale, preserving both the aspect ratio and the image size. +/// * `upper-left` - Image is anchored to the upper left corner of the window, +/// preserving the aspect ratio. +/// * `upper-right` - Image is anchored to the upper right corner of the window, +/// preserving the aspect ratio. +/// * `lower-left` - Image is anchored to the lower left corner of the window, +/// preserving the aspect ratio. +/// * `lower-right` - Image is anchored to the lower right corner of the window, +/// preserving the aspect ratio. +/// +@"background-image-mode": BackgroundImageMode = .zoomed, + /// The foreground and background color for selection. If this is not set, then /// the selection color is just the inverted window background and foreground /// (note: not to be confused with the cell bg/fg). @@ -3356,7 +3383,7 @@ fn expandPaths(self: *Config, base: []const u8) !void { // Expand all of our paths inline for (@typeInfo(Config).Struct.fields) |field| { - if (field.type == RepeatablePath) { + if (field.type == RepeatablePath or field.type == SinglePath) { try @field(self, field.name).expand( arena_alloc, base, @@ -3366,6 +3393,86 @@ fn expandPaths(self: *Config, base: []const u8) !void { } } +/// Expand a relative path to an absolute path. This function is used by +/// the RepeatablePath and SinglePath to expand the paths they store. +fn expandPath( + alloc: Allocator, + base: []const u8, + path: []const u8, + diags: *cli.DiagnosticList, +) ![]const u8 { + assert(std.fs.path.isAbsolute(base)); + var dir = try std.fs.cwd().openDir(base, .{}); + defer dir.close(); + + // If it is already absolute we can just return it + if (path.len == 0 or std.fs.path.isAbsolute(path)) return path; + + // If it isn't absolute, we need to make it absolute relative + // to the base. + var buf: [std.fs.max_path_bytes]u8 = undefined; + + // Check if the path starts with a tilde and expand it to the + // home directory on Linux/macOS. We explicitly look for "~/" + // because we don't support alternate users such as "~alice/" + if (std.mem.startsWith(u8, path, "~/")) expand: { + // Windows isn't supported yet + if (comptime builtin.os.tag == .windows) break :expand; + + const expanded: []const u8 = internal_os.expandHome( + path, + &buf, + ) catch |err| { + try diags.append(alloc, .{ + .message = try std.fmt.allocPrintZ( + alloc, + "error expanding home directory for path {s}: {}", + .{ path, err }, + ), + }); + + // We can't expand this path so return an empty string + return ""; + }; + + log.debug( + "expanding file path from home directory: path={s}", + .{expanded}, + ); + + return expanded; + } + + const abs = dir.realpath(path, &buf) catch |err| abs: { + if (err == error.FileNotFound) { + // The file doesn't exist. Try to resolve the relative path + // another way. + const resolved = try std.fs.path.resolve(alloc, &.{ base, path }); + defer alloc.free(resolved); + @memcpy(buf[0..resolved.len], resolved); + break :abs buf[0..resolved.len]; + } + + try diags.append(alloc, .{ + .message = try std.fmt.allocPrintZ( + alloc, + "error resolving file path {s}: {}", + .{ path, err }, + ), + }); + + // We can't expand this path so return an empty string + return ""; + }; + + log.debug( + "expanding file path relative={s} abs={s}", + .{ path, abs }, + ); + + return abs; +} + fn loadTheme(self: *Config, theme: Theme) !void { // Load the correct theme depending on the conditional state. // Dark/light themes were programmed prior to conditional configuration @@ -4460,6 +4567,63 @@ pub const Palette = struct { } }; +/// SinglePath is a path to a single file. When loading the configuration +/// file, always the last one will be kept and be automatically expanded +/// relative to the path of the config file. +pub const SinglePath = struct { + const Self = @This(); + + /// The actual value that is updated as we parse. + value: ?[]const u8 = null, + + /// Parse a single path. + pub fn parseCLI(self: *Self, alloc: Allocator, input: ?[]const u8) !void { + const value = input orelse return error.ValueRequired; + // If the value is empty, we set the value to null + if (value.len == 0) { + self.value = null; + return; + } + const copy = try alloc.dupe(u8, value); + self.value = copy; + } + + /// Deep copy of the struct. Required by Config. + pub fn clone(self: Self, alloc: Allocator) Allocator.Error!Self { + const value = self.value orelse return .{}; + + const copy_path = try alloc.dupe(u8, value); + return .{ + .value = copy_path, + }; + } + + /// Used by Formatter + pub fn formatEntry(self: Self, formatter: anytype) !void { + const value = self.value orelse return; + try formatter.formatEntry([]const u8, value); + } + + /// Expand all the paths relative to the base directory. + pub fn expand( + self: *Self, + alloc: Allocator, + base: []const u8, + diags: *cli.DiagnosticList, + ) !void { + // Try expanding path relative to the base. + const path = self.value orelse return; + const abs = try expandPath(alloc, base, path, diags); + + if (abs.len == 0) { + // Blank this path so that we don't attempt to resolve it again + self.value = null; + return; + } + self.value = try alloc.dupeZ(u8, abs); + } +}; + /// RepeatableString is a string value that can be repeated to accumulate /// a list of strings. This isn't called "StringList" because I find that /// sometimes leads to confusion that it _accepts_ a list such as @@ -4691,88 +4855,20 @@ pub const RepeatablePath = struct { base: []const u8, diags: *cli.DiagnosticList, ) !void { - assert(std.fs.path.isAbsolute(base)); - var dir = try std.fs.cwd().openDir(base, .{}); - defer dir.close(); - for (0..self.value.items.len) |i| { const path = switch (self.value.items[i]) { .optional, .required => |path| path, }; - // If it is already absolute we can ignore it. - if (path.len == 0 or std.fs.path.isAbsolute(path)) continue; - - // If it isn't absolute, we need to make it absolute relative - // to the base. - var buf: [std.fs.max_path_bytes]u8 = undefined; - - // Check if the path starts with a tilde and expand it to the - // home directory on Linux/macOS. We explicitly look for "~/" - // because we don't support alternate users such as "~alice/" - if (std.mem.startsWith(u8, path, "~/")) expand: { - // Windows isn't supported yet - if (comptime builtin.os.tag == .windows) break :expand; - - const expanded: []const u8 = internal_os.expandHome( - path, - &buf, - ) catch |err| { - try diags.append(alloc, .{ - .message = try std.fmt.allocPrintZ( - alloc, - "error expanding home directory for path {s}: {}", - .{ path, err }, - ), - }); - - // Blank this path so that we don't attempt to resolve it - // again - self.value.items[i] = .{ .required = "" }; - - continue; - }; - - log.debug( - "expanding file path from home directory: path={s}", - .{expanded}, - ); - - switch (self.value.items[i]) { - .optional, .required => |*p| p.* = try alloc.dupeZ(u8, expanded), - } - - continue; - } - - const abs = dir.realpath(path, &buf) catch |err| abs: { - if (err == error.FileNotFound) { - // The file doesn't exist. Try to resolve the relative path - // another way. - const resolved = try std.fs.path.resolve(alloc, &.{ base, path }); - defer alloc.free(resolved); - @memcpy(buf[0..resolved.len], resolved); - break :abs buf[0..resolved.len]; - } - - try diags.append(alloc, .{ - .message = try std.fmt.allocPrintZ( - alloc, - "error resolving file path {s}: {}", - .{ path, err }, - ), - }); + // Try expanding path relative to the base. + const abs = try expandPath(alloc, base, path, diags); + if (abs.len == 0) { // Blank this path so that we don't attempt to resolve it again self.value.items[i] = .{ .required = "" }; continue; - }; - - log.debug( - "expanding file path relative={s} abs={s}", - .{ path, abs }, - ); + } switch (self.value.items[i]) { .optional, .required => |*p| p.* = try alloc.dupeZ(u8, abs), @@ -5846,6 +5942,23 @@ pub const AlphaBlending = enum { } }; +/// See background-image-mode +/// +/// This enum is used to set the background image mode. The shader expects +/// a `uint`, so we use `u8` here. The values for each mode should be kept +/// in sync with the values in the vertex shader used to render the +/// background image (`bgimage`). +pub const BackgroundImageMode = enum(u8) { + zoomed = 0, + stretched = 1, + tiled = 2, + centered = 3, + upper_left = 4, + upper_right = 5, + lower_left = 6, + lower_right = 7, +}; + /// See freetype-load-flag pub const FreetypeLoadFlags = packed struct { // The defaults here at the time of writing this match the defaults diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig index 3e674c7155..4762d34834 100644 --- a/src/renderer/OpenGL.zig +++ b/src/renderer/OpenGL.zig @@ -4,6 +4,7 @@ pub const OpenGL = @This(); const std = @import("std"); const builtin = @import("builtin"); const glfw = @import("glfw"); +const wuffs = @import("wuffs"); const assert = std.debug.assert; const testing = std.testing; const Allocator = std.mem.Allocator; @@ -24,6 +25,7 @@ const math = @import("../math.zig"); const Surface = @import("../Surface.zig"); const CellProgram = @import("opengl/CellProgram.zig"); +const BackgroundImageProgram = @import("opengl/BackgroundImageProgram.zig"); const ImageProgram = @import("opengl/ImageProgram.zig"); const gl_image = @import("opengl/image.zig"); const custom = @import("opengl/custom.zig"); @@ -43,6 +45,9 @@ else const DrawMutex = if (single_threaded_draw) std.Thread.Mutex else void; const drawMutexZero = if (DrawMutex == void) void{} else .{}; +/// The maximum size of a background image. +const max_image_size = 400 * 1024 * 1024; // 400MB + alloc: std.mem.Allocator, /// The configuration we need derived from the main config. @@ -135,6 +140,19 @@ draw_mutex: DrawMutex = drawMutexZero, /// terminal is in reversed mode. draw_background: terminal.color.RGB, +/// The background image(s) to draw. Currently, we always draw the last image. +background_image: configpkg.SinglePath, + +/// The opacity of the background image. Not to be confused with background-opacity +background_image_opacity: f32, + +/// The background image mode to use. +background_image_mode: configpkg.BackgroundImageMode, + +/// The current background image to draw. If it is null, then we will not +/// draw any background image. +current_background_image: ?Image = null, + /// Whether we're doing padding extension for vertical sides. padding_extend_top: bool = true, padding_extend_bottom: bool = true, @@ -183,7 +201,7 @@ const SetScreenSize = struct { ); // Update the projection uniform within our shader - inline for (.{ "cell_program", "image_program" }) |name| { + inline for (.{ "cell_program", "image_program", "bgimage_program" }) |name| { const program = @field(gl_state, name); const bind = try program.program.use(); defer bind.unbind(); @@ -281,6 +299,9 @@ pub const DerivedConfig = struct { cursor_opacity: f64, background: terminal.color.RGB, background_opacity: f64, + background_image: configpkg.SinglePath, + background_image_opacity: f32, + background_image_mode: configpkg.BackgroundImageMode, foreground: terminal.color.RGB, selection_background: ?terminal.color.RGB, selection_foreground: ?terminal.color.RGB, @@ -302,6 +323,9 @@ pub const DerivedConfig = struct { // Copy our shaders const custom_shaders = try config.@"custom-shader".clone(alloc); + // Copy our background image + const background_image = try config.@"background-image".clone(alloc); + // Copy our font features const font_features = try config.@"font-feature".clone(alloc); @@ -342,6 +366,11 @@ pub const DerivedConfig = struct { .background = config.background.toTerminalRGB(), .foreground = config.foreground.toTerminalRGB(), + + .background_image = background_image, + .background_image_opacity = config.@"background-image-opacity", + .background_image_mode = config.@"background-image-mode", + .invert_selection_fg_bg = config.@"selection-invert-fg-bg", .bold_is_bright = config.@"bold-is-bright", .min_contrast = @floatCast(config.@"minimum-contrast"), @@ -406,6 +435,9 @@ pub fn init(alloc: Allocator, options: renderer.Options) !OpenGL { .default_background_color = options.config.background, .cursor_color = null, .default_cursor_color = options.config.cursor_color, + .background_image = options.config.background_image, + .background_image_opacity = options.config.background_image_opacity, + .background_image_mode = options.config.background_image_mode, .cursor_invert = options.config.cursor_invert, .surface_mailbox = options.surface_mailbox, .deferred_font_size = .{ .metrics = grid.metrics }, @@ -795,6 +827,19 @@ pub fn updateFrame( try self.prepKittyGraphics(state.terminal); } + if (self.current_background_image == null and + self.background_image.value != null) + { + if (single_threaded_draw) self.draw_mutex.lock(); + defer if (single_threaded_draw) self.draw_mutex.unlock(); + self.prepBackgroundImage() catch |err| switch (err) { + error.InvalidData => { + log.warn("invalid image data, skipping", .{}); + }, + else => return err, + }; + } + // If we have any terminal dirty flags set then we need to rebuild // the entire screen. This can be optimized in the future. const full_rebuild: bool = rebuild: { @@ -1186,6 +1231,82 @@ fn prepKittyImage( gop.value_ptr.transmit_time = image.transmit_time; } +/// Prepares the current background image for upload +pub fn prepBackgroundImage(self: *OpenGL) !void { + // If the user doesn't have a background image, do nothing... + const path = self.background_image.value orelse return; + + // Read the file content + const file_content = try self.readImageContent(path); + defer self.alloc.free(file_content); + + // Decode the png (currently, we only support png) + const decoded_image: wuffs.ImageData = blk: { + // Extract the file extension + const ext = std.fs.path.extension(path); + const ext_lower = try std.ascii.allocLowerString(self.alloc, ext); + defer self.alloc.free(ext_lower); + + // Match based on extension + if (std.mem.eql(u8, ext_lower, ".png")) { + break :blk try wuffs.png.decode(self.alloc, file_content); + } else if (std.mem.eql(u8, ext_lower, ".jpg") or std.mem.eql(u8, ext_lower, ".jpeg")) { + break :blk try wuffs.jpeg.decode(self.alloc, file_content); + } else { + log.warn("unsupported image format: {s}", .{ext}); + return error.InvalidData; + } + }; + defer self.alloc.free(decoded_image.data); + + // Copy the data into the pending state + const data = try self.alloc.dupe(u8, decoded_image.data); + errdefer self.alloc.free(data); + const pending: Image.Pending = .{ + .width = decoded_image.width, + .height = decoded_image.height, + .data = data.ptr, + }; + + // Store the image + self.current_background_image = .{ .pending_rgba = pending }; +} + +/// Reads the content of the given image path and returns it +pub fn readImageContent(self: *OpenGL, path: []const u8) ![]u8 { + assert(std.fs.path.isAbsolute(path)); + // Open the file + var file = std.fs.openFileAbsolute(path, .{}) catch |err| { + log.warn("failed to open file: {}", .{err}); + return error.InvalidData; + }; + defer file.close(); + + // File must be a regular file + if (file.stat()) |stat| { + if (stat.kind != .file) { + log.warn("file is not a regular file kind={}", .{stat.kind}); + return error.InvalidData; + } + } else |err| { + log.warn("failed to stat file: {}", .{err}); + return error.InvalidData; + } + + var buf_reader = std.io.bufferedReader(file.reader()); + const reader = buf_reader.reader(); + + // Read the file + var managed = std.ArrayList(u8).init(self.alloc); + errdefer managed.deinit(); + reader.readAllArrayList(&managed, max_image_size) catch |err| { + log.warn("failed to read file: {}", .{err}); + return error.InvalidData; + }; + + return managed.toOwnedSlice(); +} + /// rebuildCells rebuilds all the GPU cells from our CPU state. This is a /// slow operation but ensures that the GPU state exactly matches the CPU state. /// In steady-state operation, we use some GPU tricks to send down stale data @@ -2176,6 +2297,14 @@ pub fn changeConfig(self: *OpenGL, config: *DerivedConfig) !void { self.default_cursor_color = if (!config.cursor_invert) config.cursor_color else null; self.cursor_invert = config.cursor_invert; + // Reset current background image + self.background_image = config.background_image; + self.background_image_opacity = config.background_image_opacity; + self.background_image_mode = config.background_image_mode; + if (self.current_background_image) |*img| { + img.markForUnload(); + } + // Update our uniforms self.deferred_config = .{}; @@ -2320,6 +2449,31 @@ pub fn drawFrame(self: *OpenGL, surface: *apprt.Surface) !void { } } + // Check if we need to update our current background image + if (self.current_background_image) |current_background_image| { + switch (current_background_image) { + .ready => {}, + + .pending_gray, + .pending_gray_alpha, + .pending_rgb, + .pending_rgba, + .replace_gray, + .replace_gray_alpha, + .replace_rgb, + .replace_rgba, + => try self.current_background_image.?.upload(self.alloc), + + .unload_pending, + .unload_replace, + .unload_ready, + => { + self.current_background_image.?.deinit(self.alloc); + self.current_background_image = null; + }, + } + } + // In the "OpenGL Programming Guide for Mac" it explains that: "When you // use an NSOpenGLView object with OpenGL calls that are issued from a // thread other than the main one, you must set up mutex locking." @@ -2427,6 +2581,9 @@ fn drawCellProgram( ); } + // Draw our background image if defined + try self.drawBackgroundImage(gl_state); + // Draw background images first try self.drawImages( gl_state, @@ -2452,6 +2609,45 @@ fn drawCellProgram( ); } +fn drawBackgroundImage( + self: *OpenGL, + gl_state: *const GLState, +) !void { + // If we don't have a background image, just return + const current_background_image = self.current_background_image orelse return; + + // Bind our background image program + const bind = try gl_state.bgimage_program.bind(); + defer bind.unbind(); + + // Get the texture + const texture = switch (current_background_image) { + .ready => |t| t, + else => { + return; + }, + }; + + // Bind the texture + try gl.Texture.active(gl.c.GL_TEXTURE0); + var texbind = try texture.bind(.@"2D"); + defer texbind.unbind(); + + try bind.vbo.setData(BackgroundImageProgram.Input{ + .terminal_width = self.size.terminal().width, + .terminal_height = self.size.terminal().height, + .mode = self.background_image_mode, + }, .static_draw); + try gl_state.bgimage_program.program.setUniform("opacity", self.config.background_image_opacity); + + try gl.drawElementsInstanced( + gl.c.GL_TRIANGLES, + 6, + gl.c.GL_UNSIGNED_BYTE, + 1, + ); +} + /// Runs the image program to draw images. fn drawImages( self: *OpenGL, @@ -2577,6 +2773,7 @@ fn drawCells( /// easy to create/destroy these as a set in situations i.e. where the /// OpenGL context is replaced. const GLState = struct { + bgimage_program: BackgroundImageProgram, cell_program: CellProgram, image_program: ImageProgram, texture: gl.Texture, @@ -2662,6 +2859,10 @@ const GLState = struct { ); } + // Build our background image renderer + const bgimage_program = try BackgroundImageProgram.init(); + errdefer bgimage_program.deinit(); + // Build our cell renderer const cell_program = try CellProgram.init(); errdefer cell_program.deinit(); @@ -2671,6 +2872,7 @@ const GLState = struct { errdefer image_program.deinit(); return .{ + .bgimage_program = bgimage_program, .cell_program = cell_program, .image_program = image_program, .texture = tex, @@ -2683,6 +2885,7 @@ const GLState = struct { if (self.custom) |v| v.deinit(alloc); self.texture.destroy(); self.texture_color.destroy(); + self.bgimage_program.deinit(); self.image_program.deinit(); self.cell_program.deinit(); } diff --git a/src/renderer/opengl/BackgroundImageProgram.zig b/src/renderer/opengl/BackgroundImageProgram.zig new file mode 100644 index 0000000000..d73c2b98ee --- /dev/null +++ b/src/renderer/opengl/BackgroundImageProgram.zig @@ -0,0 +1,116 @@ +/// The OpenGL program for rendering terminal cells. +const BackgroundImageProgram = @This(); + +const std = @import("std"); +const gl = @import("opengl"); +const configpkg = @import("../../config.zig"); + +pub const Input = extern struct { + /// vec2 terminal_size + terminal_width: u32 = 0, + terminal_height: u32 = 0, + + /// uint mode + mode: configpkg.BackgroundImageMode = .zoomed, +}; + +program: gl.Program, +vao: gl.VertexArray, +ebo: gl.Buffer, +vbo: gl.Buffer, + +pub fn init() !BackgroundImageProgram { + // Load and compile our shaders. + const program = try gl.Program.createVF( + @embedFile("../shaders/bgimage.v.glsl"), + @embedFile("../shaders/bgimage.f.glsl"), + ); + errdefer program.destroy(); + + // Set our program uniforms + const pbind = try program.use(); + defer pbind.unbind(); + + // Set all of our texture indexes + try program.setUniform("image", 0); + + // Setup our VAO + const vao = try gl.VertexArray.create(); + errdefer vao.destroy(); + const vaobind = try vao.bind(); + defer vaobind.unbind(); + + // Element buffer (EBO) + const ebo = try gl.Buffer.create(); + errdefer ebo.destroy(); + var ebobind = try ebo.bind(.element_array); + defer ebobind.unbind(); + try ebobind.setData([6]u8{ + 0, 1, 3, // Top-left triangle + 1, 2, 3, // Bottom-right triangle + }, .static_draw); + + // Vertex buffer (VBO) + const vbo = try gl.Buffer.create(); + errdefer vbo.destroy(); + var vbobind = try vbo.bind(.array); + defer vbobind.unbind(); + var offset: usize = 0; + try vbobind.attributeAdvanced(0, 2, gl.c.GL_UNSIGNED_INT, false, @sizeOf(Input), offset); + offset += 2 * @sizeOf(u32); + try vbobind.attributeIAdvanced(1, 1, gl.c.GL_UNSIGNED_BYTE, @sizeOf(Input), offset); + offset += 1 * @sizeOf(u8); + try vbobind.enableAttribArray(0); + try vbobind.enableAttribArray(1); + try vbobind.attributeDivisor(0, 1); + try vbobind.attributeDivisor(1, 1); + + return .{ + .program = program, + .vao = vao, + .ebo = ebo, + .vbo = vbo, + }; +} + +pub fn bind(self: BackgroundImageProgram) !Binding { + const program = try self.program.use(); + errdefer program.unbind(); + + const vao = try self.vao.bind(); + errdefer vao.unbind(); + + const ebo = try self.ebo.bind(.element_array); + errdefer ebo.unbind(); + + const vbo = try self.vbo.bind(.array); + errdefer vbo.unbind(); + + return .{ + .program = program, + .vao = vao, + .ebo = ebo, + .vbo = vbo, + }; +} + +pub fn deinit(self: BackgroundImageProgram) void { + self.ebo.destroy(); + self.vao.destroy(); + self.vbo.destroy(); + self.program.destroy(); +} + +pub const Binding = struct { + program: gl.Program.Binding, + vao: gl.VertexArray.Binding, + ebo: gl.Buffer.Binding, + vbo: gl.Buffer.Binding, + + pub fn unbind(self: Binding) void { + self.ebo.unbind(); + self.vao.unbind(); + self.vbo.unbind(); + self.program.unbind(); + } +}; diff --git a/src/renderer/shaders/bgimage.f.glsl b/src/renderer/shaders/bgimage.f.glsl new file mode 100644 index 0000000000..6598255380 --- /dev/null +++ b/src/renderer/shaders/bgimage.f.glsl @@ -0,0 +1,16 @@ +#version 330 core + +in vec2 tex_coord; + +layout(location = 0) out vec4 out_FragColor; + +uniform sampler2D image; +uniform float opacity; + +void main() { + // Normalize the coordinates + vec2 norm_coord = tex_coord; + norm_coord = fract(tex_coord); + vec4 color = texture(image, norm_coord); + out_FragColor = vec4(color.rgb * color.a * opacity, color.a * opacity); +} diff --git a/src/renderer/shaders/bgimage.v.glsl b/src/renderer/shaders/bgimage.v.glsl new file mode 100644 index 0000000000..0834b7c297 --- /dev/null +++ b/src/renderer/shaders/bgimage.v.glsl @@ -0,0 +1,92 @@ +#version 330 core + +// These are the possible modes that "mode" can be set to. +// +// NOTE: this must be kept in sync with the BackgroundImageMode +const uint MODE_ZOOMED = 0u; +const uint MODE_STRETCHED = 1u; +const uint MODE_TILED = 2u; +const uint MODE_CENTERED = 3u; +const uint MODE_UPPER_LEFT = 4u; +const uint MODE_UPPER_RIGHT = 5u; +const uint MODE_LOWER_LEFT = 6u; +const uint MODE_LOWER_RIGHT = 7u; + +layout (location = 0) in vec2 terminal_size; +layout (location = 1) in uint mode; + +out vec2 tex_coord; + +uniform sampler2D image; +uniform mat4 projection; + +void main() { + // Calculate the position of the image + vec2 position; + position.x = (gl_VertexID == 0 || gl_VertexID == 1) ? 1. : 0.; + position.y = (gl_VertexID == 0 || gl_VertexID == 3) ? 0. : 1.; + + // Get the size of the image + vec2 image_size = textureSize(image, 0); + + // Handles the scale of the image relative to the terminal size + vec2 scale = vec2(1.0, 1.0); + + switch (mode) { + case MODE_ZOOMED: + // If zoomed, we want to scale the image to fit the terminal + vec2 aspect_ratio = vec2( + terminal_size.x / terminal_size.y, + image_size.x / image_size.y + ); + if (aspect_ratio.x > aspect_ratio.y) { + scale.x = aspect_ratio.y / aspect_ratio.x; + } + else { + scale.y = aspect_ratio.x / aspect_ratio.y; + } + break; + case MODE_CENTERED: + case MODE_UPPER_LEFT: + case MODE_UPPER_RIGHT: + case MODE_LOWER_LEFT: + case MODE_LOWER_RIGHT: + // If centered, the final scale of the image should match the actual + // size of the image and should be centered + scale.x = image_size.x / terminal_size.x; + scale.y = image_size.y / terminal_size.y; + break; + case MODE_STRETCHED: + case MODE_TILED: + // We don't need to do anything for stretched or tiled + break; + } + + vec2 final_image_size = terminal_size * position * scale; + vec2 offset = vec2(0.0, 0.0); + switch (mode) { + case MODE_ZOOMED: + case MODE_STRETCHED: + case MODE_TILED: + case MODE_CENTERED: + offset = (terminal_size * (1.0 - scale)) / 2.0; + break; + case MODE_UPPER_LEFT: + offset = vec2(0.0, 0.0); + break; + case MODE_UPPER_RIGHT: + offset = vec2(terminal_size.x - image_size.x, 0.0); + break; + case MODE_LOWER_LEFT: + offset = vec2(0.0, terminal_size.y - image_size.y); + break; + case MODE_LOWER_RIGHT: + offset = vec2(terminal_size.x - image_size.x, terminal_size.y - image_size.y); + break; + } + gl_Position = projection * vec4(final_image_size.xy + offset, 0.0, 1.0); + tex_coord = position; + if (mode == MODE_TILED) { + tex_coord = position * terminal_size / image_size; + } +}