aboutsummaryrefslogtreecommitdiff
path: root/vendor/libghostty-vt/src/renderer/Metal.zig
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-04-04 19:22:04 +0300
committerCan Celik <ogulcancelik@gmail.com>2026-04-05 01:23:06 +0300
commit41dd733ca7123c9ac4bfc6c781697a660e95b052 (patch)
tree3fc91815ca139ef2e0b281bba0abda4ef15175f4 /vendor/libghostty-vt/src/renderer/Metal.zig
parent55c138263e190a1e79d4b997bfb9397306c0ec1f (diff)
feat: finish ghostty pane backend integration
Diffstat (limited to 'vendor/libghostty-vt/src/renderer/Metal.zig')
-rw-r--r--vendor/libghostty-vt/src/renderer/Metal.zig458
1 files changed, 458 insertions, 0 deletions
diff --git a/vendor/libghostty-vt/src/renderer/Metal.zig b/vendor/libghostty-vt/src/renderer/Metal.zig
new file mode 100644
index 00000000..6c7432d2
--- /dev/null
+++ b/vendor/libghostty-vt/src/renderer/Metal.zig
@@ -0,0 +1,458 @@
+//! Graphics API wrapper for Metal.
+pub const Metal = @This();
+
+const std = @import("std");
+const assert = @import("../quirks.zig").inlineAssert;
+const Allocator = std.mem.Allocator;
+const builtin = @import("builtin");
+const objc = @import("objc");
+const macos = @import("macos");
+const graphics = macos.graphics;
+const apprt = @import("../apprt.zig");
+const font = @import("../font/main.zig");
+const configpkg = @import("../config.zig");
+const rendererpkg = @import("../renderer.zig");
+const Renderer = rendererpkg.GenericRenderer(Metal);
+const shadertoy = @import("shadertoy.zig");
+
+const mtl = @import("metal/api.zig");
+const IOSurfaceLayer = @import("metal/IOSurfaceLayer.zig");
+
+pub const GraphicsAPI = Metal;
+pub const Target = @import("metal/Target.zig");
+pub const Frame = @import("metal/Frame.zig");
+pub const RenderPass = @import("metal/RenderPass.zig");
+pub const Pipeline = @import("metal/Pipeline.zig");
+const bufferpkg = @import("metal/buffer.zig");
+pub const Buffer = bufferpkg.Buffer;
+pub const Sampler = @import("metal/Sampler.zig");
+pub const Texture = @import("metal/Texture.zig");
+pub const shaders = @import("metal/shaders.zig");
+
+pub const custom_shader_target: shadertoy.Target = .msl;
+// The fragCoord for Metal shaders is +Y = down.
+pub const custom_shader_y_is_down = true;
+
+/// Triple buffering.
+pub const swap_chain_count = 3;
+
+const log = std.log.scoped(.metal);
+
+layer: IOSurfaceLayer,
+
+/// MTLDevice
+device: objc.Object,
+/// MTLCommandQueue
+queue: objc.Object,
+
+/// Alpha blending mode
+blending: configpkg.Config.AlphaBlending,
+
+/// The default storage mode to use for resources created with our device.
+///
+/// This is based on whether the device is a discrete GPU or not, since
+/// discrete GPUs do not have unified memory and therefore do not support
+/// the "shared" storage mode, instead we have to use the "managed" mode.
+default_storage_mode: mtl.MTLResourceOptions.StorageMode,
+
+/// The maximum 2D texture width and height supported by the device.
+max_texture_size: u32,
+
+/// We start an AutoreleasePool before `drawFrame` and end it afterwards.
+autorelease_pool: ?*objc.AutoreleasePool = null,
+
+pub fn init(alloc: Allocator, opts: rendererpkg.Options) !Metal {
+ comptime switch (builtin.os.tag) {
+ .macos, .ios => {},
+ else => @compileError("unsupported platform for Metal"),
+ };
+
+ _ = alloc;
+
+ // Choose our MTLDevice and create a MTLCommandQueue for that device.
+ const device = try chooseDevice();
+ errdefer device.release();
+ const queue = device.msgSend(objc.Object, objc.sel("newCommandQueue"), .{});
+ errdefer queue.release();
+
+ // Grab metadata about the device.
+ const default_storage_mode: mtl.MTLResourceOptions.StorageMode = switch (comptime builtin.os.tag) {
+ // manage mode is not supported by iOS
+ .ios => .shared,
+ else => if (device.getProperty(bool, "hasUnifiedMemory")) .shared else .managed,
+ };
+ const max_texture_size = queryMaxTextureSize(device);
+ log.debug(
+ "device properties default_storage_mode={} max_texture_size={}",
+ .{ default_storage_mode, max_texture_size },
+ );
+
+ const ViewInfo = struct {
+ view: objc.Object,
+ scaleFactor: f64,
+ };
+
+ // Get the metadata about our underlying view that we'll be rendering to.
+ const info: ViewInfo = switch (apprt.runtime) {
+ apprt.embedded => .{
+ .scaleFactor = @floatCast(opts.rt_surface.content_scale.x),
+ .view = switch (opts.rt_surface.platform) {
+ .macos => |v| v.nsview,
+ .ios => |v| v.uiview,
+ },
+ },
+
+ else => @compileError("unsupported apprt for metal"),
+ };
+
+ // Create an IOSurfaceLayer which we can assign to the view to make
+ // it in to a "layer-hosting view", so that we can manually control
+ // the layer contents.
+ var layer = try IOSurfaceLayer.init();
+ errdefer layer.release();
+
+ // Add our layer to the view.
+ //
+ // On macOS we do this by making the view "layer-hosting"
+ // by assigning it to the view's `layer` property BEFORE
+ // setting `wantsLayer` to `true`.
+ //
+ // On iOS, views are always layer-backed, and `layer`
+ // is readonly, so instead we add it as a sublayer.
+ switch (comptime builtin.os.tag) {
+ .macos => {
+ info.view.setProperty("layer", layer.layer.value);
+ info.view.setProperty("wantsLayer", true);
+ },
+
+ .ios => {
+ const view_layer = objc.Object.fromId(info.view.getProperty(?*anyopaque, "layer"));
+ view_layer.msgSend(void, objc.sel("addSublayer:"), .{layer.layer.value});
+ },
+
+ else => @compileError("unsupported target for Metal"),
+ }
+
+ // Ensure that if our layer is oversized it
+ // does not overflow the bounds of the view.
+ info.view.setProperty("clipsToBounds", true);
+
+ // Ensure that our layer has a content scale set to
+ // match the scale factor of the window. This avoids
+ // magnification issues leading to blurry rendering.
+ layer.layer.setProperty("contentsScale", info.scaleFactor);
+
+ // This makes it so that our display callback will actually be called.
+ layer.layer.setProperty("needsDisplayOnBoundsChange", true);
+
+ return .{
+ .layer = layer,
+ .device = device,
+ .queue = queue,
+ .blending = opts.config.blending,
+ .default_storage_mode = default_storage_mode,
+ .max_texture_size = max_texture_size,
+ };
+}
+
+pub fn deinit(self: *Metal) void {
+ self.queue.release();
+ self.device.release();
+ self.layer.release();
+}
+
+pub fn loopEnter(self: *Metal) void {
+ const renderer: *align(1) Renderer = @fieldParentPtr("api", self);
+ self.layer.setDisplayCallback(
+ @ptrCast(&displayCallback),
+ @ptrCast(renderer),
+ );
+}
+
+fn displayCallback(renderer: *Renderer) align(8) void {
+ renderer.drawFrame(true) catch |err| {
+ log.warn("Error drawing frame in display callback, err={}", .{err});
+ };
+}
+
+/// Actions taken before doing anything in `drawFrame`.
+///
+/// Right now we use this to start an AutoreleasePool.
+pub fn drawFrameStart(self: *Metal) void {
+ assert(self.autorelease_pool == null);
+ self.autorelease_pool = .init();
+}
+
+/// Actions taken after `drawFrame` is done.
+///
+/// Right now we use this to end our AutoreleasePool.
+pub fn drawFrameEnd(self: *Metal) void {
+ assert(self.autorelease_pool != null);
+ self.autorelease_pool.?.deinit();
+ self.autorelease_pool = null;
+}
+
+pub fn initShaders(
+ self: *const Metal,
+ alloc: Allocator,
+ custom_shaders: []const [:0]const u8,
+) !shaders.Shaders {
+ return try shaders.Shaders.init(
+ alloc,
+ self.device,
+ custom_shaders,
+ // Using an `*_srgb` pixel format makes Metal gamma encode
+ // the pixels written to it *after* blending, which means
+ // we get linear alpha blending rather than gamma-incorrect
+ // blending.
+ if (self.blending.isLinear())
+ mtl.MTLPixelFormat.bgra8unorm_srgb
+ else
+ mtl.MTLPixelFormat.bgra8unorm,
+ );
+}
+
+/// Get the current size of the runtime surface.
+pub fn surfaceSize(self: *const Metal) !struct { width: u32, height: u32 } {
+ const bounds = self.layer.layer.getProperty(graphics.Rect, "bounds");
+ const scale = self.layer.layer.getProperty(f64, "contentsScale");
+
+ // We need to clamp our runtime surface size to the maximum
+ // possible texture size since we can't create a screen buffer (texture)
+ // larger than that.
+ return .{
+ .width = @min(
+ @as(u32, @intFromFloat(bounds.size.width * scale)),
+ self.max_texture_size,
+ ),
+ .height = @min(
+ @as(u32, @intFromFloat(bounds.size.height * scale)),
+ self.max_texture_size,
+ ),
+ };
+}
+
+/// Initialize a new render target which can be presented by this API.
+pub fn initTarget(self: *const Metal, width: usize, height: usize) !Target {
+ return Target.init(.{
+ .device = self.device,
+ // Using an `*_srgb` pixel format makes Metal gamma encode the pixels
+ // written to it *after* blending, which means we get linear alpha
+ // blending rather than gamma-incorrect blending.
+ .pixel_format = if (self.blending.isLinear())
+ .bgra8unorm_srgb
+ else
+ .bgra8unorm,
+ .storage_mode = self.default_storage_mode,
+ .width = width,
+ .height = height,
+ });
+}
+
+/// Present the provided target.
+pub inline fn present(self: *Metal, target: Target, sync: bool) !void {
+ if (sync) {
+ self.layer.setSurfaceSync(target.surface);
+ } else {
+ try self.layer.setSurface(target.surface);
+ }
+}
+
+/// Present the last presented target again. (noop for Metal)
+pub inline fn presentLastTarget(self: *Metal) !void {
+ _ = self;
+}
+
+/// Returns the options to use when constructing buffers.
+pub inline fn bufferOptions(self: Metal) bufferpkg.Options {
+ return .{
+ .device = self.device,
+ .resource_options = .{
+ // Indicate that the CPU writes to this resource but never reads it.
+ .cpu_cache_mode = .write_combined,
+ .storage_mode = self.default_storage_mode,
+ },
+ };
+}
+
+pub const instanceBufferOptions = bufferOptions;
+pub const uniformBufferOptions = bufferOptions;
+pub const fgBufferOptions = bufferOptions;
+pub const bgBufferOptions = bufferOptions;
+pub const imageBufferOptions = bufferOptions;
+pub const bgImageBufferOptions = bufferOptions;
+
+/// Returns the options to use when constructing textures.
+pub inline fn textureOptions(self: Metal) Texture.Options {
+ return .{
+ .device = self.device,
+ // Using an `*_srgb` pixel format makes Metal gamma encode the pixels
+ // written to it *after* blending, which means we get linear alpha
+ // blending rather than gamma-incorrect blending.
+ .pixel_format = if (self.blending.isLinear())
+ .bgra8unorm_srgb
+ else
+ .bgra8unorm,
+ .resource_options = .{
+ // Indicate that the CPU writes to this resource but never reads it.
+ .cpu_cache_mode = .write_combined,
+ .storage_mode = self.default_storage_mode,
+ },
+ .usage = .{
+ // textureOptions is currently only used for custom shaders,
+ // which require both the shader read (for when multiple shaders
+ // are chained) and render target (for the final output) usage.
+ // Disabling either of these will lead to metal validation
+ // errors in Xcode.
+ .shader_read = true,
+ .render_target = true,
+ },
+ };
+}
+
+pub inline fn samplerOptions(self: Metal) Sampler.Options {
+ return .{
+ .device = self.device,
+
+ // These parameters match Shadertoy behaviors.
+ .min_filter = .linear,
+ .mag_filter = .linear,
+ .s_address_mode = .clamp_to_edge,
+ .t_address_mode = .clamp_to_edge,
+ };
+}
+
+/// Pixel format for image texture options.
+pub const ImageTextureFormat = enum {
+ /// 1 byte per pixel grayscale.
+ gray,
+ /// 4 bytes per pixel RGBA.
+ rgba,
+ /// 4 bytes per pixel BGRA.
+ bgra,
+
+ fn toPixelFormat(
+ self: ImageTextureFormat,
+ srgb: bool,
+ ) mtl.MTLPixelFormat {
+ return switch (self) {
+ .gray => if (srgb) .r8unorm_srgb else .r8unorm,
+ .rgba => if (srgb) .rgba8unorm_srgb else .rgba8unorm,
+ .bgra => if (srgb) .bgra8unorm_srgb else .bgra8unorm,
+ };
+ }
+};
+
+/// Returns the options to use when constructing textures for images.
+pub inline fn imageTextureOptions(
+ self: Metal,
+ format: ImageTextureFormat,
+ srgb: bool,
+) Texture.Options {
+ return .{
+ .device = self.device,
+ .pixel_format = format.toPixelFormat(srgb),
+ .resource_options = .{
+ // Indicate that the CPU writes to this resource but never reads it.
+ .cpu_cache_mode = .write_combined,
+ .storage_mode = self.default_storage_mode,
+ },
+ .usage = .{
+ // We only need to read from this texture from a shader.
+ .shader_read = true,
+ },
+ };
+}
+
+/// Initializes a Texture suitable for the provided font atlas.
+pub fn initAtlasTexture(
+ self: *const Metal,
+ atlas: *const font.Atlas,
+) Texture.Error!Texture {
+ const pixel_format: mtl.MTLPixelFormat = switch (atlas.format) {
+ .grayscale => .r8unorm,
+ .bgra => .bgra8unorm_srgb,
+ else => @panic("unsupported atlas format for Metal texture"),
+ };
+
+ return try Texture.init(
+ .{
+ .device = self.device,
+ .pixel_format = pixel_format,
+ .resource_options = .{
+ // Indicate that the CPU writes to this resource but never reads it.
+ .cpu_cache_mode = .write_combined,
+ .storage_mode = self.default_storage_mode,
+ },
+ .usage = .{
+ // We only need to read from this texture from a shader.
+ .shader_read = true,
+ },
+ },
+ atlas.size,
+ atlas.size,
+ null,
+ );
+}
+
+/// Begin a frame.
+pub inline fn beginFrame(
+ self: *const Metal,
+ /// Once the frame has been completed, the `frameCompleted` method
+ /// on the renderer is called with the health status of the frame.
+ renderer: *Renderer,
+ /// The target is presented via the provided renderer's API when completed.
+ target: *Target,
+) !Frame {
+ return try Frame.begin(.{ .queue = self.queue }, renderer, target);
+}
+
+fn chooseDevice() error{NoMetalDevice}!objc.Object {
+ var chosen_device: ?objc.Object = null;
+
+ switch (comptime builtin.os.tag) {
+ .macos => {
+ const devices = objc.Object.fromId(mtl.MTLCopyAllDevices());
+ defer devices.release();
+
+ var iter = devices.iterate();
+ while (iter.next()) |device| {
+ // We want a GPU that’s connected to a display.
+ if (device.getProperty(bool, "isHeadless")) continue;
+ chosen_device = device;
+ // If the user has an eGPU plugged in, they probably want
+ // to use it. Otherwise, integrated GPUs are better for
+ // battery life and thermals.
+ if (device.getProperty(bool, "isRemovable") or
+ device.getProperty(bool, "isLowPower")) break;
+ }
+ },
+ .ios => {
+ chosen_device = objc.Object.fromId(mtl.MTLCreateSystemDefaultDevice());
+ },
+ else => @compileError("unsupported target for Metal"),
+ }
+
+ const device = chosen_device orelse return error.NoMetalDevice;
+ return device.retain();
+}
+
+/// Determines the maximum 2D texture size supported by the device.
+/// We need to clamp our frame size to this if it's larger.
+fn queryMaxTextureSize(device: objc.Object) u32 {
+ // https://developer.apple.com/metal/Metal-Feature-Set-Tables.pdf
+
+ if (device.msgSend(
+ bool,
+ objc.sel("supportsFamily:"),
+ .{mtl.MTLGPUFamily.apple10},
+ )) return 32768;
+
+ if (device.msgSend(
+ bool,
+ objc.sel("supportsFamily:"),
+ .{mtl.MTLGPUFamily.apple3},
+ )) return 16384;
+
+ return 8192;
+}