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:
1 parent
76771bed0d
commit
bab49ee596
124 files changed
+14936
-1242
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))) };
|
||||
}
|
||||
@@ -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,
|
||||
}],
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user