//! 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 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 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, /// 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(); 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.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}); }; } /// 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"); 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; 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, }, }; } /// 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, }, }; } /// 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, }, }, 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(); }