mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-07-25 13:16:11 +03:00
354 lines
12 KiB
Zig
354 lines
12 KiB
Zig
//! Graphics API wrapper for Metal.
|
||
pub const Metal = @This();
|
||
|
||
const std = @import("std");
|
||
const assert = std.debug.assert;
|
||
const Allocator = std.mem.Allocator;
|
||
const builtin = @import("builtin");
|
||
const glfw = @import("glfw");
|
||
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 Texture = @import("metal/Texture.zig");
|
||
pub const shaders = @import("metal/shaders.zig");
|
||
|
||
pub const cellpkg = @import("metal/cell.zig");
|
||
pub const imagepkg = @import("metal/image.zig");
|
||
|
||
pub const custom_shader_target: shadertoy.Target = .msl;
|
||
|
||
/// Triple buffering.
|
||
pub const swap_chain_count = 3;
|
||
|
||
const log = std.log.scoped(.metal);
|
||
|
||
// Get native API access on certain platforms so we can do more customization.
|
||
const glfwNative = glfw.Native(.{
|
||
.cocoa = builtin.os.tag == .macos,
|
||
});
|
||
|
||
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,
|
||
|
||
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();
|
||
|
||
const default_storage_mode: mtl.MTLResourceOptions.StorageMode =
|
||
if (device.getProperty(bool, "hasUnifiedMemory")) .shared else .managed;
|
||
|
||
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.glfw => info: {
|
||
// Everything in glfw is window-oriented so we grab the backing
|
||
// window, then derive everything from that.
|
||
const nswindow = objc.Object.fromId(glfwNative.getCocoaWindow(
|
||
opts.rt_surface.window,
|
||
).?);
|
||
|
||
const contentView = objc.Object.fromId(
|
||
nswindow.getProperty(?*anyopaque, "contentView").?,
|
||
);
|
||
const scaleFactor = nswindow.getProperty(
|
||
graphics.c.CGFloat,
|
||
"backingScaleFactor",
|
||
);
|
||
|
||
break :info .{
|
||
.view = contentView,
|
||
.scaleFactor = scaleFactor,
|
||
};
|
||
},
|
||
|
||
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 => {
|
||
info.view.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,
|
||
};
|
||
}
|
||
|
||
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});
|
||
};
|
||
}
|
||
|
||
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");
|
||
return .{
|
||
.width = @intFromFloat(bounds.size.width * scale),
|
||
.height = @intFromFloat(bounds.size.height * scale),
|
||
};
|
||
}
|
||
|
||
/// 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;
|
||
|
||
/// 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,
|
||
},
|
||
};
|
||
}
|
||
|
||
/// 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,
|
||
.rgba => .bgra8unorm,
|
||
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,
|
||
},
|
||
},
|
||
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();
|
||
}
|