Ports apprt/embedded.zig, main_c.zig and config/CApi.zig + c_get.zig as crates/ghostty-lib (cdylib/staticlib/rlib `libghostty`), exporting every function of the unchanged include/ghostty.h - the ABI Ghostty's Swift macOS/iOS app links against. The embedded runtime implements the runtime traits over the host's C callbacks, so the real core drives it. - ghostty-core selects the renderer per target: Metal on Apple (surface NSView/UIView via RtSurface::platform_view), OpenGL elsewhere. - ghostty_config_get follows Zig's c_get rules via a CValue type (143 supported keys, checked against the Zig field types). - The inspector C API is complete; its Metal backend replaces ImGui's Metal renderer and has not run. Verification: generated C ABI probe (sizeof/alignof/offsetof of every struct/union, 755 enum values, every function linked, nm exports, headers byte-identical to upstream); a C host on Linux covering init, CLI actions, config, app, keys, focus and color scheme; an opt-in test-platform feature creating real surfaces on Linux with headless EGL and a pty (13 tests). Apple targets are clippy-clean but not run; the Swift app has not been built against it. Test Plan: - cargo test -p ghostty-lib (and --features test-platform) - cargo clippy --target aarch64-apple-darwin -p ghostty-lib (cross env in docs/rewrite/notes/macos-M1.md)
540 lines
19 KiB
Rust
540 lines
19 KiB
Rust
//! 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,
|
||
})
|
||
}
|
||
|
||
/// [`Metal::init`] for a raw `NSView` / `UIView` pointer as handed over
|
||
/// through the C API (`ghostty_platform_macos_s.nsview`). The view must
|
||
/// stay alive for the lifetime of the renderer (the host owns it).
|
||
pub fn init_with_view(
|
||
blending: AlphaBlending,
|
||
view: *mut c_void,
|
||
scale_factor: f64,
|
||
) -> Result<Metal, Error> {
|
||
if view.is_null() {
|
||
return Err(Error::ObjCFailed);
|
||
}
|
||
// SAFETY: the caller (the embedded runtime) guarantees `view` is a
|
||
// valid, live NSView / UIView, which is an Objective-C object.
|
||
let view: &AnyObject = unsafe { &*view.cast::<AnyObject>() };
|
||
Metal::init(blending, &ViewInfo { view, scale_factor })
|
||
}
|
||
|
||
/// 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));
|
||
}
|
||
}
|