mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-08-25 00:21:46 +00:00
macos: reduce app launch time ~15%, time to first frame ~27% (#13722)
Startup optimizations for macOS! Highlights: * Process exec to visible window: **15% reduction, ~193ms to ~165ms.** * Time to first rendered frame: **27% reduction, ~126ms to ~92ms.** * Zig startup time goes from **20ms to ~5ms**, the remainder is AppKit/Swift stuff. > [!NOTE] > > "Time to first rendered frame?" I measured the time between global init start to the first Metal callback saying that a frame was completed/drawn. This is faster than when it is _presented_ because we can create an IOSurfaceLayer and draw to it before AppKit finishes its startup and shows the window. But, the good news is this means that when the window is shown, the frame is already drawn! See individual commits for speeds, but a summary below: 1. **Resolve Sentry directories on the init thread, not startup thread (~3-4ms).** Sentry init already ran on a thread, but directory resolution happened on the main thread first, and on macOS that calls `NSFileManager URLForDirectory:` which is slow as shit. 2. **Initialize the TIS keymap lazily (~7ms).** The keymap is only needed once keyboard events flow. If AppKit isn't warmed up, this is SLOW. Defer setup until its needed. 3. **Warm up the font registry and Metal on background threads (~7ms+ off the first surface).** The first CoreText query initializes the system font database (~7ms) and the first Metal device/queue/pipeline use pays framework init and shader compilation costs. `App.create` now spawns a detached warmup thread per subsystem so this overlaps config load, AppKit launch, and window creation. First font grid init went from ~4.5ms to ~1.1ms, renderer init from ~6.8ms to ~1.5ms. 4. **Look up Apple Color Emoji by exact name.** We know exactly which font we want, so skip the system-wide `CTFontCollection` matching (~312us to ~13us). 5. **Cache unified logging loggers per scope.** We created and released an `os_log_t` on every log call. I actually had a comment saying this is slow but probably won't matter. Well, we log a lot on startup, and this actually mattered. ## Warmup Threads As a note, some of the biggest speedups are by using "warmup" threads. These are one-time launched threads on system start that basically just "touch" the relevant frameworks (CoreText/Metal). The initial touching of these frameworks has a ton of cost associated with them (and they're thread-safe), so we can shave off a bunch of time by just touching them in the background. This sets up a race between our own startup needing it and these warmup threads, but in every case I measured, the warmup threads win. ## Linux All the optimizations here focused really on slow macOS APIs. I plan on measuring on Linux, but nothing here should slow it down. **AI usage:** Fable was used for this one to find the issues, help perform the measurements, and draft commit messages by splitting up my work. I wrote the code, then edited the commit messages. This PR message is fully hand-written.
This commit is contained in:
@@ -13,7 +13,15 @@ class ConfigurationErrorsController: NSWindowController, NSWindowDelegate, Confi
|
||||
@Published var errors: [String] = [] {
|
||||
didSet {
|
||||
if errors.count == 0 {
|
||||
self.window?.performClose(nil)
|
||||
// Only close the window if it was ever loaded: accessing
|
||||
// `window` on an NSWindowController loads the nib (and our
|
||||
// SwiftUI content view), which takes tens of milliseconds.
|
||||
// This happens on every app launch via the initial config
|
||||
// apply, when there are usually no errors and the window
|
||||
// was never loaded.
|
||||
if isWindowLoaded {
|
||||
self.window?.performClose(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1119,7 +1119,16 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
||||
// We don't run this logic in fullscreen because in fullscreen this will end up
|
||||
// removing the window and putting it into its own dedicated fullscreen, which is not
|
||||
// the expected or desired behavior of anyone I've found.
|
||||
if !window.styleMask.contains(.fullScreen) {
|
||||
//
|
||||
// We also only run this when the system tabbing preference is "always",
|
||||
// which is the only scenario AppKit will have auto-tabbed a fresh window
|
||||
// at this point: the tab bar "+" button goes through newWindowForTab
|
||||
// which we route through our own tab logic. This check matters because
|
||||
// accessing `window.tabGroup` materializes the window's tab group
|
||||
// machinery, which takes ~15-20ms and is otherwise not needed during
|
||||
// window creation.
|
||||
if NSWindow.userTabbingPreference == .always,
|
||||
!window.styleMask.contains(.fullScreen) {
|
||||
// If we have more than 1 window in our tab group we know we're a new window.
|
||||
// Since Ghostty manages tabbing manually this will never be more than one
|
||||
// at this point in the AppKit lifecycle (we add to the group after this).
|
||||
|
||||
@@ -18,6 +18,17 @@ pub const Font = opaque {
|
||||
) orelse Allocator.Error.OutOfMemory;
|
||||
}
|
||||
|
||||
pub fn createWithName(name: *foundation.String, size: f32) Allocator.Error!*Font {
|
||||
return @as(
|
||||
?*Font,
|
||||
@ptrFromInt(@intFromPtr(c.CTFontCreateWithName(
|
||||
@ptrCast(name),
|
||||
size,
|
||||
null,
|
||||
))),
|
||||
) orelse Allocator.Error.OutOfMemory;
|
||||
}
|
||||
|
||||
pub fn createForString(
|
||||
self: *Font,
|
||||
str: *foundation.String,
|
||||
|
||||
26
src/App.zig
26
src/App.zig
@@ -78,6 +78,32 @@ pub fn create(alloc: Allocator) CreateError!*App {
|
||||
var app = try alloc.create(App);
|
||||
errdefer alloc.destroy(app);
|
||||
try app.init(alloc);
|
||||
|
||||
// If font discovery supports warmup, then we call it. Some font
|
||||
// mechanisms (e.g. CoreText) have a multi-millisecond one-time cost
|
||||
// on startup.
|
||||
if (comptime @hasDecl(font.Discover, "warmup")) {
|
||||
if (std.Thread.spawn(
|
||||
.{},
|
||||
font.Discover.warmup,
|
||||
.{},
|
||||
)) |thr| thr.detach() else |err| {
|
||||
log.warn("font warmup thread spawn failed err={}", .{err});
|
||||
}
|
||||
}
|
||||
|
||||
// Same for the renderer's graphics API (e.g. Metal), which pays
|
||||
// one-time framework initialization costs on first use.
|
||||
if (comptime @hasDecl(renderer.Renderer.API, "warmup")) {
|
||||
if (std.Thread.spawn(
|
||||
.{},
|
||||
renderer.Renderer.API.warmup,
|
||||
.{},
|
||||
)) |thr| thr.detach() else |err| {
|
||||
log.warn("renderer warmup thread spawn failed err={}", .{err});
|
||||
}
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
|
||||
@@ -120,7 +120,13 @@ pub const App = struct {
|
||||
|
||||
core_app: *CoreApp,
|
||||
opts: Options,
|
||||
keymap: input.Keymap,
|
||||
|
||||
/// The keyboard layout keymap. This is lazily initialized on first
|
||||
/// use because creating it requires talking to the text input
|
||||
/// system (TIS on macOS), and the first such call in a process is
|
||||
/// slow (multiple milliseconds). It is only needed once keyboard
|
||||
/// events start flowing, at which point the system is warm.
|
||||
keymap: ?input.Keymap,
|
||||
|
||||
/// The configuration for the app. This is owned by this structure.
|
||||
config: Config,
|
||||
@@ -136,19 +142,16 @@ pub const App = struct {
|
||||
var config_clone = try config.clone(alloc);
|
||||
errdefer config_clone.deinit();
|
||||
|
||||
var keymap = try input.Keymap.init();
|
||||
errdefer keymap.deinit();
|
||||
|
||||
self.* = .{
|
||||
.core_app = core_app,
|
||||
.config = config_clone,
|
||||
.opts = opts,
|
||||
.keymap = keymap,
|
||||
.keymap = null,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn terminate(self: *App) void {
|
||||
self.keymap.deinit();
|
||||
if (self.keymap) |*v| v.deinit();
|
||||
self.config.deinit();
|
||||
}
|
||||
|
||||
@@ -208,8 +211,10 @@ pub const App = struct {
|
||||
|
||||
/// This should be called whenever the keyboard layout was changed.
|
||||
pub fn reloadKeymap(self: *App) !void {
|
||||
// Reload the keymap
|
||||
try self.keymap.reload();
|
||||
// Reload the keymap. If it was never initialized we don't need
|
||||
// to do anything since lazy initialization will pick up the
|
||||
// current layout.
|
||||
if (self.keymap) |*v| try v.reload();
|
||||
}
|
||||
|
||||
/// Loads the keyboard layout.
|
||||
@@ -217,13 +222,25 @@ pub const App = struct {
|
||||
/// Kind of expensive so this should be avoided if possible. When I say
|
||||
/// "kind of expensive" I mean that its not something you probably want
|
||||
/// to run on every keypress.
|
||||
pub fn keyboardLayout(self: *const App) input.KeyboardLayout {
|
||||
pub fn keyboardLayout(self: *App) input.KeyboardLayout {
|
||||
// We only support keyboard layout detection on macOS.
|
||||
if (comptime builtin.os.tag != .macos) return .unknown;
|
||||
|
||||
// Lazily initialize the keymap.
|
||||
const keymap: *input.Keymap = keymap: {
|
||||
if (self.keymap == null) {
|
||||
self.keymap = input.Keymap.init() catch |err| {
|
||||
log.warn("error initializing keymap err={}", .{err});
|
||||
return .unknown;
|
||||
};
|
||||
}
|
||||
|
||||
break :keymap &self.keymap.?;
|
||||
};
|
||||
|
||||
// Any layout larger than this is not something we can handle.
|
||||
var buf: [256]u8 = undefined;
|
||||
const id = self.keymap.sourceId(&buf) catch |err| {
|
||||
const id = keymap.sourceId(&buf) catch |err| {
|
||||
comptime assert(@TypeOf(err) == error{OutOfMemory});
|
||||
return .unknown;
|
||||
};
|
||||
|
||||
@@ -56,52 +56,55 @@ pub threadlocal var thread_state: ?ThreadState = null;
|
||||
/// crash reports and logs, but we only store them locally (see Transport).
|
||||
/// It is up to the user to grab the logs and manually send them to us
|
||||
/// (or to their own Sentry instance) if they want to.
|
||||
pub fn init(gpa: Allocator, environ_map: *const std.process.Environ.Map) !void {
|
||||
if (comptime !build_options.sentry) return;
|
||||
pub fn init(gpa: Allocator, environ_map: std.process.Environ.Map) !void {
|
||||
if (comptime !build_options.sentry) {
|
||||
var map = environ_map;
|
||||
map.deinit();
|
||||
return;
|
||||
}
|
||||
|
||||
// Not supported on Windows currently, doesn't build.
|
||||
if (comptime builtin.os.tag == .windows) return;
|
||||
if (comptime builtin.os.tag == .windows) {
|
||||
var map = environ_map;
|
||||
map.deinit();
|
||||
return;
|
||||
}
|
||||
|
||||
// Must only start once
|
||||
assert(init_thread == null);
|
||||
|
||||
// Get our directories.
|
||||
var single_threaded: std.Io.Threaded = .init_single_threaded;
|
||||
defer single_threaded.deinit();
|
||||
var fba: std.heap.FixedBufferAllocator = .init(&dir_mem);
|
||||
|
||||
state_dir_ = state_dir: {
|
||||
const dir = try crash.defaultDir(single_threaded.io(), gpa, environ_map);
|
||||
defer gpa.free(dir.path);
|
||||
break :state_dir try fba.allocator().dupe(u8, dir.path);
|
||||
};
|
||||
errdefer state_dir_ = null;
|
||||
|
||||
const cache_dir = cache_dir: {
|
||||
const dir = try cacheDir(single_threaded.io(), gpa, environ_map);
|
||||
defer gpa.free(dir);
|
||||
break :cache_dir try fba.allocator().dupe(u8, dir);
|
||||
};
|
||||
cache_dir_ = cache_dir;
|
||||
errdefer cache_dir_ = null;
|
||||
|
||||
// We use a thread for initializing Sentry because initialization takes
|
||||
// ~2k ns on my M3 Max. That's not a LOT of time but it's enough to be
|
||||
// 90% of our pre-App startup time. Everything Sentry is doing initially
|
||||
// is safe to do on a separate thread and fast enough that its very
|
||||
// likely to be done before a crash occurs.
|
||||
const thr = try std.Thread.spawn(
|
||||
// We use a thread for initializing Sentry because initialization is
|
||||
// slow enough to matter for process startup: resolving our directories
|
||||
// can take multiple milliseconds on macOS (Apple APIs) and Sentry's
|
||||
// own init does disk I/O. Everything Sentry is doing initially is safe
|
||||
// to do on a separate thread and fast enough that its very likely to
|
||||
// be done before a crash occurs.
|
||||
//
|
||||
// The environ map is a snapshot owned by the thread (and freed there),
|
||||
// so it is safe against concurrent mutations of the process environment
|
||||
// (e.g. ensureLocale on the main thread).
|
||||
const thr = std.Thread.spawn(
|
||||
.{},
|
||||
initThread,
|
||||
.{cache_dir},
|
||||
);
|
||||
.{ gpa, environ_map },
|
||||
) catch |err| {
|
||||
var map = environ_map;
|
||||
map.deinit();
|
||||
return err;
|
||||
};
|
||||
|
||||
// Naming the thread from here only works on some platforms (e.g.
|
||||
// Linux). On Darwin the thread names itself in initThread.
|
||||
var single_threaded: std.Io.Threaded = .init_single_threaded;
|
||||
defer single_threaded.deinit();
|
||||
thr.setName(single_threaded.io(), "sentry-init") catch {};
|
||||
|
||||
init_thread = thr;
|
||||
}
|
||||
|
||||
fn initThread(cache_dir: []const u8) !void {
|
||||
if (comptime !build_options.sentry) return;
|
||||
fn initThread(gpa: Allocator, environ_map_: std.process.Environ.Map) !void {
|
||||
var environ_map = environ_map_;
|
||||
defer environ_map.deinit();
|
||||
|
||||
// Right now, on Darwin, `std.Thread.setName` can only name the current
|
||||
// thread, and we have no way to get the current thread from within it,
|
||||
@@ -110,6 +113,34 @@ fn initThread(cache_dir: []const u8) !void {
|
||||
internal_os.macos.pthread_setname_np(&"sentry-init".*);
|
||||
}
|
||||
|
||||
// Get our directories.
|
||||
var single_threaded: std.Io.Threaded = .init_single_threaded;
|
||||
defer single_threaded.deinit();
|
||||
var fba: std.heap.FixedBufferAllocator = .init(&dir_mem);
|
||||
|
||||
state_dir_ = state_dir: {
|
||||
const dir = try crash.defaultDir(
|
||||
single_threaded.io(),
|
||||
gpa,
|
||||
&environ_map,
|
||||
);
|
||||
defer gpa.free(dir.path);
|
||||
break :state_dir try fba.allocator().dupe(u8, dir.path);
|
||||
};
|
||||
errdefer state_dir_ = null;
|
||||
|
||||
const cache_dir = cache_dir: {
|
||||
const dir = try cacheDir(
|
||||
single_threaded.io(),
|
||||
gpa,
|
||||
&environ_map,
|
||||
);
|
||||
defer gpa.free(dir);
|
||||
break :cache_dir try fba.allocator().dupe(u8, dir);
|
||||
};
|
||||
cache_dir_ = cache_dir;
|
||||
errdefer cache_dir_ = null;
|
||||
|
||||
const transport = sentry.Transport.init(&Transport.send);
|
||||
// This will crash if the transport was never used so we avoid
|
||||
// that for now. This probably leaks some memory but it'd be very
|
||||
|
||||
@@ -340,6 +340,24 @@ fn collection(
|
||||
// specifying a font-family for emoji.
|
||||
if (comptime builtin.target.os.tag.isDarwin() and Discover != void) apple_emoji: {
|
||||
const disco = try self.discover() orelse break :apple_emoji;
|
||||
|
||||
// Fast path: we know the exact name of the font we want so we
|
||||
// can look it up directly, which is sometimes significantly faster than
|
||||
// full discovery (e.g. CoreText).
|
||||
if (@hasDecl(Discover, "discoverExactFamily")) {
|
||||
if (try disco.discoverExactFamily(
|
||||
"Apple Color Emoji",
|
||||
)) |face| {
|
||||
_ = try c.addDeferred(self.alloc, face, .{
|
||||
.style = .regular,
|
||||
.fallback = true,
|
||||
// No size adjustment for emojis.
|
||||
.size_adjustment = .none,
|
||||
});
|
||||
break :apple_emoji;
|
||||
}
|
||||
}
|
||||
|
||||
var disco_it = try disco.discover(self.alloc, .{
|
||||
.family = "Apple Color Emoji",
|
||||
});
|
||||
|
||||
@@ -350,6 +350,22 @@ pub const CoreText = struct {
|
||||
_ = self;
|
||||
}
|
||||
|
||||
/// Warm up the system font registry.
|
||||
///
|
||||
/// The first CoreText query in a process initializes the system font
|
||||
/// database, which takes multiple milliseconds, while subsequent
|
||||
/// queries are microseconds.
|
||||
pub fn warmup() void {
|
||||
const name = macos.foundation.String.createWithBytes(
|
||||
"AppleColorEmoji",
|
||||
.utf8,
|
||||
false,
|
||||
) catch return;
|
||||
defer name.release();
|
||||
const ct_font = macos.text.Font.createWithName(name, 12) catch return;
|
||||
ct_font.release();
|
||||
}
|
||||
|
||||
/// Discover fonts from a descriptor. This returns an iterator that can
|
||||
/// be used to build up the deferred fonts.
|
||||
pub fn discover(self: *const CoreText, alloc: Allocator, desc: Descriptor) !DiscoverIterator {
|
||||
@@ -383,6 +399,54 @@ pub const CoreText = struct {
|
||||
};
|
||||
}
|
||||
|
||||
/// Discover a font by its exact name (family, full, or PostScript
|
||||
/// name). This is significantly faster than `discover` because it
|
||||
/// avoids the system-wide font matching that CTFontCollection does
|
||||
/// (which takes multiple milliseconds). This should be preferred
|
||||
/// when the desired font is known exactly, e.g. system fonts such
|
||||
/// as Apple Color Emoji.
|
||||
///
|
||||
/// Returns null if no font with this exact family name exists;
|
||||
/// CoreText fallback fonts are never returned.
|
||||
pub fn discoverExactFamily(
|
||||
self: *const CoreText,
|
||||
family: []const u8,
|
||||
) !?DeferredFace {
|
||||
_ = self;
|
||||
|
||||
const family_str = try macos.foundation.String.createWithBytes(
|
||||
family,
|
||||
.utf8,
|
||||
false,
|
||||
);
|
||||
defer family_str.release();
|
||||
|
||||
// Create our font. We need a size to initialize it so we use size
|
||||
// 12 but we will alter the size later (same as DiscoverIterator).
|
||||
const ct_font = try macos.text.Font.createWithName(family_str, 12);
|
||||
|
||||
// CTFontCreateWithName never returns null: if the requested font
|
||||
// isn't installed it returns a substitute font. Verify we got
|
||||
// the family we asked for, otherwise report not found.
|
||||
const found: bool = found: {
|
||||
const actual = ct_font.copyFamilyName();
|
||||
defer actual.release();
|
||||
var buf: [256]u8 = undefined;
|
||||
const actual_slice = actual.cstring(&buf, .utf8) orelse
|
||||
break :found false;
|
||||
break :found std.mem.eql(u8, actual_slice, family);
|
||||
};
|
||||
if (!found) {
|
||||
ct_font.release();
|
||||
return null;
|
||||
}
|
||||
|
||||
return .{ .ct = .{
|
||||
.font = ct_font,
|
||||
.variations = &.{},
|
||||
} };
|
||||
}
|
||||
|
||||
pub fn discoverFallback(
|
||||
self: *const CoreText,
|
||||
alloc: Allocator,
|
||||
|
||||
@@ -181,10 +181,10 @@ pub fn init(opts: InitOpts) !void {
|
||||
self.rlimits = .init();
|
||||
|
||||
if (build_options.sentry) {
|
||||
// Initialize our crash reporting.
|
||||
var environ_map = try self.environ.createMap(self.alloc);
|
||||
defer environ_map.deinit();
|
||||
crash.init(self.alloc, &environ_map) catch |err| {
|
||||
// Initialize our crash reporting. The environ map snapshot is
|
||||
// owned by crash.init (it is freed by the init thread).
|
||||
const environ_map = try self.environ.createMap(self.alloc);
|
||||
crash.init(self.alloc, environ_map) catch |err| {
|
||||
std.log.warn(
|
||||
"sentry init failed, no crash capture available err={}",
|
||||
.{err},
|
||||
|
||||
@@ -140,11 +140,12 @@ fn logFn(
|
||||
.err => .fault,
|
||||
};
|
||||
|
||||
// Initialize a logger. This is slow to do on every operation
|
||||
// but we shouldn't be logging too much.
|
||||
const logger = macos.os.Log.create(build_config.bundle_id, @tagName(scope));
|
||||
defer logger.release();
|
||||
logger.log(std.heap.c_allocator, mac_level, prefix ++ format, args);
|
||||
macosLogger(scope).log(
|
||||
std.heap.c_allocator,
|
||||
mac_level,
|
||||
prefix ++ format,
|
||||
args,
|
||||
);
|
||||
}
|
||||
|
||||
stderr: {
|
||||
@@ -166,6 +167,37 @@ fn logFn(
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the macOS unified logging logger for the given scope. The
|
||||
/// logger is created once per scope and cached for the lifetime of the
|
||||
/// process, because os_log object creation is slow (it shows up in
|
||||
/// startup profiles when done per log call) and Apple's guidance is to
|
||||
/// create loggers once and reuse them.
|
||||
fn macosLogger(comptime scope: @TypeOf(.EnumLiteral)) *macos.os.Log {
|
||||
const S = struct {
|
||||
var cached: std.atomic.Value(?*macos.os.Log) = .init(null);
|
||||
};
|
||||
|
||||
if (S.cached.load(.acquire)) |v| return v;
|
||||
|
||||
// Create and attempt to store our logger. If we race with another
|
||||
// thread then we use theirs and release ours.
|
||||
const created = macos.os.Log.create(
|
||||
build_config.bundle_id,
|
||||
@tagName(scope),
|
||||
);
|
||||
if (S.cached.cmpxchgStrong(
|
||||
null,
|
||||
created,
|
||||
.acq_rel,
|
||||
.acquire,
|
||||
)) |existing| {
|
||||
created.release();
|
||||
return existing.?;
|
||||
}
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
pub const std_options: std.Options = .{
|
||||
// Our log level is always at least info in every build mode.
|
||||
//
|
||||
|
||||
@@ -407,6 +407,44 @@ pub inline fn beginFrame(
|
||||
return try Frame.begin(.{ .queue = self.queue }, renderer, target);
|
||||
}
|
||||
|
||||
/// Warm up the Metal device machinery. The first Metal device query in
|
||||
/// a process takes multiple milliseconds; once warm, subsequent queries
|
||||
/// are effectively free. Calling this early (e.g. on a background
|
||||
/// thread at app startup; Metal device queries are thread-safe) moves
|
||||
/// that one-time cost off the critical path of the first surface's
|
||||
/// renderer initialization.
|
||||
pub fn warmup() void {
|
||||
const device = chooseDevice() catch return;
|
||||
defer device.release();
|
||||
|
||||
// Create and release a command queue. The first command queue
|
||||
// created for a device pays additional one-time driver setup
|
||||
// costs; subsequent creations are much cheaper.
|
||||
const queue = device.msgSend(objc.Object, objc.sel("newCommandQueue"), .{});
|
||||
queue.release();
|
||||
|
||||
// Build and discard our shader pipelines for both pixel formats we
|
||||
// may use (which one is used depends on the blending config). The
|
||||
// first pipeline state creation compiles shaders which is slow;
|
||||
// once warm, later creations hit driver and OS caches.
|
||||
inline for (.{
|
||||
mtl.MTLPixelFormat.bgra8unorm_srgb,
|
||||
mtl.MTLPixelFormat.bgra8unorm,
|
||||
}) |format| {
|
||||
if (shaders.Shaders.init(
|
||||
std.heap.c_allocator,
|
||||
device,
|
||||
&.{},
|
||||
format,
|
||||
)) |s| {
|
||||
var s_mut = s;
|
||||
s_mut.deinit(std.heap.c_allocator);
|
||||
} else |err| {
|
||||
log.warn("metal warmup shader init failed err={}", .{err});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn chooseDevice() error{NoMetalDevice}!objc.Object {
|
||||
var chosen_device: ?objc.Object = null;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user