mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-07-24 04:36:10 +03:00

Fixes #1037 Renderers must convert the internal Kitty graphics state to a GPU texture for rendering. For performance reasons, renderers cache the GPU state by image ID. Unfortunately, this meant that if an image was replaced with the same ID and was already cached, it would never be updated on the GPU. This PR adds the transmission time to the cache. If the transmission time differs, we assume the image changed and replace the image.
378 lines
12 KiB
Zig
378 lines
12 KiB
Zig
const std = @import("std");
|
|
const Allocator = std.mem.Allocator;
|
|
const assert = std.debug.assert;
|
|
const objc = @import("objc");
|
|
|
|
const mtl = @import("api.zig");
|
|
|
|
/// Represents a single image placement on the grid. A placement is a
|
|
/// request to render an instance of an image.
|
|
pub const Placement = struct {
|
|
/// The image being rendered. This MUST be in the image map.
|
|
image_id: u32,
|
|
|
|
/// The grid x/y where this placement is located.
|
|
x: u32,
|
|
y: u32,
|
|
z: i32,
|
|
|
|
/// The width/height of the placed image.
|
|
width: u32,
|
|
height: u32,
|
|
|
|
/// The offset in pixels from the top left of the cell. This is
|
|
/// clamped to the size of a cell.
|
|
cell_offset_x: u32,
|
|
cell_offset_y: u32,
|
|
|
|
/// The source rectangle of the placement.
|
|
source_x: u32,
|
|
source_y: u32,
|
|
source_width: u32,
|
|
source_height: u32,
|
|
};
|
|
|
|
/// The map used for storing images.
|
|
pub const ImageMap = std.AutoHashMapUnmanaged(u32, struct {
|
|
image: Image,
|
|
transmit_time: std.time.Instant,
|
|
});
|
|
|
|
/// The state for a single image that is to be rendered. The image can be
|
|
/// pending upload or ready to use with a texture.
|
|
pub const Image = union(enum) {
|
|
/// The image is pending upload to the GPU. The different keys are
|
|
/// different formats since some formats aren't accepted by the GPU
|
|
/// and require conversion.
|
|
///
|
|
/// This data is owned by this union so it must be freed once the
|
|
/// image is uploaded.
|
|
pending_rgb: Pending,
|
|
pending_rgba: Pending,
|
|
|
|
/// This is the same as the pending states but there is a texture
|
|
/// already allocated that we want to replace.
|
|
replace_rgb: Replace,
|
|
replace_rgba: Replace,
|
|
|
|
/// The image is uploaded and ready to be used.
|
|
ready: objc.Object, // MTLTexture
|
|
|
|
/// The image is uploaded but is scheduled to be unloaded.
|
|
unload_pending: []u8,
|
|
unload_ready: objc.Object, // MTLTexture
|
|
unload_replace: struct { []u8, objc.Object },
|
|
|
|
pub const Replace = struct {
|
|
texture: objc.Object,
|
|
pending: Pending,
|
|
};
|
|
|
|
/// Pending image data that needs to be uploaded to the GPU.
|
|
pub const Pending = struct {
|
|
height: u32,
|
|
width: u32,
|
|
|
|
/// Data is always expected to be (width * height * depth). Depth
|
|
/// is based on the union key.
|
|
data: [*]u8,
|
|
|
|
pub fn dataSlice(self: Pending, d: u32) []u8 {
|
|
return self.data[0..self.len(d)];
|
|
}
|
|
|
|
pub fn len(self: Pending, d: u32) u32 {
|
|
return self.width * self.height * d;
|
|
}
|
|
};
|
|
|
|
pub fn deinit(self: Image, alloc: Allocator) void {
|
|
switch (self) {
|
|
.pending_rgb => |p| alloc.free(p.dataSlice(3)),
|
|
.pending_rgba => |p| alloc.free(p.dataSlice(4)),
|
|
.unload_pending => |data| alloc.free(data),
|
|
|
|
.replace_rgb => |r| {
|
|
alloc.free(r.pending.dataSlice(3));
|
|
r.texture.msgSend(void, objc.sel("release"), .{});
|
|
},
|
|
|
|
.replace_rgba => |r| {
|
|
alloc.free(r.pending.dataSlice(4));
|
|
r.texture.msgSend(void, objc.sel("release"), .{});
|
|
},
|
|
|
|
.unload_replace => |r| {
|
|
alloc.free(r[0]);
|
|
r[1].msgSend(void, objc.sel("release"), .{});
|
|
},
|
|
|
|
.ready,
|
|
.unload_ready,
|
|
=> |obj| obj.msgSend(void, objc.sel("release"), .{}),
|
|
}
|
|
}
|
|
|
|
/// Mark this image for unload whatever state it is in.
|
|
pub fn markForUnload(self: *Image) void {
|
|
self.* = switch (self.*) {
|
|
.unload_pending,
|
|
.unload_replace,
|
|
.unload_ready,
|
|
=> return,
|
|
|
|
.ready => |obj| .{ .unload_ready = obj },
|
|
.pending_rgb => |p| .{ .unload_pending = p.dataSlice(3) },
|
|
.pending_rgba => |p| .{ .unload_pending = p.dataSlice(4) },
|
|
.replace_rgb => |r| .{ .unload_replace = .{
|
|
r.pending.dataSlice(3), r.texture,
|
|
} },
|
|
.replace_rgba => |r| .{ .unload_replace = .{
|
|
r.pending.dataSlice(4), r.texture,
|
|
} },
|
|
};
|
|
}
|
|
|
|
/// Replace the currently pending image with a new one. This will
|
|
/// attempt to update the existing texture if it is already allocated.
|
|
/// If the texture is not allocated, this will act like a new upload.
|
|
///
|
|
/// This function only marks the image for replace. The actual logic
|
|
/// to replace is done later.
|
|
pub fn markForReplace(self: *Image, alloc: Allocator, img: Image) !void {
|
|
assert(img.pending() != null);
|
|
|
|
// Get our existing texture. This switch statement will also handle
|
|
// scenarios where there is no existing texture and we can modify
|
|
// the self pointer directly.
|
|
const existing: objc.Object = switch (self.*) {
|
|
// For pending, we can free the old data and become pending ourselves.
|
|
.pending_rgb => |p| {
|
|
alloc.free(p.dataSlice(3));
|
|
self.* = img;
|
|
return;
|
|
},
|
|
|
|
.pending_rgba => |p| {
|
|
alloc.free(p.dataSlice(4));
|
|
self.* = img;
|
|
return;
|
|
},
|
|
|
|
// If we're marked for unload but we just have pending data,
|
|
// this behaves the same as a normal "pending": free the data,
|
|
// become new pending.
|
|
.unload_pending => |data| {
|
|
alloc.free(data);
|
|
self.* = img;
|
|
return;
|
|
},
|
|
|
|
.unload_replace => |r| existing: {
|
|
alloc.free(r[0]);
|
|
break :existing r[1];
|
|
},
|
|
|
|
// If we were already pending a replacement, then we free our
|
|
// existing pending data and use the same texture.
|
|
.replace_rgb => |r| existing: {
|
|
alloc.free(r.pending.dataSlice(3));
|
|
break :existing r.texture;
|
|
},
|
|
|
|
.replace_rgba => |r| existing: {
|
|
alloc.free(r.pending.dataSlice(4));
|
|
break :existing r.texture;
|
|
},
|
|
|
|
// For both ready and unload_ready, we need to replace the
|
|
// texture. We can't do that here, so we just mark ourselves
|
|
// for replacement.
|
|
.ready, .unload_ready => |tex| tex,
|
|
};
|
|
|
|
// We now have an existing texture, so set the proper replace key.
|
|
self.* = switch (img) {
|
|
.pending_rgb => |p| .{ .replace_rgb = .{
|
|
.texture = existing,
|
|
.pending = p,
|
|
} },
|
|
|
|
.pending_rgba => |p| .{ .replace_rgba = .{
|
|
.texture = existing,
|
|
.pending = p,
|
|
} },
|
|
|
|
else => unreachable,
|
|
};
|
|
}
|
|
|
|
/// Returns true if this image is pending upload.
|
|
pub fn isPending(self: Image) bool {
|
|
return self.pending() != null;
|
|
}
|
|
|
|
/// Returns true if this image is pending an unload.
|
|
pub fn isUnloading(self: Image) bool {
|
|
return switch (self) {
|
|
.unload_pending,
|
|
.unload_ready,
|
|
=> true,
|
|
|
|
.ready,
|
|
.pending_rgb,
|
|
.pending_rgba,
|
|
=> false,
|
|
};
|
|
}
|
|
|
|
/// Converts the image data to a format that can be uploaded to the GPU.
|
|
/// If the data is already in a format that can be uploaded, this is a
|
|
/// no-op.
|
|
pub fn convert(self: *Image, alloc: Allocator) !void {
|
|
switch (self.*) {
|
|
.ready,
|
|
.unload_pending,
|
|
.unload_replace,
|
|
.unload_ready,
|
|
=> unreachable, // invalid
|
|
|
|
.pending_rgba,
|
|
.replace_rgba,
|
|
=> {}, // ready
|
|
|
|
// RGB needs to be converted to RGBA because Metal textures
|
|
// don't support RGB.
|
|
.pending_rgb => |*p| {
|
|
// Note: this is the slowest possible way to do this...
|
|
const data = p.dataSlice(3);
|
|
const rgba = try rgbToRgba(alloc, data);
|
|
alloc.free(data);
|
|
p.data = rgba.ptr;
|
|
self.* = .{ .pending_rgba = p.* };
|
|
},
|
|
|
|
.replace_rgb => |*r| {
|
|
const data = r.pending.dataSlice(3);
|
|
const rgba = try rgbToRgba(alloc, data);
|
|
alloc.free(data);
|
|
r.pending.data = rgba.ptr;
|
|
self.* = .{ .replace_rgba = r.* };
|
|
},
|
|
}
|
|
}
|
|
|
|
fn rgbToRgba(alloc: Allocator, data: []const u8) ![]u8 {
|
|
const pixels = data.len / 3;
|
|
var rgba = try alloc.alloc(u8, pixels * 4);
|
|
errdefer alloc.free(rgba);
|
|
var i: usize = 0;
|
|
while (i < pixels) : (i += 1) {
|
|
const data_i = i * 3;
|
|
const rgba_i = i * 4;
|
|
rgba[rgba_i] = data[data_i];
|
|
rgba[rgba_i + 1] = data[data_i + 1];
|
|
rgba[rgba_i + 2] = data[data_i + 2];
|
|
rgba[rgba_i + 3] = 255;
|
|
}
|
|
|
|
return rgba;
|
|
}
|
|
|
|
/// Upload the pending image to the GPU and change the state of this
|
|
/// image to ready.
|
|
pub fn upload(
|
|
self: *Image,
|
|
alloc: Allocator,
|
|
device: objc.Object,
|
|
) !void {
|
|
// Convert our data if we have to
|
|
try self.convert(alloc);
|
|
|
|
// Get our pending info
|
|
const p = self.pending().?;
|
|
|
|
// Create our texture
|
|
const texture = try initTexture(p, device);
|
|
errdefer texture.msgSend(void, objc.sel("release"), .{});
|
|
|
|
// Upload our data
|
|
const d = self.depth();
|
|
texture.msgSend(
|
|
void,
|
|
objc.sel("replaceRegion:mipmapLevel:withBytes:bytesPerRow:"),
|
|
.{
|
|
mtl.MTLRegion{
|
|
.origin = .{ .x = 0, .y = 0, .z = 0 },
|
|
.size = .{
|
|
.width = @intCast(p.width),
|
|
.height = @intCast(p.height),
|
|
.depth = 1,
|
|
},
|
|
},
|
|
@as(c_ulong, 0),
|
|
@as(*const anyopaque, p.data),
|
|
@as(c_ulong, d * p.width),
|
|
},
|
|
);
|
|
|
|
// Uploaded. We can now clear our data and change our state.
|
|
//
|
|
// NOTE: For "replace_*" states, this will free the old texture.
|
|
// We don't currently actually replace the existing texture in-place
|
|
// but that is an optimization we can do later.
|
|
self.deinit(alloc);
|
|
self.* = .{ .ready = texture };
|
|
}
|
|
|
|
/// Our pixel depth
|
|
fn depth(self: Image) u32 {
|
|
return switch (self) {
|
|
.pending_rgb => 3,
|
|
.pending_rgba => 4,
|
|
.replace_rgb => 3,
|
|
.replace_rgba => 4,
|
|
else => unreachable,
|
|
};
|
|
}
|
|
|
|
/// Returns true if this image is in a pending state and requires upload.
|
|
fn pending(self: Image) ?Pending {
|
|
return switch (self) {
|
|
.pending_rgb,
|
|
.pending_rgba,
|
|
=> |p| p,
|
|
|
|
.replace_rgb,
|
|
.replace_rgba,
|
|
=> |r| r.pending,
|
|
|
|
else => null,
|
|
};
|
|
}
|
|
|
|
fn initTexture(p: Pending, device: objc.Object) !objc.Object {
|
|
// Create our descriptor
|
|
const desc = init: {
|
|
const Class = objc.getClass("MTLTextureDescriptor").?;
|
|
const id_alloc = Class.msgSend(objc.Object, objc.sel("alloc"), .{});
|
|
const id_init = id_alloc.msgSend(objc.Object, objc.sel("init"), .{});
|
|
break :init id_init;
|
|
};
|
|
|
|
// Set our properties
|
|
desc.setProperty("pixelFormat", @intFromEnum(mtl.MTLPixelFormat.rgba8uint));
|
|
desc.setProperty("width", @as(c_ulong, @intCast(p.width)));
|
|
desc.setProperty("height", @as(c_ulong, @intCast(p.height)));
|
|
|
|
// Initialize
|
|
const id = device.msgSend(
|
|
?*anyopaque,
|
|
objc.sel("newTextureWithDescriptor:"),
|
|
.{desc},
|
|
) orelse return error.MetalFailed;
|
|
|
|
return objc.Object.fromId(id);
|
|
}
|
|
};
|