diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-04-04 19:22:04 +0300 |
|---|---|---|
| committer | Can Celik <ogulcancelik@gmail.com> | 2026-04-05 01:23:06 +0300 |
| commit | 41dd733ca7123c9ac4bfc6c781697a660e95b052 (patch) | |
| tree | 3fc91815ca139ef2e0b281bba0abda4ef15175f4 /vendor/libghostty-vt/src/renderer/Metal.zig | |
| parent | 55c138263e190a1e79d4b997bfb9397306c0ec1f (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.zig | 458 |
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; +} |
