feat(macos): port Ghostty's macOS code paths (Metal, CoreText, Darwin)

Ports the macOS-specific Zig code behind cfg(target_os = "macos"):
- renderer: a GraphicsApi trait with generic::Renderer<G>; Metal backend
  (all renderer/metal/* files, shaders.metal verbatim, runtime library
  compile, IOSurface layer, CVDisplayLink), shadertoy -> MSL via naga;
  OpenGL remains the backend elsewhere.
- font: CoreText face, shaper and discovery with build.rs backend
  selection (coretext default; coretext-freetype / coretext-harfbuzz /
  coretext-noshape features), no fontconfig/freetype/harfbuzz on Apple.
- input: KeymapDarwin via UCKeyTranslate, option-as-alt, mac keycodes.
- os: macos, mach, cf_release_thread, NSProcessInfo args and macOS
  branches; config: macOS defaults, keybinds, Application Support paths.
- termio/core: login(1) wrapping, openpty/execvpe, Darwin child exit
  handling, os_log logging.

Compile-checked for aarch64/x86_64-apple-darwin (clippy -D warnings
clean) using the cross setup in docs/rewrite/notes/macos-M1.md; cannot
run here. The embedded runtime/C API (embedded.zig) is the next step.

Also includes the cross-platform integration edits shared by the crash,
verification, Windows and macOS work (Cargo target-gating, thread
crash-context hooks, apprt-gtk surface/IME refactor, termio/os cfg
gates), a cfg fix so ghostty-config builds on wasm32, and an allow for
the intentional recursion in the crash overflow test.

Test Plan:
- cargo test --workspace (3224 passed)
- cargo clippy --workspace --all-targets -- -D warnings
- cargo check --target aarch64-apple-darwin -p ghostty-renderer (with
  the documented cross env)
This commit is contained in:
ddidderr committed 2026-10-12 06:02:39 +02:00
1 parent 76771bed0d
commit bab49ee596
124 files changed
+14936 -1242

No files matched your search

+174
View File
@@ -0,0 +1,174 @@
//! Metal data storage for a certain set of equal types (`metal/buffer.zig`).
use std::marker::PhantomData;
use bytemuck::Pod;
use objc2::{rc::Retained, runtime::ProtocolObject};
use objc2_foundation::NSRange;
use objc2_metal::{MTLBuffer, MTLDevice as _, MTLResourceOptions};
use super::{Device, Error};
use crate::{
graphics_api::{ApiError, GpuBuffer},
metal_layout::{ResourceOptions, StorageMode},
};
/// Options for initializing a buffer.
#[derive(Clone)]
pub struct Options {
/// MTLDevice
pub device: Device,
pub resource_options: ResourceOptions,
}
impl Options {
fn mtl_options(&self) -> MTLResourceOptions {
MTLResourceOptions(self.resource_options.bits() as usize)
}
}
/// Metal data storage for a certain set of equal types. This is usually
/// used for vertex buffers, etc. This helpful wrapper makes it easy to
/// prealloc, shrink, grow, sync, buffers with Metal.
pub struct Buffer<T: Pod> {
/// The options this buffer was initialized with.
opts: Options,
/// The underlying MTLBuffer object.
buffer: Retained<ProtocolObject<dyn MTLBuffer>>,
/// The allocated length of the buffer. Note that this is the number
/// of `T`s not the size in bytes.
pub len: usize,
_marker: PhantomData<fn() -> T>,
}
// SAFETY: an MTLBuffer is safe to use from any thread; CPU writes through
// `contents` are serialized by `&mut self` and by the swap chain, which never
// hands a buffer to the CPU while the GPU is using it.
unsafe impl<T: Pod> Send for Buffer<T> {}
// SAFETY: see above; shared access only reads the handle.
unsafe impl<T: Pod> Sync for Buffer<T> {}
impl<T: Pod> Buffer<T> {
/// The underlying MTLBuffer.
pub fn buffer(&self) -> &ProtocolObject<dyn MTLBuffer> {
&self.buffer
}
/// A retained handle to the underlying MTLBuffer (what render pass steps
/// bind).
pub fn raw(&self) -> Retained<ProtocolObject<dyn MTLBuffer>> {
self.buffer.clone()
}
/// Initialize a buffer with the given length pre-allocated.
pub fn init(opts: Options, len: usize) -> Result<Self, Error> {
let buffer = opts
.device
.newBufferWithLength_options(len * size_of::<T>(), opts.mtl_options())
.ok_or(Error::MetalFailed)?;
Ok(Buffer {
buffer,
opts,
len,
_marker: PhantomData,
})
}
/// Init the buffer filled with the given data.
pub fn init_fill(opts: Options, data: &[T]) -> Result<Self, Error> {
let bytes: &[u8] = bytemuck::cast_slice(data);
// newBufferWithBytes rejects a null pointer / zero length, so empty
// data allocates an (empty) buffer of length 0 via the length path.
let buffer = if bytes.is_empty() {
opts.device
.newBufferWithLength_options(0, opts.mtl_options())
} else {
let ptr = std::ptr::NonNull::from(bytes).cast();
// SAFETY: `ptr` points to `bytes.len()` readable bytes.
unsafe {
opts.device
.newBufferWithBytes_length_options(ptr, bytes.len(), opts.mtl_options())
}
}
.ok_or(Error::MetalFailed)?;
Ok(Buffer {
buffer,
opts,
len: data.len(),
_marker: PhantomData,
})
}
/// Sync new contents to the buffer. The data is expected to be the
/// complete contents of the buffer. If the amount of data is larger
/// than the buffer length, the buffer will be reallocated.
///
/// If the amount of data is smaller than the buffer length, the
/// remaining data in the buffer is left untouched.
pub fn sync(&mut self, data: &[T]) -> Result<(), Error> {
self.sync_from_slices(&[data]).map(|_| ())
}
/// Like `Buffer::sync` but takes data from an array of slices, rather
/// than a single slice. Returns the number of items synced.
pub fn sync_from_slices(&mut self, lists: &[&[T]]) -> Result<usize, Error> {
let total_len: usize = lists.iter().map(|l| l.len()).sum();
// If we need more bytes than our buffer has, we need to reallocate.
let req_bytes = total_len * size_of::<T>();
let avail_bytes = self.buffer.length();
if req_bytes > avail_bytes {
// Allocate a new buffer with enough to hold double what we
// require (the old buffer is released when it is replaced).
//
// NOTE: Zig multiplies this size by `@sizeOf(T)` a second time,
// over-allocating; we allocate exactly double.
let size = req_bytes * 2;
self.buffer = self
.opts
.device
.newBufferWithLength_options(size, self.opts.mtl_options())
.ok_or(Error::MetalFailed)?;
self.len = total_len * 2;
}
// We can fit within the buffer so we can just replace bytes.
let dst = self.buffer.contents().as_ptr().cast::<u8>();
let mut offset = 0;
for list in lists {
let src: &[u8] = bytemuck::cast_slice(list);
// SAFETY: `dst` points to a live buffer of at least `req_bytes`
// bytes (checked/reallocated above) and `offset + src.len()` never
// exceeds `req_bytes`; the regions can't overlap because the
// buffer is GPU memory owned by us.
unsafe { std::ptr::copy_nonoverlapping(src.as_ptr(), dst.add(offset), src.len()) };
offset += src.len();
}
// If we're using the managed resource storage mode, then
// we need to signal Metal to synchronize the buffer data.
//
// Ref: https://developer.apple.com/documentation/metal/synchronizing-a-managed-resource-in-macos?language=objc
if self.opts.resource_options.storage_mode == StorageMode::Managed {
self.buffer.didModifyRange(NSRange::new(0, req_bytes));
}
Ok(total_len)
}
}
impl<T: Pod> GpuBuffer<super::Metal, T> for Buffer<T> {
fn raw(&self) -> Retained<ProtocolObject<dyn MTLBuffer>> {
Buffer::raw(self)
}
fn sync(&mut self, data: &[T]) -> Result<(), ApiError> {
Buffer::sync(self, data).map_err(ApiError::new)
}
fn sync_from_slices(&mut self, lists: &[&[T]]) -> Result<usize, ApiError> {
Buffer::sync_from_slices(self, lists).map_err(ApiError::new)
}
}
+143
View File
@@ -0,0 +1,143 @@
//! Wrapper for handling render frames (`metal/Frame.zig`).
use std::{ptr::NonNull, sync::Arc};
use block2::RcBlock;
use objc2::{rc::Retained, runtime::ProtocolObject};
use objc2_metal::{MTLCommandBuffer, MTLCommandBufferStatus, MTLCommandQueue};
use super::{IOSurfaceLayer, Metal, Queue, render_pass::RenderPass, target::Target};
use crate::{
backend::Health,
graphics_api::{Attachment, FrameCompletion, GpuFrame},
};
/// A frame being encoded: one MTLCommandBuffer.
pub struct Frame {
/// MTLCommandBuffer (`None` if the queue failed to create one, in which
/// case the frame is a no-op that completes unhealthy).
buffer: Option<Retained<ProtocolObject<dyn MTLCommandBuffer>>>,
/// The layer the target is presented on.
layer: IOSurfaceLayer,
/// The target is presented via the layer when completed.
target: Arc<Target>,
}
impl Frame {
/// Begin encoding a frame.
pub fn begin(queue: &Queue, layer: IOSurfaceLayer, target: Arc<Target>) -> Frame {
let buffer = queue.commandBuffer();
if buffer.is_none() {
log::warn!("unable to create a Metal command buffer");
}
Frame {
buffer,
layer,
target,
}
}
/// Add a render pass to this frame with the provided attachments.
/// Returns a RenderPass which allows render steps to be added.
pub fn render_pass(&self, attachments: &[Attachment<'_, Metal>]) -> RenderPass {
RenderPass::begin(self.buffer.as_deref(), attachments)
}
/// Complete this frame and present the target.
///
/// If `sync` is true, this will block until the frame is presented.
pub fn complete(self, sync: bool, completion: FrameCompletion) {
let Frame {
buffer,
layer,
target,
} = self;
let Some(buffer) = buffer else {
completion(Health::Unhealthy);
return;
};
// What happens when the command buffer has finished (`bufferCompleted`).
let finish = move |buffer: &ProtocolObject<dyn MTLCommandBuffer>, sync: bool| {
// Get our command buffer status to pass back to the generic renderer.
let health = health_for_status(buffer.status());
// If the frame is healthy, present it.
if health == Health::Healthy {
if sync {
layer.set_surface_sync(target.surface());
} else {
layer.set_surface(target.surface());
}
}
completion(health);
};
if sync {
// If we need to complete synchronously, we wait until the buffer
// is completed and invoke the callback directly.
buffer.commit();
buffer.waitUntilCompleted();
finish(&buffer, true);
} else {
// Otherwise we add our block as a completion handler. It is
// copied when we add the handler, and then the copy is deallocated
// by the objc runtime on success.
let block = RcBlock::new(
move |buffer: NonNull<ProtocolObject<dyn MTLCommandBuffer>>| {
// SAFETY: Metal passes the (live) command buffer.
finish(unsafe { buffer.as_ref() }, false);
},
);
// SAFETY: the block is a valid completion handler and is copied
// by `addCompletedHandler`.
unsafe { buffer.addCompletedHandler(RcBlock::as_ptr(&block)) };
buffer.commit();
}
}
}
/// The health of a frame from its command buffer status.
pub fn health_for_status(status: MTLCommandBufferStatus) -> Health {
if status == MTLCommandBufferStatus::Error {
Health::Unhealthy
} else {
Health::Healthy
}
}
impl GpuFrame<Metal> for Frame {
fn render_pass<'a>(&'a self, attachments: &'a [Attachment<'a, Metal>]) -> RenderPass
where
Metal: 'a,
{
Frame::render_pass(self, attachments)
}
fn complete(self, sync: bool, completion: FrameCompletion) {
Frame::complete(self, sync, completion);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn error_status_is_unhealthy() {
assert_eq!(
health_for_status(MTLCommandBufferStatus::Error),
Health::Unhealthy
);
assert_eq!(
health_for_status(MTLCommandBufferStatus::Completed),
Health::Healthy
);
assert_eq!(
health_for_status(MTLCommandBufferStatus::NotEnqueued),
Health::Healthy
);
}
}
@@ -0,0 +1,193 @@
//! A wrapper around a CALayer with a utility method for settings its
//! `contents` to an IOSurface (`metal/IOSurfaceLayer.zig`).
use std::sync::{Arc, Mutex, PoisonError};
use dispatch2::DispatchQueue;
use objc2::{
AnyThread,
DefinedClass,
MainThreadMarker,
define_class,
msg_send,
rc::Retained,
runtime::AnyObject,
};
use objc2_core_foundation::CFRetained;
use objc2_foundation::{NSNull, NSString};
use objc2_io_surface::IOSurfaceRef;
use objc2_quartz_core::{CALayer, kCAGravityTopLeft};
use super::Error;
/// The callback invoked when the layer needs to `display`.
pub type DisplayCallback = Arc<dyn Fn() + Send + Sync>;
/// Instance variables of the layer subclass (`display_cb` / `display_ctx`).
#[derive(Default)]
pub struct Ivars {
display_cb: Mutex<Option<DisplayCallback>>,
}
define_class!(
/// We subclass CALayer with a custom display handler.
// SAFETY:
// - The superclass CALayer has no subclassing requirements we violate.
// - The class does not implement `Drop`.
#[unsafe(super(CALayer))]
#[name = "IOSurfaceLayer"]
#[ivars = Ivars]
pub struct IOSurfaceLayerClass;
impl IOSurfaceLayerClass {
#[unsafe(method(display))]
fn display(&self) {
// Clone the callback out so it isn't invoked with the lock held.
let cb = self
.ivars()
.display_cb
.lock()
.unwrap_or_else(PoisonError::into_inner)
.clone();
if let Some(cb) = cb {
cb();
}
}
/// Disable all animations for this layer by returning null for all
/// actions.
#[unsafe(method_id(actionForKey:))]
fn action_for_key(&self, _key: &NSString) -> Option<Retained<NSNull>> {
Some(NSNull::null())
}
}
);
impl IOSurfaceLayerClass {
fn create() -> Retained<Self> {
let this = Self::alloc().set_ivars(Ivars::default());
// SAFETY: `init` is the designated initializer of the superclass.
unsafe { msg_send![super(this), init] }
}
}
/// Wrapper to move an Objective-C / CoreFoundation object to another thread.
struct SendWrapper<T>(T);
// SAFETY: used only for the layer and IOSurface, which are reference counted
// thread-safe objects when messaged on the main thread (we only message the
// layer on the main queue).
unsafe impl<T> Send for SendWrapper<T> {}
/// A CALayer which displays IOSurfaces.
#[derive(Clone)]
pub struct IOSurfaceLayer {
/// The underlying CALayer
layer: Retained<IOSurfaceLayerClass>,
}
// SAFETY: the layer is only messaged from the render / main threads through
// thread-safe CALayer properties (`contents`, `bounds`, `contentsScale`), the
// same way Ghostty (Zig) shares it between the renderer and the main thread.
unsafe impl Send for IOSurfaceLayer {}
// SAFETY: see above.
unsafe impl Sync for IOSurfaceLayer {}
impl IOSurfaceLayer {
pub fn new() -> Result<IOSurfaceLayer, Error> {
let layer = IOSurfaceLayerClass::create();
// The layer gravity is set to top-left so that the contents aren't
// stretched during resize operations before a new frame has been drawn.
//
// SAFETY: a constant NSString exported by QuartzCore.
layer.setContentsGravity(unsafe { kCAGravityTopLeft });
Ok(IOSurfaceLayer { layer })
}
/// The underlying CALayer.
pub fn layer(&self) -> &CALayer {
&self.layer
}
/// The layer's bounds size and contents scale.
pub fn bounds_and_scale(&self) -> (f64, f64, f64) {
let bounds = self.layer.bounds();
(
bounds.size.width,
bounds.size.height,
self.layer.contentsScale(),
)
}
/// Sets the layer's `contents` to the provided IOSurface.
///
/// Makes sure to do so on the main thread to avoid visual artifacts.
pub fn set_surface(&self, surface: &CFRetained<IOSurfaceRef>) {
// We retain the surface (and the layer) to make sure they're not
// released before we can set it as the contents of the layer; both
// are released when the closure is dropped.
let layer = SendWrapper(self.layer.clone());
let surface = SendWrapper(surface.clone());
// We check if we're on the main thread and run the work directly if so.
if MainThreadMarker::new().is_some() {
set_surface_callback(&layer.0, &surface.0);
} else {
DispatchQueue::main().exec_async(move || {
let layer = layer;
let surface = surface;
set_surface_callback(&layer.0, &surface.0);
});
}
}
/// Sets the layer's `contents` to the provided IOSurface.
///
/// Does not ensure this happens on the main thread.
pub fn set_surface_sync(&self, surface: &CFRetained<IOSurfaceRef>) {
let contents: &AnyObject = surface_as_object(surface);
// SAFETY: an IOSurface is a valid value for `contents`.
unsafe { self.layer.setContents(Some(contents)) };
}
/// Set (or clear) the callback invoked from the layer's `display` method.
pub fn set_display_callback(&self, cb: Option<DisplayCallback>) {
*self
.layer
.ivars()
.display_cb
.lock()
.unwrap_or_else(PoisonError::into_inner) = cb;
}
}
/// View an IOSurface as an Objective-C object (they are toll-free bridged).
fn surface_as_object(surface: &CFRetained<IOSurfaceRef>) -> &AnyObject {
let cf: &objc2_core_foundation::CFType = surface;
cf.as_ref()
}
fn set_surface_callback(layer: &IOSurfaceLayerClass, surface: &CFRetained<IOSurfaceRef>) {
// We check to see if the surface is the appropriate size for
// the layer, if it's not then we discard it. This is because
// asynchronously drawn frames can sometimes finish just after
// a synchronously drawn frame during a resize, and if we don't
// discard the improperly sized surface it creates jank.
let bounds = layer.bounds();
let scale = layer.contentsScale();
let width = (bounds.size.width * scale) as usize;
let height = (bounds.size.height * scale) as usize;
if width != surface.width() || height != surface.height() {
log::debug!(
"setSurfaceCallback(): surface is wrong size for layer, discarding. surface = {}x{}, layer = {}x{}",
surface.width(),
surface.height(),
width,
height,
);
return;
}
// SAFETY: an IOSurface is a valid value for `contents`.
unsafe { layer.setContents(Some(surface_as_object(surface))) };
}
+522
View File
@@ -0,0 +1,522 @@
//! Graphics API wrapper for Metal (`renderer/Metal.zig` and `renderer/metal/*`).
//!
//! The renderer draws into IOSurface-backed textures ([`target::Target`]) and
//! presents them by setting the `contents` of an [`IOSurfaceLayer`], which is
//! installed on the apprt's NSView (macOS: made "layer-hosting") or UIView
//! (iOS: added as a sublayer).
//!
//! The platform-independent parts (enum values, GPU data layouts, pixel
//! format selection, vertex attribute tables) live in
//! [`crate::metal_layout`] so they can be unit tested on any host.
//!
//! Safety: this module talks to Metal / QuartzCore through `objc2`, which is
//! mostly safe; `unsafe` is limited to encoder calls that take raw resources,
//! raw pointer copies and the autorelease pool, each with a SAFETY comment.
use std::{ffi::c_void, sync::Arc};
use bytemuck::Pod;
use ghostty_config::types_b::AlphaBlending;
use ghostty_font::atlas::Atlas;
use objc2::{
msg_send,
rc::Retained,
runtime::{AnyObject, ProtocolObject},
sel,
};
use objc2_foundation::NSError;
use objc2_metal::{
MTLBuffer,
MTLCommandQueue,
MTLDevice,
MTLGPUFamily,
MTLSamplerAddressMode,
MTLSamplerMinMagFilter,
};
pub mod buffer;
pub mod frame;
pub mod io_surface_layer;
pub mod pipeline;
pub mod render_pass;
pub mod sampler;
pub mod shaders;
pub mod target;
pub mod texture;
pub use self::{
buffer::Buffer,
frame::Frame,
io_surface_layer::IOSurfaceLayer,
pipeline::Pipeline,
render_pass::RenderPass,
sampler::Sampler,
shaders::Shaders,
target::Target,
texture::Texture,
};
use crate::{
graphics_api::{ApiError, GraphicsApi, ImageTextureFormat, TextureKind},
metal_layout::{
MetalUniforms,
PixelFormat,
ResourceOptions,
StorageMode,
TextureUsage,
atlas_pixel_format,
color_pixel_format,
default_storage_mode,
image_pixel_format,
max_texture_size,
surface_size,
},
shader_types::Uniforms,
shadertoy,
};
/// A `MTLDevice`.
pub type Device = Retained<ProtocolObject<dyn MTLDevice>>;
/// A `MTLCommandQueue`.
pub type Queue = Retained<ProtocolObject<dyn MTLCommandQueue>>;
/// Custom shaders are converted to MSL for Metal.
pub const CUSTOM_SHADER_TARGET: shadertoy::Target = shadertoy::Target::Msl;
/// The fragCoord for Metal shaders is +Y = down.
pub const CUSTOM_SHADER_Y_IS_DOWN: bool = true;
/// Triple buffering.
pub const SWAP_CHAIN_COUNT: usize = 3;
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// A Metal API call failed.
#[error("Metal API call failed")]
MetalFailed,
/// No usable Metal device exists.
#[error("no Metal device")]
NoMetalDevice,
/// An Objective-C runtime operation failed.
#[error("Objective-C runtime failure")]
ObjCFailed,
/// A shader function was not found in its library.
#[error("shader function `{0}` not found")]
MissingFunction(String),
/// Metal reported an error (shader compilation, pipeline creation, ...).
#[error("metal error: {0}")]
Message(String),
}
impl Error {
/// Log and convert an `NSError` (`checkError`).
pub(crate) fn from_ns_error(err: &NSError) -> Error {
let desc = err.localizedDescription().to_string();
log::error!("metal error={desc}");
Error::Message(desc)
}
}
/// The NSView (macOS) / UIView (iOS) the renderer draws in, plus its content
/// scale (Zig: `rt_surface.platform` and `rt_surface.content_scale.x`).
pub struct ViewInfo<'a> {
/// The NSView / UIView.
pub view: &'a AnyObject,
/// The scale factor of the window (backing scale factor).
pub scale_factor: f64,
}
/// An autorelease pool token (`objc_autoreleasePoolPush`).
struct PoolToken(*mut c_void);
// SAFETY: the token is only used from the thread that pushed it (draw_frame
// starts and ends the pool on one thread); `Send` is needed because the
// graphics API lives inside the renderer's mutex.
unsafe impl Send for PoolToken {}
unsafe extern "C" {
fn objc_autoreleasePoolPush() -> *mut c_void;
fn objc_autoreleasePoolPop(pool: *mut c_void);
}
/// The Metal graphics API.
pub struct Metal {
layer: IOSurfaceLayer,
/// MTLDevice
device: Device,
/// MTLCommandQueue
queue: Queue,
/// Alpha blending mode
blending: 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: 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: Option<PoolToken>,
}
impl Metal {
/// Create the Metal backend and attach its layer to `view`.
///
/// Must be called on the thread that owns the view (the main thread).
pub fn init(blending: AlphaBlending, info: &ViewInfo<'_>) -> Result<Metal, Error> {
// Choose our MTLDevice and create a MTLCommandQueue for that device.
let device = choose_device()?;
let queue = device.newCommandQueue().ok_or(Error::MetalFailed)?;
// Grab metadata about the device.
let has_unified_memory = device.hasUnifiedMemory();
let default_storage_mode =
default_storage_mode(cfg!(target_os = "ios"), has_unified_memory);
let max_texture_size = query_max_texture_size(&device);
log::debug!(
"device properties default_storage_mode={default_storage_mode:?} max_texture_size={max_texture_size}"
);
// 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.
let layer = IOSurfaceLayer::new()?;
let layer_object: &AnyObject = layer.layer().as_ref();
let view = info.view;
// 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.
//
// SAFETY: `view` is an NSView / UIView (a documented requirement of
// `ViewInfo`), which implements these selectors with these types.
unsafe {
#[cfg(target_os = "macos")]
{
let _: () = msg_send![view, setLayer: layer_object];
let _: () = msg_send![view, setWantsLayer: true];
}
#[cfg(not(target_os = "macos"))]
{
let view_layer: Option<Retained<AnyObject>> = msg_send![view, layer];
let view_layer = view_layer.ok_or(Error::ObjCFailed)?;
let _: () = msg_send![&*view_layer, addSublayer: layer_object];
}
// Ensure that if our layer is oversized it does not overflow the
// bounds of the view. (`clipsToBounds` is NSView API since macOS
// 14; older systems don't have the setter.)
let responds: bool = msg_send![view, respondsToSelector: sel!(setClipsToBounds:)];
if responds {
let _: () = msg_send![view, setClipsToBounds: 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().setContentsScale(info.scale_factor);
// This makes it so that our display callback will actually be called.
layer.layer().setNeedsDisplayOnBoundsChange(true);
Ok(Metal {
layer,
device,
queue,
blending,
default_storage_mode,
max_texture_size,
autorelease_pool: None,
})
}
/// The layer the renderer presents on.
pub fn layer(&self) -> &IOSurfaceLayer {
&self.layer
}
/// The device the renderer draws with.
pub fn device(&self) -> &Device {
&self.device
}
/// The default storage mode of resources created with this device.
pub fn default_storage_mode(&self) -> StorageMode {
self.default_storage_mode
}
/// The maximum 2D texture size of the device.
pub fn max_texture_size(&self) -> u32 {
self.max_texture_size
}
/// The pixel format of targets, custom shader textures and pipelines.
fn color_format(&self) -> PixelFormat {
color_pixel_format(self.blending)
}
/// Returns the options to use when constructing buffers.
pub fn buffer_options(&self) -> buffer::Options {
buffer::Options {
device: self.device.clone(),
// Indicate that the CPU writes to this resource but never reads it.
resource_options: ResourceOptions::write_combined(self.default_storage_mode),
}
}
/// Returns the options to use when constructing textures.
pub fn texture_options(&self) -> texture::Options {
texture::Options {
device: self.device.clone(),
// 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: self.color_format(),
resource_options: ResourceOptions::write_combined(self.default_storage_mode),
// textureOptions is currently only used for custom shaders.
usage: TextureUsage::custom_shader(),
}
}
/// Returns the options to use when constructing samplers.
pub fn sampler_options(&self) -> sampler::Options {
sampler::Options {
device: self.device.clone(),
// These parameters match Shadertoy behaviors.
min_filter: MTLSamplerMinMagFilter::Linear,
mag_filter: MTLSamplerMinMagFilter::Linear,
s_address_mode: MTLSamplerAddressMode::ClampToEdge,
t_address_mode: MTLSamplerAddressMode::ClampToEdge,
}
}
/// Returns the options to use when constructing textures for images.
pub fn image_texture_options(
&self,
format: ImageTextureFormat,
srgb: bool,
) -> texture::Options {
texture::Options {
device: self.device.clone(),
pixel_format: image_pixel_format(format, srgb),
resource_options: ResourceOptions::write_combined(self.default_storage_mode),
// We only need to read from this texture from a shader.
usage: TextureUsage::shader_read_only(),
}
}
/// Initialize a new render target which can be presented by this API.
pub fn init_target(&self, width: usize, height: usize) -> Result<Target, Error> {
Target::init(&target::Options {
device: self.device.clone(),
// 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: self.color_format(),
storage_mode: self.default_storage_mode,
width,
height,
})
}
/// Initializes a Texture suitable for the provided font atlas.
pub fn init_atlas_texture(&self, atlas: &Atlas) -> Result<Texture, Error> {
let pixel_format =
atlas_pixel_format(atlas.format).expect("unsupported atlas format for Metal texture");
Texture::init(
&texture::Options {
device: self.device.clone(),
pixel_format,
resource_options: ResourceOptions::write_combined(self.default_storage_mode),
// We only need to read from this texture from a shader.
usage: TextureUsage::shader_read_only(),
},
atlas.size as usize,
atlas.size as usize,
None,
)
}
/// Get the current size of the runtime surface.
pub fn surface_size(&self) -> (u32, u32) {
let (width, height, scale) = self.layer.bounds_and_scale();
// 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.
surface_size(width, height, scale, self.max_texture_size)
}
}
impl Drop for Metal {
fn drop(&mut self) {
// Nothing may call back into a renderer that is going away.
self.layer.set_display_callback(None);
if let Some(pool) = self.autorelease_pool.take() {
// SAFETY: the pool was pushed by `draw_frame_start` on this thread.
unsafe { objc_autoreleasePoolPop(pool.0) };
}
}
}
/// Determines the maximum 2D texture size supported by the device.
/// We need to clamp our frame size to this if it's larger.
fn query_max_texture_size(device: &ProtocolObject<dyn MTLDevice>) -> u32 {
// https://developer.apple.com/metal/Metal-Feature-Set-Tables.pdf
max_texture_size(
device.supportsFamily(MTLGPUFamily::Apple10),
device.supportsFamily(MTLGPUFamily::Apple3),
)
}
#[cfg(target_os = "macos")]
fn choose_device() -> Result<Device, Error> {
let devices = objc2_metal::MTLCopyAllDevices();
let mut chosen_device: Option<Device> = None;
for device in &devices {
// We want a GPU that’s connected to a display.
if device.isHeadless() {
continue;
}
chosen_device = Some(device.clone());
// 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.isRemovable() || device.isLowPower() {
break;
}
}
chosen_device.ok_or(Error::NoMetalDevice)
}
#[cfg(not(target_os = "macos"))]
fn choose_device() -> Result<Device, Error> {
objc2_metal::MTLCreateSystemDefaultDevice().ok_or(Error::NoMetalDevice)
}
impl GraphicsApi for Metal {
const CUSTOM_SHADER_TARGET: shadertoy::Target = CUSTOM_SHADER_TARGET;
const CUSTOM_SHADER_Y_IS_DOWN: bool = CUSTOM_SHADER_Y_IS_DOWN;
const SWAP_CHAIN_COUNT: usize = SWAP_CHAIN_COUNT;
type Target = Target;
type Texture = Texture;
type Sampler = Sampler;
type Buffer<T: Pod> = Buffer<T>;
type BufferRef = Retained<ProtocolObject<dyn MTLBuffer>>;
type Pipeline = Pipeline;
type Shaders = Shaders;
type GpuUniforms = MetalUniforms;
type Frame = Frame;
type RenderPass<'a> = RenderPass;
fn gpu_uniforms(uniforms: &Uniforms) -> MetalUniforms {
MetalUniforms::from(uniforms)
}
fn blending(&self) -> AlphaBlending {
self.blending
}
fn set_blending(&mut self, blending: AlphaBlending) {
self.blending = blending;
}
fn init_shaders(&self, custom_shaders: &[String]) -> Result<Shaders, ApiError> {
Shaders::init(
&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.
self.color_format(),
)
.map_err(ApiError::new)
}
fn surface_size(&self) -> Result<(u32, u32), ApiError> {
Ok(Metal::surface_size(self))
}
fn init_target(&self, width: usize, height: usize) -> Result<Target, ApiError> {
Metal::init_target(self, width, height).map_err(ApiError::new)
}
fn init_buffer<T: Pod>(&self, len: usize) -> Result<Buffer<T>, ApiError> {
Buffer::init(self.buffer_options(), len).map_err(ApiError::new)
}
fn init_buffer_fill<T: Pod>(&self, data: &[T]) -> Result<Buffer<T>, ApiError> {
Buffer::init_fill(self.buffer_options(), data).map_err(ApiError::new)
}
fn init_texture(
&self,
kind: TextureKind,
width: usize,
height: usize,
data: Option<&[u8]>,
) -> Result<Texture, ApiError> {
let opts = match kind {
TextureKind::CustomShader => self.texture_options(),
TextureKind::Image { format, srgb } => self.image_texture_options(format, srgb),
};
Texture::init(&opts, width, height, data).map_err(ApiError::new)
}
fn init_atlas_texture(&self, atlas: &Atlas) -> Result<Texture, ApiError> {
Metal::init_atlas_texture(self, atlas).map_err(ApiError::new)
}
fn init_sampler(&self) -> Result<Sampler, ApiError> {
Sampler::init(&self.sampler_options()).map_err(ApiError::new)
}
fn begin_frame(&self, target: Arc<Target>) -> Frame {
Frame::begin(&self.queue, self.layer.clone(), target)
}
/// Present the last presented target again. (noop for Metal)
fn present_last_target(&self) -> Result<(), ApiError> {
Ok(())
}
fn clear_last_target(&self) {}
/// Actions taken before doing anything in `drawFrame`.
///
/// Right now we use this to start an AutoreleasePool.
fn draw_frame_start(&mut self) {
debug_assert!(self.autorelease_pool.is_none());
// SAFETY: balanced with the pop in `draw_frame_end` / `Drop`.
self.autorelease_pool = Some(PoolToken(unsafe { objc_autoreleasePoolPush() }));
}
/// Actions taken after `drawFrame` is done.
///
/// Right now we use this to end our AutoreleasePool.
fn draw_frame_end(&mut self) {
debug_assert!(self.autorelease_pool.is_some());
if let Some(pool) = self.autorelease_pool.take() {
// SAFETY: the pool was pushed by `draw_frame_start` on this thread.
unsafe { objc_autoreleasePoolPop(pool.0) };
}
}
fn loop_enter(&mut self, draw: Arc<dyn Fn() + Send + Sync>) {
self.layer.set_display_callback(Some(draw));
}
}
@@ -0,0 +1,168 @@
//! Wrapper for handling render pipelines (`metal/Pipeline.zig`).
use objc2::{rc::Retained, runtime::ProtocolObject};
use objc2_foundation::NSString;
use objc2_metal::{
MTLBlendFactor,
MTLBlendOperation,
MTLDevice as _,
MTLLibrary,
MTLPixelFormat,
MTLRenderPipelineDescriptor,
MTLRenderPipelineState,
MTLVertexDescriptor,
MTLVertexFormat,
MTLVertexStepFunction,
};
use super::{Device, Error};
use crate::metal_layout::{
MetalVertex,
PREMULTIPLIED_ALPHA,
PixelFormat,
VertexAttribute,
VertexStepFunction,
};
/// Describes a color attachment.
#[derive(Debug, Clone, Copy)]
pub struct Attachment {
pub pixel_format: PixelFormat,
pub blending_enabled: bool,
}
/// Options for initializing a render pipeline.
pub struct Options<'a> {
/// MTLDevice
pub device: &'a Device,
/// Name of the vertex function
pub vertex_fn: &'a str,
/// Name of the fragment function
pub fragment_fn: &'a str,
/// MTLLibrary to get the vertex function from
pub vertex_library: &'a ProtocolObject<dyn MTLLibrary>,
/// MTLLibrary to get the fragment function from
pub fragment_library: &'a ProtocolObject<dyn MTLLibrary>,
/// Vertex step function
pub step_fn: VertexStepFunction,
/// Info about the color attachments used by this render pipeline.
pub attachments: &'a [Attachment],
}
/// A render pipeline state.
pub struct Pipeline {
/// MTLRenderPipelineState
state: Retained<ProtocolObject<dyn MTLRenderPipelineState>>,
}
// SAFETY: MTLRenderPipelineState objects are immutable and thread-safe.
unsafe impl Send for Pipeline {}
// SAFETY: see above.
unsafe impl Sync for Pipeline {}
impl Pipeline {
/// The underlying MTLRenderPipelineState.
pub fn state(&self) -> &ProtocolObject<dyn MTLRenderPipelineState> {
&self.state
}
/// Create a pipeline whose vertex attributes are those of `V`.
pub fn init<V: MetalVertex>(opts: &Options<'_>) -> Result<Pipeline, Error> {
Self::init_impl(Some((V::ATTRIBUTES, size_of::<V>())), opts)
}
/// Create a pipeline without a vertex descriptor.
pub fn init_no_attributes(opts: &Options<'_>) -> Result<Pipeline, Error> {
Self::init_impl(None, opts)
}
fn init_impl(
vertex_attributes: Option<(&[VertexAttribute], usize)>,
opts: &Options<'_>,
) -> Result<Pipeline, Error> {
// Create our descriptor
let desc = MTLRenderPipelineDescriptor::new();
// Get our vertex and fragment functions and add them to the descriptor.
{
let name = NSString::from_str(opts.vertex_fn);
let func_vert = opts
.vertex_library
.newFunctionWithName(&name)
.ok_or_else(|| Error::MissingFunction(opts.vertex_fn.to_owned()))?;
desc.setVertexFunction(Some(&func_vert));
}
{
let name = NSString::from_str(opts.fragment_fn);
let func_frag = opts
.fragment_library
.newFunctionWithName(&name)
.ok_or_else(|| Error::MissingFunction(opts.fragment_fn.to_owned()))?;
desc.setFragmentFunction(Some(&func_frag));
}
// If we have vertex attributes, create and add a vertex descriptor.
if let Some((attributes, stride)) = vertex_attributes {
let vertex_desc = MTLVertexDescriptor::new();
// Our attributes are the fields of the input
let attrs = vertex_desc.attributes();
for (i, a) in attributes.iter().enumerate() {
// SAFETY: indexing the attribute array with a valid index
// (the descriptor array has 31 slots); offsets and buffer
// index are plain numbers validated by pipeline creation.
unsafe {
let attr = attrs.objectAtIndexedSubscript(i);
attr.setFormat(MTLVertexFormat(a.format as usize));
attr.setOffset(a.offset);
attr.setBufferIndex(0);
}
}
// The layout describes how and when we fetch the next vertex input.
let layouts = vertex_desc.layouts();
// SAFETY: buffer layout 0 exists; the stride is a plain number.
unsafe {
let layout = layouts.objectAtIndexedSubscript(0);
layout.setStepFunction(MTLVertexStepFunction(opts.step_fn as usize));
layout.setStride(stride);
}
desc.setVertexDescriptor(Some(&vertex_desc));
}
// Set our color attachment
let attachments = desc.colorAttachments();
for (i, at) in opts.attachments.iter().enumerate() {
// SAFETY: color attachment indexes are validated by Metal; the
// pipelines use a single attachment.
let attachment = unsafe { attachments.objectAtIndexedSubscript(i) };
attachment.setPixelFormat(MTLPixelFormat(at.pixel_format as usize));
attachment.setBlendingEnabled(at.blending_enabled);
// We always use premultiplied alpha blending for now.
if at.blending_enabled {
let b = PREMULTIPLIED_ALPHA;
attachment.setRgbBlendOperation(MTLBlendOperation(b.rgb_operation as usize));
attachment.setAlphaBlendOperation(MTLBlendOperation(b.alpha_operation as usize));
attachment.setSourceRGBBlendFactor(MTLBlendFactor(b.source_rgb as usize));
attachment.setSourceAlphaBlendFactor(MTLBlendFactor(b.source_alpha as usize));
attachment.setDestinationRGBBlendFactor(MTLBlendFactor(b.destination_rgb as usize));
attachment
.setDestinationAlphaBlendFactor(MTLBlendFactor(b.destination_alpha as usize));
}
}
// Make our state
let state = opts
.device
.newRenderPipelineStateWithDescriptor_error(&desc)
.map_err(|err| Error::from_ns_error(&err))?;
Ok(Pipeline { state })
}
}
@@ -0,0 +1,198 @@
//! Wrapper for handling render passes (`metal/RenderPass.zig`).
use objc2::{rc::Retained, runtime::ProtocolObject};
use objc2_metal::{
MTLClearColor,
MTLCommandBuffer,
MTLCommandEncoder as _,
MTLLoadAction,
MTLPrimitiveType,
MTLRenderCommandEncoder,
MTLRenderPassDescriptor,
MTLStoreAction,
};
use super::Metal;
use crate::{
graphics_api::{Attachment, AttachmentTarget, GpuRenderPass, Primitive, Step},
metal_layout::{UNIFORMS_BUFFER_INDEX, buffer_index},
};
/// The `MTLPrimitiveType` of a [`Primitive`].
fn primitive_type(p: Primitive) -> MTLPrimitiveType {
match p {
Primitive::Point => MTLPrimitiveType::Point,
Primitive::Line => MTLPrimitiveType::Line,
Primitive::LineStrip => MTLPrimitiveType::LineStrip,
Primitive::Triangle => MTLPrimitiveType::Triangle,
Primitive::TriangleStrip => MTLPrimitiveType::TriangleStrip,
}
}
/// A render pass being encoded.
pub struct RenderPass {
/// MTLRenderCommandEncoder (`None` if the encoder couldn't be created, in
/// which case every step is a no-op).
encoder: Option<Retained<ProtocolObject<dyn MTLRenderCommandEncoder>>>,
}
impl RenderPass {
/// Begin a render pass.
pub fn begin(
command_buffer: Option<&ProtocolObject<dyn MTLCommandBuffer>>,
attachments: &[Attachment<'_, Metal>],
) -> RenderPass {
let Some(command_buffer) = command_buffer else {
return RenderPass { encoder: None };
};
// Create a pass descriptor
let desc = MTLRenderPassDescriptor::renderPassDescriptor();
// Set our color attachment to be our drawable surface.
let color_attachments = desc.colorAttachments();
for (i, at) in attachments.iter().enumerate() {
// SAFETY: color attachment indexes are validated by Metal.
let attachment = unsafe { color_attachments.objectAtIndexedSubscript(i) };
attachment.setLoadAction(if at.clear_color.is_some() {
MTLLoadAction::Clear
} else {
MTLLoadAction::Load
});
attachment.setStoreAction(MTLStoreAction::Store);
attachment.setTexture(Some(match &at.target {
AttachmentTarget::Texture(t) => t.texture(),
AttachmentTarget::Target(t) => t.texture(),
}));
if let Some(c) = at.clear_color {
attachment.setClearColor(MTLClearColor {
red: f64::from(c[0]),
green: f64::from(c[1]),
blue: f64::from(c[2]),
alpha: f64::from(c[3]),
});
}
}
// MTLRenderCommandEncoder
let encoder = command_buffer.renderCommandEncoderWithDescriptor(&desc);
if encoder.is_none() {
log::warn!("unable to create a Metal render command encoder");
}
RenderPass { encoder }
}
/// Add a step to this render pass.
pub fn step(&self, s: &Step<'_, Metal>) {
let Some(encoder) = &self.encoder else {
return;
};
if s.draw.instance_count == 0 {
return;
}
// Set pipeline state
encoder.setRenderPipelineState(s.pipeline.state());
if !s.buffers.is_empty() {
// We set buffers for both stages, at index 0 for the vertex
// buffer (this isn't very flexible but it lines up with the API we
// have for OpenGL) and the rest starting at index 2, so that we
// can use index 1 for the uniforms if present.
//
// Also, we set buffers (and textures) for both stages.
//
// Again, not very flexible, but it's consistent and predictable,
// and we need to treat the uniforms as special because of OpenGL.
//
// TODO: Maybe in the future add info to the pipeline struct which
// allows it to define a mapping between provided buffers and
// what index they get set at for the vertex / fragment stage.
for (i, b) in s.buffers.iter().enumerate() {
if let Some(buf) = b {
// SAFETY: the buffer is a live MTLBuffer.
unsafe {
encoder.setVertexBuffer_offset_atIndex(Some(buf), 0, buffer_index(i));
encoder.setFragmentBuffer_offset_atIndex(Some(buf), 0, buffer_index(i));
}
}
}
}
// Set the uniforms as buffer index 1 if present.
if let Some(buf) = &s.uniforms {
// SAFETY: the buffer is a live MTLBuffer.
unsafe {
encoder.setVertexBuffer_offset_atIndex(Some(buf), 0, UNIFORMS_BUFFER_INDEX);
encoder.setFragmentBuffer_offset_atIndex(Some(buf), 0, UNIFORMS_BUFFER_INDEX);
}
}
// Set textures.
for (i, t) in s.textures.iter().enumerate() {
if let Some(tex) = t {
// SAFETY: the texture is a live MTLTexture.
unsafe {
encoder.setVertexTexture_atIndex(Some(tex.texture()), i);
encoder.setFragmentTexture_atIndex(Some(tex.texture()), i);
}
}
}
// Set samplers.
for (i, samp) in s.samplers.iter().enumerate() {
if let Some(sampler) = samp {
// SAFETY: the sampler is a live MTLSamplerState.
unsafe {
encoder.setFragmentSamplerState_atIndex(Some(sampler.sampler()), i);
}
}
}
// Draw!
//
// SAFETY: all resources the pipeline reads were bound above (the
// same contract as the Zig renderer; Metal validation reports misuse).
unsafe {
encoder.drawPrimitives_vertexStart_vertexCount_instanceCount(
primitive_type(s.draw.ty),
0,
s.draw.vertex_count,
s.draw.instance_count,
);
}
}
/// Complete this render pass.
/// This struct can no longer be used after calling this.
pub fn complete(self) {
if let Some(encoder) = &self.encoder {
encoder.endEncoding();
}
}
}
impl GpuRenderPass<'_, Metal> for RenderPass {
fn step(&mut self, step: &Step<'_, Metal>) {
RenderPass::step(self, step);
}
fn complete(self) {
RenderPass::complete(self);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn primitive_mapping() {
// MTLPrimitiveType raw values (api.zig).
assert_eq!(primitive_type(Primitive::Point).0, 0);
assert_eq!(primitive_type(Primitive::Line).0, 1);
assert_eq!(primitive_type(Primitive::LineStrip).0, 2);
assert_eq!(primitive_type(Primitive::Triangle).0, 3);
assert_eq!(primitive_type(Primitive::TriangleStrip).0, 4);
}
}
@@ -0,0 +1,53 @@
//! Wrapper for handling samplers (`metal/Sampler.zig`).
use objc2::{rc::Retained, runtime::ProtocolObject};
use objc2_metal::{
MTLDevice as _,
MTLSamplerAddressMode,
MTLSamplerDescriptor,
MTLSamplerMinMagFilter,
MTLSamplerState,
};
use super::{Device, Error};
/// Options for initializing a sampler.
#[derive(Clone)]
pub struct Options {
/// MTLDevice
pub device: Device,
pub min_filter: MTLSamplerMinMagFilter,
pub mag_filter: MTLSamplerMinMagFilter,
pub s_address_mode: MTLSamplerAddressMode,
pub t_address_mode: MTLSamplerAddressMode,
}
/// A sampler state.
pub struct Sampler {
/// The underlying MTLSamplerState Object.
sampler: Retained<ProtocolObject<dyn MTLSamplerState>>,
}
impl Sampler {
/// The underlying MTLSamplerState.
pub fn sampler(&self) -> &ProtocolObject<dyn MTLSamplerState> {
&self.sampler
}
/// Initialize a sampler
pub fn init(opts: &Options) -> Result<Sampler, Error> {
// Create our descriptor and set the properties
let desc = MTLSamplerDescriptor::new();
desc.setMinFilter(opts.min_filter);
desc.setMagFilter(opts.mag_filter);
desc.setSAddressMode(opts.s_address_mode);
desc.setTAddressMode(opts.t_address_mode);
// Create the sampler state
let sampler = opts
.device
.newSamplerStateWithDescriptor(&desc)
.ok_or(Error::MetalFailed)?;
Ok(Sampler { sampler })
}
}
@@ -0,0 +1,209 @@
//! Shader pipelines and GPU data layouts for Metal (`metal/shaders.zig`).
//!
//! The CPU-side data layouts are shared with OpenGL
//! ([`crate::shader_types`]) except for the uniform block, see
//! [`crate::metal_layout::MetalUniforms`].
use std::time::Instant;
use objc2::{rc::Retained, runtime::ProtocolObject};
use objc2_foundation::NSString;
use objc2_metal::{MTLDevice as _, MTLLibrary};
use super::{
Device,
Error,
pipeline::{self, Attachment, Pipeline},
};
use crate::{
graphics_api::GpuShaders,
metal_layout::{
FULL_SCREEN_VERTEX_FN,
PIPELINE_DESCS,
POST_FRAGMENT_FN,
PipelineDesc,
PixelFormat,
VertexStepFunction,
},
shader_types::{BgImage, CellText, Image as ImageVertex},
};
/// The Metal shading language source of all built-in shaders
/// (`shaders/shaders.metal`). Ghostty compiles this to a `.metallib` at build
/// time with `xcrun metal`; we compile it with `newLibraryWithSource` when the
/// shaders are initialized.
pub const SHADERS_METAL: &str = include_str!("../../shaders/shaders.metal");
/// This contains the state for the shaders used by the Metal renderer.
pub struct Shaders {
library: Retained<ProtocolObject<dyn MTLLibrary>>,
pub bg_color: Pipeline,
pub cell_bg: Pipeline,
pub cell_text: Pipeline,
pub image: Pipeline,
pub bg_image: Pipeline,
/// Custom shaders to run against the final drawable texture. This
/// can be used to apply a lot of effects. Each shader is run in sequence
/// against the output of the previous shader.
pub post_pipelines: Vec<Pipeline>,
}
// SAFETY: MTLLibrary objects are thread-safe, and pipelines are immutable.
unsafe impl Send for Shaders {}
// SAFETY: see above.
unsafe impl Sync for Shaders {}
impl Shaders {
/// Initialize our shader set.
///
/// "post_shaders" is an optional list of postprocess shaders to run
/// against the final drawable texture. This is an array of shader source
/// code, not file paths.
pub fn init<S: AsRef<str>>(
device: &Device,
post_shaders: &[S],
pixel_format: PixelFormat,
) -> Result<Shaders, Error> {
let library = init_library(device)?;
let make = |desc: &PipelineDesc| -> Result<Pipeline, Error> {
let opts = pipeline::Options {
device,
vertex_fn: desc.vertex_fn,
fragment_fn: desc.fragment_fn,
vertex_library: &library,
fragment_library: &library,
step_fn: desc.step_fn,
attachments: &[Attachment {
pixel_format,
blending_enabled: desc.blending_enabled,
}],
};
match desc.name {
"cell_text" => Pipeline::init::<CellText>(&opts),
"image" => Pipeline::init::<ImageVertex>(&opts),
"bg_image" => Pipeline::init::<BgImage>(&opts),
_ => Pipeline::init_no_attributes(&opts),
}
};
let [bg_color, cell_bg, cell_text, image, bg_image] = &PIPELINE_DESCS;
let bg_color = make(bg_color)?;
let cell_bg = make(cell_bg)?;
let cell_text = make(cell_text)?;
let image = make(image)?;
let bg_image = make(bg_image)?;
let post_pipelines = init_post_pipelines(device, &library, post_shaders, pixel_format)
.unwrap_or_else(|err| {
// If an error happens while building postprocess shaders we
// want to just not use any postprocess shaders since we don't
// want to block Ghostty from working.
log::warn!("error initializing postprocess shaders err={err}");
Vec::new()
});
Ok(Shaders {
library,
bg_color,
cell_bg,
cell_text,
image,
bg_image,
post_pipelines,
})
}
/// The compiled library of the built-in shaders.
pub fn library(&self) -> &ProtocolObject<dyn MTLLibrary> {
&self.library
}
}
impl GpuShaders<super::Metal> for Shaders {
fn bg_color(&self) -> &Pipeline {
&self.bg_color
}
fn cell_bg(&self) -> &Pipeline {
&self.cell_bg
}
fn cell_text(&self) -> &Pipeline {
&self.cell_text
}
fn image(&self) -> &Pipeline {
&self.image
}
fn bg_image(&self) -> &Pipeline {
&self.bg_image
}
fn post_pipelines(&self) -> &[Pipeline] {
&self.post_pipelines
}
}
/// Initialize the MTLLibrary. A MTLLibrary is a collection of shaders.
fn init_library(device: &Device) -> Result<Retained<ProtocolObject<dyn MTLLibrary>>, Error> {
let start = Instant::now();
let source = NSString::from_str(SHADERS_METAL);
let library = device
.newLibraryWithSource_options_error(&source, None)
.map_err(|err| Error::from_ns_error(&err))?;
log::debug!(
"shader library loaded time={}us",
start.elapsed().as_micros()
);
Ok(library)
}
/// Initialize our custom shader pipelines.
///
/// The shaders argument is a set of shader source code, not file paths.
fn init_post_pipelines<S: AsRef<str>>(
device: &Device,
library: &ProtocolObject<dyn MTLLibrary>,
shaders: &[S],
pixel_format: PixelFormat,
) -> Result<Vec<Pipeline>, Error> {
// If we have no shaders, do nothing. If any error happens the
// already-built pipelines are dropped (released).
shaders
.iter()
.map(|source| init_post_pipeline(device, library, source.as_ref(), pixel_format))
.collect()
}
/// Initialize a single custom shader pipeline from shader source.
fn init_post_pipeline(
device: &Device,
library: &ProtocolObject<dyn MTLLibrary>,
data: &str,
pixel_format: PixelFormat,
) -> Result<Pipeline, Error> {
// Create our library which has the shader source
let source = NSString::from_str(data);
let post_library = device
.newLibraryWithSource_options_error(&source, None)
.map_err(|err| Error::from_ns_error(&err))?;
Pipeline::init_no_attributes(&pipeline::Options {
device,
vertex_fn: FULL_SCREEN_VERTEX_FN,
fragment_fn: POST_FRAGMENT_FN,
vertex_library: library,
fragment_library: &post_library,
step_fn: VertexStepFunction::PerVertex,
attachments: &[Attachment {
pixel_format,
blending_enabled: false,
}],
})
}
+199
View File
@@ -0,0 +1,199 @@
//! Represents a render target (`metal/Target.zig`).
//!
//! In this case, an IOSurface-backed MTLTexture.
use objc2::{rc::Retained, runtime::ProtocolObject};
use objc2_core_foundation::{CFDictionary, CFNumber, CFRetained, CFString, CFType};
use objc2_core_graphics::{CGColorSpace, kCGColorSpaceDisplayP3};
use objc2_io_surface::{
IOSurfaceRef,
kIOSurfaceBytesPerElement,
kIOSurfaceColorSpace,
kIOSurfaceHeight,
kIOSurfacePixelFormat,
kIOSurfaceWidth,
};
use objc2_metal::{
MTLDevice as _,
MTLPixelFormat,
MTLResourceOptions,
MTLTexture,
MTLTextureDescriptor,
MTLTextureUsage,
};
use super::{Device, Error};
use crate::{
graphics_api::GpuTarget,
metal_layout::{PixelFormat, ResourceOptions, StorageMode, TextureUsage},
};
/// `kCVPixelFormatType_32BGRA` ('BGRA'): `video.PixelFormat.@"32BGRA"`.
pub const PIXEL_FORMAT_32BGRA: i32 = 0x4247_5241;
/// `kIOSurfacePurgeableEmpty`.
const IOSURFACE_PURGEABLE_EMPTY: u32 = 2;
/// Options for initializing a Target
pub struct Options {
/// MTLDevice
pub device: Device,
/// Desired width
pub width: usize,
/// Desired height
pub height: usize,
/// Pixel format for the MTLTexture
pub pixel_format: PixelFormat,
/// Storage mode for the MTLTexture
pub storage_mode: StorageMode,
}
/// An IOSurface-backed render target.
pub struct Target {
/// The underlying IOSurface.
surface: CFRetained<IOSurfaceRef>,
/// The underlying MTLTexture.
texture: Retained<ProtocolObject<dyn MTLTexture>>,
/// Current width of this target.
pub width: usize,
/// Current height of this target.
pub height: usize,
}
// SAFETY: IOSurfaces and MTLTextures are thread-safe reference counted
// objects; the target is only mutated by the GPU.
unsafe impl Send for Target {}
// SAFETY: see above.
unsafe impl Sync for Target {}
impl Target {
/// The underlying IOSurface.
pub fn surface(&self) -> &CFRetained<IOSurfaceRef> {
&self.surface
}
/// The underlying MTLTexture.
pub fn texture(&self) -> &ProtocolObject<dyn MTLTexture> {
&self.texture
}
pub fn init(opts: &Options) -> Result<Target, Error> {
// We set our surface's color space to Display P3.
// This allows us to have "Apple-style" alpha blending,
// since it seems to be the case that Apple apps like
// Terminal and TextEdit render text in the display's
// color space using converted colors, which reduces,
// but does not fully eliminate blending artifacts.
//
// SAFETY: a constant CFString exported by CoreGraphics.
let colorspace = CGColorSpace::with_name(Some(unsafe { kCGColorSpaceDisplayP3 }))
.ok_or(Error::MetalFailed)?;
let surface = create_surface(opts.width, opts.height, &colorspace)?;
// Create our descriptor and set our properties
let desc = MTLTextureDescriptor::new();
// SAFETY: plain numbers, validated when the texture is created.
unsafe {
desc.setWidth(opts.width);
desc.setHeight(opts.height);
}
desc.setPixelFormat(MTLPixelFormat(opts.pixel_format as usize));
desc.setUsage(MTLTextureUsage(
TextureUsage {
render_target: true,
..TextureUsage::default()
}
.bits() as usize,
));
desc.setResourceOptions(MTLResourceOptions(
// Indicate that the CPU writes to this resource but never reads it.
ResourceOptions::write_combined(opts.storage_mode).bits() as usize,
));
let texture = opts
.device
.newTextureWithDescriptor_iosurface_plane(&desc, &surface, 0)
.ok_or(Error::MetalFailed)?;
Ok(Target {
surface,
texture,
width: opts.width,
height: opts.height,
})
}
}
impl Drop for Target {
fn drop(&mut self) {
// We mark it purgeable so that it is immediately unloaded, so that we
// don't have to wait for CoreFoundation garbage collection to trigger.
//
// SAFETY: a null `old_state` is allowed.
unsafe {
self.surface
.set_purgeable(IOSURFACE_PURGEABLE_EMPTY, std::ptr::null_mut());
}
}
}
impl GpuTarget for Target {
fn width(&self) -> usize {
self.width
}
fn height(&self) -> usize {
self.height
}
}
/// `IOSurface.init`: a BGRA IOSurface of the given size with a color space.
fn create_surface(
width: usize,
height: usize,
colorspace: &CGColorSpace,
) -> Result<CFRetained<IOSurfaceRef>, Error> {
let w = CFNumber::new_i32(i32::try_from(width).map_err(|_| Error::MetalFailed)?);
let h = CFNumber::new_i32(i32::try_from(height).map_err(|_| Error::MetalFailed)?);
let pf = CFNumber::new_i32(PIXEL_FORMAT_32BGRA);
let bpe = CFNumber::new_i32(4);
// SAFETY: constant CFStrings exported by IOSurface.
let keys: [&CFString; 4] = unsafe {
[
kIOSurfaceWidth,
kIOSurfaceHeight,
kIOSurfacePixelFormat,
kIOSurfaceBytesPerElement,
]
};
let values: [&CFNumber; 4] = [&w, &h, &pf, &bpe];
let properties = CFDictionary::<CFString, CFNumber>::from_slices(&keys, &values);
// SAFETY: the dictionary has the CFString keys / CFNumber values
// IOSurfaceCreate expects.
let surface = unsafe { IOSurfaceRef::new(properties.as_opaque()) }.ok_or(Error::MetalFailed)?;
// setColorSpace: serialize the colorspace into the surface properties.
if let Some(serialized) = colorspace.property_list() {
let value: &CFType = serialized.as_ref();
// SAFETY: the color space key takes a property list value.
unsafe { surface.set_value(kIOSurfaceColorSpace, value) };
}
Ok(surface)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bgra_fourcc() {
assert_eq!(PIXEL_FORMAT_32BGRA, i32::from_be_bytes(*b"BGRA"));
}
}
@@ -0,0 +1,156 @@
//! Wrapper for handling textures (`metal/Texture.zig`).
use std::{ffi::c_void, ptr::NonNull};
use objc2::{rc::Retained, runtime::ProtocolObject};
use objc2_metal::{
MTLDevice as _,
MTLOrigin,
MTLPixelFormat,
MTLRegion,
MTLResourceOptions,
MTLSize,
MTLTexture,
MTLTextureDescriptor,
MTLTextureUsage,
};
use super::{Device, Error};
use crate::{
graphics_api::{ApiError, GpuTexture},
metal_layout::{PixelFormat, ResourceOptions, TextureUsage},
};
/// Options for initializing a texture.
#[derive(Clone)]
pub struct Options {
/// MTLDevice
pub device: Device,
pub pixel_format: PixelFormat,
pub resource_options: ResourceOptions,
pub usage: TextureUsage,
}
/// A 2D texture.
pub struct Texture {
/// The underlying MTLTexture Object.
texture: Retained<ProtocolObject<dyn MTLTexture>>,
/// The width of this texture.
pub width: usize,
/// The height of this texture.
pub height: usize,
/// Bytes per pixel for this texture.
pub bpp: usize,
}
// SAFETY: MTLTexture objects may be used from any thread (the CPU side only
// calls `replaceRegion`, which the renderer serializes through its draw
// mutex).
unsafe impl Send for Texture {}
// SAFETY: see above.
unsafe impl Sync for Texture {}
impl Texture {
/// The underlying MTLTexture.
pub fn texture(&self) -> &ProtocolObject<dyn MTLTexture> {
&self.texture
}
/// Initialize a texture
pub fn init(
opts: &Options,
width: usize,
height: usize,
data: Option<&[u8]>,
) -> Result<Texture, Error> {
// Create our descriptor and set our properties
let desc = MTLTextureDescriptor::new();
desc.setPixelFormat(MTLPixelFormat(opts.pixel_format as usize));
// SAFETY: plain numbers, validated when the texture is created.
unsafe {
desc.setWidth(width);
desc.setHeight(height);
}
desc.setResourceOptions(MTLResourceOptions(opts.resource_options.bits() as usize));
desc.setUsage(MTLTextureUsage(opts.usage.bits() as usize));
// Initialize
let texture = opts
.device
.newTextureWithDescriptor(&desc)
.ok_or(Error::MetalFailed)?;
let this = Texture {
texture,
width,
height,
bpp: opts.pixel_format.bytes_per_pixel(),
};
// If we have data, we set it here.
if let Some(d) = data {
debug_assert_eq!(d.len(), width * height * this.bpp);
this.replace_region(0, 0, width, height, d)?;
}
Ok(this)
}
/// Replace a region of the texture with the provided data.
///
/// Zig does NOT check the dimensions of the data to ensure correctness;
/// since this is a safe function we reject data that is too short.
pub fn replace_region(
&self,
x: usize,
y: usize,
width: usize,
height: usize,
data: &[u8],
) -> Result<(), Error> {
let bytes_per_row = self.bpp * width;
if data.len() < bytes_per_row * height {
return Err(Error::MetalFailed);
}
let Some(ptr) = NonNull::new(data.as_ptr().cast_mut().cast::<c_void>()) else {
return Err(Error::MetalFailed);
};
let region = MTLRegion {
origin: MTLOrigin { x, y, z: 0 },
size: MTLSize {
width,
height,
depth: 1,
},
};
// SAFETY: `ptr` points to at least `bytes_per_row * height` readable
// bytes (checked above); Metal only reads from it.
unsafe {
self.texture
.replaceRegion_mipmapLevel_withBytes_bytesPerRow(region, 0, ptr, bytes_per_row);
}
Ok(())
}
}
impl GpuTexture for Texture {
fn width(&self) -> usize {
self.width
}
fn height(&self) -> usize {
self.height
}
fn replace_region(
&self,
x: usize,
y: usize,
width: usize,
height: usize,
data: &[u8],
) -> Result<(), ApiError> {
Texture::replace_region(self, x, y, width, height, data).map_err(ApiError::new)
}
}