From 6555153b21fb709cb153818fad73ba38d57aaea7 Mon Sep 17 00:00:00 2001 From: Ahmidou Lyazidi Date: Thu, 17 Sep 2026 23:35:38 -0700 Subject: [PATCH] Backends + Examples: Metal4: robust CAMetalLayer multi-viewport on macOS Rework the Metal 4 backend and example for correct, glitch-free multi-viewport rendering on macOS, and convert example_apple_metal4 off MTKView. Backend (imgui_impl_metal4.mm): - Per-window argument tables so overlapping per-window GPU work within a frame can't corrupt each other's bindings (multi-viewport flicker). - Hold per-slot vertex/index buffers until their frame completes, so a second viewport rendered later in the same frame can't overwrite a buffer still in flight (blink). - Make hand-built render-pass drawables resident, and evict stale (resized-away) drawables from the residency set so it can't grow without bound during a live resize. - Present secondary viewports in-transaction on the frame they were resized so content stays glued to the new size (no stutter). Example (example_apple_metal4/main.mm): - Replace MTKView with a custom NSView/UIView + CAMetalLayer driven by a display link. - Proper frames-in-flight: per-slot command allocators, rotating slots, and a semaphore gate released on GPU completion via an MTLSharedEvent listener. - Glitch-free main-window resize: present in-transaction during a live resize, driven by AppKit (the display link steps aside) so the transaction commits and drawables aren't starved; async present otherwise (avoids a ~1s nextDrawable stall / freeze). - Idle CPU throttling: only redraw on input/activity plus a slow heartbeat, so the main thread sleeps when nothing changes. --- backends/imgui_impl_metal4.mm | 122 ++++++-- examples/example_apple_metal4/main.mm | 401 ++++++++++++++++++++++---- 2 files changed, 450 insertions(+), 73 deletions(-) diff --git a/backends/imgui_impl_metal4.mm b/backends/imgui_impl_metal4.mm index 301d5ddd9..2a401a64c 100644 --- a/backends/imgui_impl_metal4.mm +++ b/backends/imgui_impl_metal4.mm @@ -81,19 +81,26 @@ struct ImGui_Metal4_ConstantData @property (nonatomic, strong) id device; @property (nonatomic, strong) id commandQueue; @property (nonatomic, strong) id depthStencilState; -@property (nonatomic, strong) id argumentTable; +// One argument table per frame-in-flight slot, shared by every window (main + secondary viewports) drawn +// in that frame. Metal snapshots an argument table's bindings when each draw is *encoded* (see +// -[MTL4RenderCommandEncoder setArgumentTable:atStages:]), so rebinding it for the next window doesn't +// disturb draws already encoded for the previous one. Per-slot (rather than one global table) keeps reuse +// safe across frames-in-flight. +@property (nonatomic, strong) NSMutableArray>* argumentTables; // indexed [frameSlot] +@property (nonatomic, strong) id currentArgumentTable; // table for the RenderDrawData call in progress @property (nonatomic, strong) id samplerStateLinear; @property (nonatomic, strong) id samplerStateNearest; @property (nonatomic, strong) id residencySet; +@property (nonatomic, strong) NSMapTable, NSNumber*>* residentDrawables; // drawable render target -> frame index it was last used; lets us evict stale (resized-away) drawables from residencySet instead of leaking them +@property (nonatomic, assign) uint64_t residencyFrameCounter; @property (nonatomic, strong) FramebufferDescriptor* framebufferDescriptor; @property (nonatomic, strong) NSMutableDictionary* renderPipelineStateCache; @property (nonatomic, assign) NSUInteger framesInFlight; @property (nonatomic, assign) NSUInteger currentFrameSlot; @property (nonatomic, strong) NSMutableArray*>* bufferCaches; +@property (nonatomic, strong) NSMutableArray*>* buffersInUse; // per-slot: buffers handed out this frame, held until the slot's GPU work completes (next NewFrame for that slot) so a second viewport in the same frame can't reuse (and overwrite) an in-flight buffer @property (nonatomic, strong) NSObject* bufferCacheLock; @property (nonatomic, assign) double lastBufferCachePurge; -@property (nonatomic, strong) NSArray>* events; // for tracking when a commands are complete to reset allocator -@property (nonatomic) uint64_t eventValue; @property (nonatomic, strong) NSArray>* commandAllocators; @property (nonatomic, strong) NSMutableArray>*>* constantBuffers; @property (nonatomic) uint64_t constantBufferChunkCount; @@ -147,6 +154,19 @@ bool ImGui_ImplMetal_CreateDeviceObjects(MTL::Device* device) #pragma mark - Dear ImGui Metal Backend API +// Add a per-frame drawable render target to the residency set (Metal 4 requires it, since the render +// pass is now built by the app rather than MTKView), tracking the frame it was last used so stale +// drawables — e.g. the fresh-sized textures a CAMetalLayer produces every frame during a live resize — +// can be evicted instead of accumulating in the set forever. +static void ImGui_ImplMetal4_TrackResidentDrawable(MetalContext* ctx, id texture) +{ + if (texture == nil) + return; + if ([ctx.residentDrawables objectForKey:texture] == nil) + [ctx.residencySet addAllocation:texture]; + [ctx.residentDrawables setObject:@(ctx.residencyFrameCounter) forKey:texture]; +} + void ImGui_ImplMetal4_NewFrame(MTL4RenderPassDescriptor* renderPassDescriptor, int frameInFlightIndex) { ImGui_ImplMetal4_Data* bd = ImGui_ImplMetal4_GetBackendData(); @@ -160,10 +180,41 @@ void ImGui_ImplMetal4_NewFrame(MTL4RenderPassDescriptor* renderPassDescriptor, i bd->SharedMetalContext.currentFrameSlot = (NSUInteger)frameInFlightIndex; if (bd->SharedMetalContext.depthStencilState == nil) ImGui_ImplMetal4_CreateDeviceObjects(bd->SharedMetalContext.device); - + + // The render pass is now built by the application (no MTKView), so the render-target texture is not + // automatically resident and must be added to the residency set. Before adding this frame's drawable, + // evict any tracked drawable not used for >= framesInFlight+2 frames: the frames-in-flight gate + // guarantees its last frame has completed on the GPU and it won't be handed out again (during a live + // resize the layer creates a new texture every frame), so this stops the residency set from growing + // without bound. Pooled same-size drawables keep getting reused, so they are never evicted. + MetalContext* ctx = bd->SharedMetalContext; + ctx.residencyFrameCounter++; + NSMutableArray>* staleDrawables = nil; + for (id texture in ctx.residentDrawables) + if (ctx.residencyFrameCounter - [[ctx.residentDrawables objectForKey:texture] unsignedLongLongValue] >= ctx.framesInFlight + 2) + { + if (staleDrawables == nil) + staleDrawables = [NSMutableArray array]; + [staleDrawables addObject:texture]; + } + for (id texture in staleDrawables) + { + [ctx.residencySet removeAllocation:texture]; + [ctx.residentDrawables removeObjectForKey:texture]; + } + ImGui_ImplMetal4_TrackResidentDrawable(ctx, renderPassDescriptor.colorAttachments[0].texture); + bd->SharedMetalContext.currentConstantBufferIndex = 0; - [bd->SharedMetalContext.events[frameInFlightIndex] waitUntilSignaledValue:bd->SharedMetalContext.eventValue timeoutMS:UINT64_MAX]; [bd->SharedMetalContext.commandAllocators[frameInFlightIndex] reset]; + + // This slot's previous frame has completed on the GPU (the app gates on frames-in-flight before + // reusing a slot), so its held buffers are now free to reuse: release them back to the pool. + @synchronized(bd->SharedMetalContext.bufferCacheLock) + { + NSMutableArray* slotInUse = bd->SharedMetalContext.buffersInUse[frameInFlightIndex]; + [bd->SharedMetalContext.bufferCaches[frameInFlightIndex] addObjectsFromArray:slotInUse]; + [slotInUse removeAllObjects]; + } } static void ImGui_ImplMetal4_SetupRenderState(ImDrawData* draw_data, id commandBuffer, @@ -212,7 +263,12 @@ static void ImGui_ImplMetal4_SetupRenderState(ImDrawData* draw_data, idModelViewProjectionMatrix[currentIndex], ortho_projection, sizeof(ortho_projection)); - id argumentTable = bd->SharedMetalContext.argumentTable; + // One argument table per frame-in-flight slot, shared by every window in the frame. Metal snapshots + // an argument table's bindings when each draw is *encoded* (see -[MTL4RenderCommandEncoder + // setArgumentTable:atStages:]), so rebinding it for the next window doesn't disturb draws already + // encoded for the previous one. Per-slot (not a single global table) keeps it safe across frames-in-flight. + id argumentTable = bd->SharedMetalContext.argumentTables[currentFrameIndex]; + bd->SharedMetalContext.currentArgumentTable = argumentTable; [argumentTable setAddress:constantBuffer.gpuAddress+(uint64_t)currentIndex * sizeof(constantBufferContents->ModelViewProjectionMatrix[0]) atIndex:1]; [argumentTable setAddress:(vertexBuffer.buffer.gpuAddress + vertexBufferOffset) attributeStride:sizeof(ImDrawVert) atIndex:0]; [argumentTable setSamplerState:bd->SharedMetalContext.samplerStateLinear.gpuResourceID atIndex:0]; @@ -221,8 +277,8 @@ static void ImGui_ImplMetal4_SetupRenderState(ImDrawData* draw_data, idSharedMetalContext.argumentTable setSamplerState:bd->SharedMetalContext.samplerStateLinear.gpuResourceID atIndex:0]; } -static void ImGui_ImplMetal4_DrawCallback_SetSamplerNearest(const ImDrawList*, const ImDrawCmd*) { ImGui_ImplMetal4_Data* bd = ImGui_ImplMetal4_GetBackendData(); [bd->SharedMetalContext.argumentTable setSamplerState:bd->SharedMetalContext.samplerStateNearest.gpuResourceID atIndex:0]; } +static void ImGui_ImplMetal4_DrawCallback_SetSamplerLinear(const ImDrawList*, const ImDrawCmd*) { ImGui_ImplMetal4_Data* bd = ImGui_ImplMetal4_GetBackendData(); [bd->SharedMetalContext.currentArgumentTable setSamplerState:bd->SharedMetalContext.samplerStateLinear.gpuResourceID atIndex:0]; } +static void ImGui_ImplMetal4_DrawCallback_SetSamplerNearest(const ImDrawList*, const ImDrawCmd*) { ImGui_ImplMetal4_Data* bd = ImGui_ImplMetal4_GetBackendData(); [bd->SharedMetalContext.currentArgumentTable setSamplerState:bd->SharedMetalContext.samplerStateNearest.gpuResourceID atIndex:0]; } void ImGui_ImplMetal4_RenderDrawData(ImDrawData* draw_data, id commandBuffer, id commandEncoder) { @@ -332,10 +388,10 @@ void ImGui_ImplMetal4_RenderDrawData(ImDrawData* draw_data, id texture = (__bridge id)(void*)(intptr_t)tex_id; [bd->SharedMetalContext.residencySet addAllocation:texture]; - [bd->SharedMetalContext.argumentTable setTexture:texture.gpuResourceID atIndex:0]; + [bd->SharedMetalContext.currentArgumentTable setTexture:texture.gpuResourceID atIndex:0]; } - [bd->SharedMetalContext.argumentTable setAddress:(vertexBuffer.buffer.gpuAddress + vertexBufferOffset + (pcmd->VtxOffset * sizeof(ImDrawVert))) attributeStride:sizeof(ImDrawVert) atIndex:0]; + [bd->SharedMetalContext.currentArgumentTable setAddress:(vertexBuffer.buffer.gpuAddress + vertexBufferOffset + (pcmd->VtxOffset * sizeof(ImDrawVert))) attributeStride:sizeof(ImDrawVert) atIndex:0]; size_t indexBufferCmdOffset = indexBufferOffset + (pcmd->IdxOffset * sizeof(ImDrawIdx)); [commandEncoder drawIndexedPrimitives:MTLPrimitiveTypeTriangle @@ -353,9 +409,12 @@ void ImGui_ImplMetal4_RenderDrawData(ImDrawData* draw_data, idSharedMetalContext; @synchronized(sharedMetalContext.bufferCacheLock) { - NSMutableArray* slotCache = sharedMetalContext.bufferCaches[sharedMetalContext.currentFrameSlot]; - [slotCache addObject:vertexBuffer]; - [slotCache addObject:indexBuffer]; + // Hold these until this slot's GPU work completes (released back to the available pool at the + // next NewFrame for this slot). Returning them to the available pool now would let a second + // viewport rendered later in THIS frame dequeue and overwrite them while still in flight. + NSMutableArray* slotInUse = sharedMetalContext.buffersInUse[sharedMetalContext.currentFrameSlot]; + [slotInUse addObject:vertexBuffer]; + [slotInUse addObject:indexBuffer]; } // Commit residency set @@ -440,6 +499,8 @@ bool ImGui_ImplMetal4_CreateDeviceObjects(id device) IM_ASSERT(bd->SharedMetalContext.residencySet != nil && error == nil); [bd->SharedMetalContext.commandQueue addResidencySet:bd->SharedMetalContext.residencySet]; + bd->SharedMetalContext.residentDrawables = [NSMapTable strongToStrongObjectsMapTable]; + bd->SharedMetalContext.residencyFrameCounter = 0; MTLDepthStencilDescriptor* depthStencilDescriptor = [[MTLDepthStencilDescriptor alloc] init]; depthStencilDescriptor.depthWriteEnabled = NO; @@ -457,11 +518,9 @@ bool ImGui_ImplMetal4_CreateDeviceObjects(id device) bd->SharedMetalContext.samplerStateNearest = [device newSamplerStateWithDescriptor:samplerDescriptor]; NSMutableArray>* commandAllocators = [NSMutableArray array]; - NSMutableArray>* events = [NSMutableArray array]; bd->SharedMetalContext.constantBuffers = [NSMutableArray array]; for (NSUInteger i = 0; i < bd->SharedMetalContext.framesInFlight; i++) { - events[i] = [device newSharedEvent]; commandAllocators[i] = [device newCommandAllocator]; bd->SharedMetalContext.constantBuffers[i] = [NSMutableArray array]; id buffer = [device newBufferWithLength:sizeof(ImGui_Metal4_ConstantData) options:MTLResourceStorageModeShared]; @@ -469,7 +528,6 @@ bool ImGui_ImplMetal4_CreateDeviceObjects(id device) [bd->SharedMetalContext.residencySet addAllocation:buffer]; } bd->SharedMetalContext.constantBufferChunkCount = 1; - bd->SharedMetalContext.events = events; MTL4ArgumentTableDescriptor* argumentTableDescriptor = [[MTL4ArgumentTableDescriptor alloc] init]; argumentTableDescriptor.maxBufferBindCount = 2; // vertex buffer + constant buffer @@ -479,8 +537,16 @@ bool ImGui_ImplMetal4_CreateDeviceObjects(id device) bd->SharedMetalContext.commandAllocators = commandAllocators; - bd->SharedMetalContext.argumentTable = [device newArgumentTableWithDescriptor:argumentTableDescriptor error:&error]; - IM_ASSERT(bd->SharedMetalContext.argumentTable != nil && error == nil); + // One argument table per frame-in-flight slot, reused by every window in that slot's frame (bindings + // are snapshotted per draw at encode time). Pre-create them so no argument table is created mid-frame. + NSMutableArray>* argumentTables = [NSMutableArray array]; + for (NSUInteger i = 0; i < bd->SharedMetalContext.framesInFlight; i++) + { + id argumentTable = [device newArgumentTableWithDescriptor:argumentTableDescriptor error:&error]; + IM_ASSERT(argumentTable != nil && error == nil); + [argumentTables addObject:argumentTable]; + } + bd->SharedMetalContext.argumentTables = argumentTables; ImGui_ImplMetal_CreateDeviceObjectsForPlatformWindows(); return true; @@ -526,9 +592,14 @@ bool ImGui_ImplMetal4_Init(id device, id commandQue bd->SharedMetalContext.commandQueue = commandQueue; bd->SharedMetalContext.framesInFlight = (NSUInteger)framesInFlight; NSMutableArray*>* bufferCaches = [NSMutableArray array]; + NSMutableArray*>* buffersInUse = [NSMutableArray array]; for (NSUInteger i = 0; i < framesInFlight; i++) + { [bufferCaches addObject:[NSMutableArray array]]; + [buffersInUse addObject:[NSMutableArray array]]; + } bd->SharedMetalContext.bufferCaches = bufferCaches; + bd->SharedMetalContext.buffersInUse = buffersInUse; ImGui_ImplMetal_InitMultiViewportSupport(); return true; @@ -803,6 +874,7 @@ struct ImGuiViewportDataMetal MTL4RenderPassDescriptor* RenderPassDescriptor; void* Handle = nullptr; bool FirstFrame = true; + bool PendingResizePresent = false; }; static void ImGui_ImplMetal_CreateWindow(ImGuiViewport* viewport) @@ -850,6 +922,9 @@ static void ImGui_ImplMetal_SetWindowSize(ImGuiViewport* viewport, ImVec2 size) { ImGuiViewportDataMetal* data = (ImGuiViewportDataMetal*)viewport->RendererUserData; data->MetalLayer.drawableSize = MakeScaledSize(CGSizeMake(size.x, size.y), data->MetalLayer.contentsScale); + // The window frame was just resized; ask the next RenderWindow to present in-transaction so the + // drawable lands together with the new frame (no stale/stretched content while dragging). + data->PendingResizePresent = true; } static void ImGui_ImplMetal_RenderWindow(ImGuiViewport* viewport, void*) @@ -875,6 +950,13 @@ static void ImGui_ImplMetal_RenderWindow(ImGuiViewport* viewport, void*) CGSize want_size = MakeScaledSize(window.contentView.bounds.size, fb_scale); if (!CGSizeEqualToSize(data->MetalLayer.drawableSize, want_size)) data->MetalLayer.drawableSize = want_size; + // On frames where the window was just resized (flagged by ImGui_ImplMetal_SetWindowSize), present + // in-transaction so content stays glued to the new size; otherwise present asynchronously to avoid a + // per-frame stall. Unlike the main window this is safe here because secondary viewports are rendered + // inside the main frame's transaction, which commits (releasing the drawable) each frame. + bool resizing = data->PendingResizePresent; + data->PendingResizePresent = false; + data->MetalLayer.presentsWithTransaction = resizing; #endif id drawable = [data->MetalLayer nextDrawable]; @@ -888,6 +970,9 @@ static void ImGui_ImplMetal_RenderWindow(ImGuiViewport* viewport, void*) renderPassDescriptor.colorAttachments[0].loadAction = MTLLoadActionClear; ImGui_ImplMetal4_Data* bd = ImGui_ImplMetal4_GetBackendData(); + // The secondary-viewport render pass is hand-built here too, so make its render-target texture + // resident (tracked so it can be evicted once stale) before RenderDrawData commits the residency set. + ImGui_ImplMetal4_TrackResidentDrawable(bd->SharedMetalContext, drawable.texture); id commandBuffer = [bd->SharedMetalContext.device newCommandBuffer]; [commandBuffer beginCommandBufferWithAllocator:bd->SharedMetalContext.commandAllocators[bd->SharedMetalContext.currentFrameSlot]]; @@ -900,7 +985,6 @@ static void ImGui_ImplMetal_RenderWindow(ImGuiViewport* viewport, void*) [bd->SharedMetalContext.commandQueue commit:&commandBuffer count:1]; [bd->SharedMetalContext.commandQueue signalDrawable:drawable]; [drawable present]; - [bd->SharedMetalContext.commandQueue signalEvent:bd->SharedMetalContext.events[bd->SharedMetalContext.currentFrameSlot] value:++(bd->SharedMetalContext.eventValue)]; } static void ImGui_ImplMetal_InitMultiViewportSupport() diff --git a/examples/example_apple_metal4/main.mm b/examples/example_apple_metal4/main.mm index 2bfb415ed..603b04b04 100644 --- a/examples/example_apple_metal4/main.mm +++ b/examples/example_apple_metal4/main.mm @@ -1,4 +1,6 @@ -// Dear ImGui: standalone example application for OSX + Metal. +// Dear ImGui: standalone example application for OSX + Metal 4. +// Uses CAMetalLayer directly (instead of MTKView) for glitch-free resizing. +// Based on Tristan Hume's MetalLayerView pattern with presentsWithTransaction. // Learn about Dear ImGui: // - FAQ https://dearimgui.com/faq @@ -10,17 +12,171 @@ #if TARGET_OS_OSX #import +#import #else #import #endif #import -#import +#import #include "imgui.h" #include "imgui_impl_metal4.h" #if TARGET_OS_OSX #include "imgui_impl_osx.h" +#endif + +//----------------------------------------------------------------------------------- +// MetalLayerView — custom view backed by CAMetalLayer +//----------------------------------------------------------------------------------- + +// Render callback type: the view calls this to draw a frame +typedef void (^RenderCallback)(CAMetalLayer* layer, CGSize viewSize, CGFloat scaleFactor); + +// Idle CPU throttling: after the last user input (or while a widget is active) keep rendering at the full +// display rate for this many frames, then fall back to a slow heartbeat so the main thread can idle. +static const NSInteger kActiveRenderFrames = 30; + +#if TARGET_OS_OSX + +@interface MetalLayerView : NSView +@property (nonatomic, strong) CAMetalLayer* metalLayer; +@property (nonatomic, copy) RenderCallback renderCallback; +@property (atomic, assign) NSInteger activeFrames; // >0 => keep driving redraws; read on the display-link thread +@end + +@implementation MetalLayerView + +-(instancetype)initWithFrame:(NSRect)frame device:(id)device +{ + self = [super initWithFrame:frame]; + if (self) + { + self.wantsLayer = YES; + self.layerContentsRedrawPolicy = NSViewLayerContentsRedrawDuringViewResize; + self.layerContentsPlacement = NSViewLayerContentsPlacementScaleAxesIndependently; + + _metalLayer = (CAMetalLayer*)self.layer; + _metalLayer.device = device; + _metalLayer.pixelFormat = MTLPixelFormatBGRA8Unorm; + _metalLayer.delegate = self; + _metalLayer.allowsNextDrawableTimeout = NO; + _metalLayer.autoresizingMask = kCALayerHeightSizable | kCALayerWidthSizable; + _metalLayer.needsDisplayOnBoundsChange = YES; + // Default to async presentation. presentsWithTransaction is turned on only during a live resize + // (in renderWithLayer:) so content stays glued to the window frame; see the note there and in + // displayLinkCallback for why it must stay off otherwise. + _metalLayer.presentsWithTransaction = NO; + _activeFrames = kActiveRenderFrames; // render at startup until things settle + } + return self; +} + +-(CALayer*)makeBackingLayer +{ + CAMetalLayer* layer = [CAMetalLayer layer]; + return layer; +} + +-(BOOL)wantsUpdateLayer { return YES; } + +-(void)setFrameSize:(NSSize)newSize +{ + [super setFrameSize:newSize]; + _metalLayer.drawableSize = [self convertSizeToBacking:newSize]; +} + +-(void)viewDidChangeBackingProperties +{ + [super viewDidChangeBackingProperties]; + if (self.window) + { + _metalLayer.contentsScale = self.window.backingScaleFactor; + // Moving to a display with a different backing scale changes the pixel size without changing the + // point size, so setFrameSize: is not called — update the drawable resolution here too. + _metalLayer.drawableSize = [self convertSizeToBacking:self.bounds.size]; + } +} + +-(void)displayLayer:(CALayer*)layer +{ + if (_renderCallback) + { + CGFloat scale = self.window.backingScaleFactor ?: NSScreen.mainScreen.backingScaleFactor; + _renderCallback(_metalLayer, self.bounds.size, scale); + } +} + +@end + +#else // iOS + +@interface MetalLayerView : UIView +@property (nonatomic, strong) CAMetalLayer* metalLayer; +@property (nonatomic, copy) RenderCallback renderCallback; +@property (nonatomic, strong) CADisplayLink* displayLink; +@end + +@implementation MetalLayerView + ++(Class)layerClass +{ + return [CAMetalLayer class]; +} + +-(instancetype)initWithFrame:(CGRect)frame device:(id)device +{ + self = [super initWithFrame:frame]; + if (self) + { + _metalLayer = (CAMetalLayer*)self.layer; + _metalLayer.device = device; + _metalLayer.pixelFormat = MTLPixelFormatBGRA8Unorm; + _metalLayer.framebufferOnly = YES; + self.contentScaleFactor = UIScreen.mainScreen.scale; + _metalLayer.drawableSize = CGSizeMake(frame.size.width * self.contentScaleFactor, + frame.size.height * self.contentScaleFactor); + } + return self; +} + +-(void)layoutSubviews +{ + [super layoutSubviews]; + _metalLayer.drawableSize = CGSizeMake(self.bounds.size.width * self.contentScaleFactor, + self.bounds.size.height * self.contentScaleFactor); +} + +-(void)startDisplayLink +{ + _displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(displayLinkFired:)]; + [_displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes]; +} + +-(void)stopDisplayLink +{ + [_displayLink invalidate]; + _displayLink = nil; +} + +-(void)displayLinkFired:(CADisplayLink*)link +{ + if (_renderCallback) + { + CGFloat scale = self.contentScaleFactor; + _renderCallback(_metalLayer, self.bounds.size, scale); + } +} + +@end + +#endif + +//----------------------------------------------------------------------------------- +// AppViewController +//----------------------------------------------------------------------------------- + +#if TARGET_OS_OSX @interface AppViewController : NSViewController @end #else @@ -28,19 +184,24 @@ @end #endif -@interface AppViewController () -@property (nonatomic, readonly) MTKView *mtkView; -@property (nonatomic, strong) id device; -@property (nonatomic, strong) id commandQueue; -@property (nonatomic, strong) id commandAllocator; +@interface AppViewController () +@property (nonatomic, strong) MetalLayerView* metalView; +@property (nonatomic, strong) id device; +@property (nonatomic, strong) id commandQueue; +@property (nonatomic, strong) NSArray>* commandAllocators; +@property (nonatomic, strong) id frameEvent; +@property (nonatomic, assign) uint64_t frameValue; +@property (nonatomic, strong) dispatch_semaphore_t inFlightSemaphore; +@property (nonatomic, strong) MTLSharedEventListener* frameListener; +@property (nonatomic, assign) uint64_t frameCount; +#if TARGET_OS_OSX +@property (nonatomic, assign) CVDisplayLinkRef displayLink; +@property (nonatomic, strong) id eventMonitor; // local NSEvent monitor that keeps rendering active on input +#endif @end #define FRAMES_IN_FLIGHT 2 -//----------------------------------------------------------------------------------- -// AppViewController -//----------------------------------------------------------------------------------- - @implementation AppViewController -(instancetype)initWithNibName:(nullable NSString *)nibNameOrNil bundle:(nullable NSBundle *)nibBundleOrNil @@ -49,7 +210,24 @@ _device = MTLCreateSystemDefaultDevice(); _commandQueue = [_device newMTL4CommandQueue]; - _commandAllocator = [_device newCommandAllocator]; + + // One command allocator per frame-in-flight slot so a slot's allocator is only reset/reused once + // its previous frame's GPU work has completed (gated by the semaphore below). + NSMutableArray>* commandAllocators = [NSMutableArray array]; + for (NSUInteger i = 0; i < FRAMES_IN_FLIGHT; i++) + [commandAllocators addObject:[_device newCommandAllocator]]; + _commandAllocators = commandAllocators; + + _frameEvent = [_device newSharedEvent]; + _frameValue = 0; + _frameCount = 0; + + // Frames-in-flight gate: never let the CPU get more than FRAMES_IN_FLIGHT frames ahead of the GPU, + // so per-slot resources (command allocators, constant buffers, argument tables) are safely double + // buffered. The listener signals the semaphore from a serial queue when a frame's GPU work finishes. + dispatch_queue_t listenerQueue = dispatch_queue_create("imgui.metal4.frameListener", DISPATCH_QUEUE_SERIAL); + _frameListener = [[MTLSharedEventListener alloc] initWithDispatchQueue:listenerQueue]; + _inFlightSemaphore = dispatch_semaphore_create(FRAMES_IN_FLIGHT); if (!self.device) { @@ -103,62 +281,128 @@ return self; } --(MTKView *)mtkView -{ - return (MTKView *)self.view; -} - -(void)loadView { - self.view = [[MTKView alloc] initWithFrame:CGRectMake(0, 0, 1200, 800)]; +#if TARGET_OS_OSX + self.metalView = [[MetalLayerView alloc] initWithFrame:CGRectMake(0, 0, 1200, 800) device:self.device]; +#else + self.metalView = [[MetalLayerView alloc] initWithFrame:UIScreen.mainScreen.bounds device:self.device]; +#endif + self.view = self.metalView; } -(void)viewDidLoad { [super viewDidLoad]; - self.mtkView.device = self.device; - self.mtkView.delegate = self; - #if TARGET_OS_OSX ImGui_ImplOSX_Init(self.view); [NSApp activateIgnoringOtherApps:YES]; #endif -} --(void)drawInMTKView:(MTKView*)view -{ - ImGuiIO& io = ImGui::GetIO(); - io.DisplaySize.x = view.bounds.size.width; - io.DisplaySize.y = view.bounds.size.height; + // Setup render callback — captures self weakly to avoid retain cycle + __weak AppViewController* weakSelf = self; + self.metalView.renderCallback = ^(CAMetalLayer* layer, CGSize viewSize, CGFloat scaleFactor) { + [weakSelf renderWithLayer:layer viewSize:viewSize scaleFactor:scaleFactor]; + }; #if TARGET_OS_OSX - CGFloat framebufferScale = view.window.screen.backingScaleFactor ?: NSScreen.mainScreen.backingScaleFactor; + // Keep rendering at the full rate for a short grace period after any user input, so hover/typing/drag + // stay responsive; the display-link callback throttles once this decays. (See kActiveRenderFrames.) + NSEventMask inputMask = NSEventMaskLeftMouseDown | NSEventMaskLeftMouseUp | NSEventMaskRightMouseDown | + NSEventMaskRightMouseUp | NSEventMaskOtherMouseDown | NSEventMaskOtherMouseUp | + NSEventMaskMouseMoved | NSEventMaskLeftMouseDragged | NSEventMaskRightMouseDragged | + NSEventMaskOtherMouseDragged | NSEventMaskScrollWheel | NSEventMaskKeyDown | + NSEventMaskKeyUp | NSEventMaskFlagsChanged; + __weak MetalLayerView* weakView = self.metalView; + self.eventMonitor = [NSEvent addLocalMonitorForEventsMatchingMask:inputMask handler:^NSEvent* (NSEvent* event) { + weakView.activeFrames = kActiveRenderFrames; + return event; + }]; + + // Start CVDisplayLink for continuous rendering + CVDisplayLinkCreateWithActiveCGDisplays(&_displayLink); + CVDisplayLinkSetOutputCallback(_displayLink, &displayLinkCallback, (__bridge void*)self.metalView); + CVDisplayLinkStart(_displayLink); #else - CGFloat framebufferScale = view.window.screen.scale ?: UIScreen.mainScreen.scale; + [self.metalView startDisplayLink]; #endif - io.DisplayFramebufferScale = ImVec2(framebufferScale, framebufferScale); - [self.commandAllocator reset]; +} - id commandBuffer = [self.device newCommandBuffer]; - [commandBuffer beginCommandBufferWithAllocator:self.commandAllocator]; +#if TARGET_OS_OSX +static CVReturn displayLinkCallback(CVDisplayLinkRef displayLink, + const CVTimeStamp* now, + const CVTimeStamp* outputTime, + CVOptionFlags flagsIn, + CVOptionFlags* flagsOut, + void* context) +{ + MetalLayerView* view = (__bridge MetalLayerView*)context; + // Idle throttle: only drive a redraw when the UI was recently active (view.activeFrames, bumped by + // input and by active widgets), or on a slow heartbeat so time-based animations still advance and we + // periodically re-check for activity. When idle we don't even wake the main thread. (activeFrames is + // an atomic property; this runs on the display-link thread.) + static uint64_t tick = 0; + tick++; + if (view.activeFrames <= 0 && (tick % 60) != 0) + return kCVReturnSuccess; - MTL4RenderPassDescriptor* renderPassDescriptor = view.currentMTL4RenderPassDescriptor; - if (renderPassDescriptor == nil) + dispatch_async(dispatch_get_main_queue(), ^{ + // While the OS is live-resizing the window, AppKit already redraws the layer each step inside its + // resize CATransaction (which commits, releasing the in-transaction drawable). Driving an extra + // render from the display link here would present in-transaction WITHOUT a commit and starve the + // drawable pool, so skip it during resize and let AppKit be the sole driver. + if (!view.inLiveResize) + [view setNeedsDisplay:YES]; + }); + return kCVReturnSuccess; +} +#endif + +-(void)renderWithLayer:(CAMetalLayer*)layer viewSize:(CGSize)viewSize scaleFactor:(CGFloat)scaleFactor +{ + // Frames-in-flight gate: block until a slot is free (its previous frame's GPU work has completed). + // Once past this, the slot's per-frame resources are guaranteed no longer read by the GPU. + dispatch_semaphore_wait(self.inFlightSemaphore, DISPATCH_TIME_FOREVER); + NSUInteger slot = self.frameCount % FRAMES_IN_FLIGHT; + self.frameCount++; + + ImGuiIO& io = ImGui::GetIO(); + io.DisplaySize.x = viewSize.width; + io.DisplaySize.y = viewSize.height; + io.DisplayFramebufferScale = ImVec2(scaleFactor, scaleFactor); + +#if TARGET_OS_OSX + // While the OS is live-resizing the window, present in-transaction so the content stays glued to the + // window frame (glitch-free resize). This is safe because during a live resize AppKit drives the + // redraw inside its resize transaction — which commits and releases the drawable — while the display + // link stands down (see displayLinkCallback). Async otherwise, to avoid a per-frame stall. + layer.presentsWithTransaction = self.metalView.inLiveResize; +#endif + + id drawable = [layer nextDrawable]; + if (!drawable) { - [commandBuffer endCommandBuffer]; - [self.commandQueue commit:&commandBuffer count:1]; + // Release the gate we just took, otherwise the semaphore count leaks and we deadlock. + dispatch_semaphore_signal(self.inFlightSemaphore); return; } + [self.commandAllocators[slot] reset]; + id commandBuffer = [self.device newCommandBuffer]; + [commandBuffer beginCommandBufferWithAllocator:self.commandAllocators[slot]]; + + // Build the Metal 4 render pass descriptor by hand (MTKView's currentMTL4RenderPassDescriptor is gone). + MTL4RenderPassDescriptor* renderPassDescriptor = [[MTL4RenderPassDescriptor alloc] init]; + renderPassDescriptor.colorAttachments[0].texture = drawable.texture; + renderPassDescriptor.colorAttachments[0].loadAction = MTLLoadActionClear; + renderPassDescriptor.colorAttachments[0].storeAction = MTLStoreActionStore; + // Start the Dear ImGui frame - static int frameIndex = 0; - ImGui_ImplMetal4_NewFrame(renderPassDescriptor, frameIndex); - frameIndex++; - frameIndex = (frameIndex + 1) % FRAMES_IN_FLIGHT; - + ImGui_ImplMetal4_NewFrame(renderPassDescriptor, (int)slot); + #if TARGET_OS_OSX - ImGui_ImplOSX_NewFrame(view); + ImGui_ImplOSX_NewFrame(self.view); #endif ImGui::NewFrame(); @@ -208,19 +452,30 @@ ImGui::Render(); ImDrawData* draw_data = ImGui::GetDrawData(); +#if TARGET_OS_OSX + // Idle throttle bookkeeping: keep rendering while the UI is doing something (text-cursor blink, an + // active/dragged widget, a held mouse button, or a live resize); otherwise let the counter decay so + // the display-link callback can drop to the idle heartbeat. Mouse movement is handled by the input + // monitor in viewDidLoad. + if (io.WantTextInput || ImGui::IsAnyItemActive() || [NSEvent pressedMouseButtons] != 0 || self.metalView.inLiveResize) + self.metalView.activeFrames = kActiveRenderFrames; + else if (self.metalView.activeFrames > 0) + self.metalView.activeFrames = self.metalView.activeFrames - 1; +#endif + renderPassDescriptor.colorAttachments[0].clearColor = MTLClearColorMake(clear_color.x * clear_color.w, clear_color.y * clear_color.w, clear_color.z * clear_color.w, clear_color.w); id renderEncoder = [commandBuffer renderCommandEncoderWithDescriptor:renderPassDescriptor]; [renderEncoder pushDebugGroup:@"Dear ImGui rendering"]; ImGui_ImplMetal4_RenderDrawData(draw_data, commandBuffer, renderEncoder); [renderEncoder popDebugGroup]; [renderEncoder endEncoding]; - - // Present [commandBuffer endCommandBuffer]; - [self.commandQueue waitForDrawable:view.currentDrawable]; + + // Present (canonical Metal 4 sequence, as the original MTKView example used). + [self.commandQueue waitForDrawable:drawable]; [self.commandQueue commit:&commandBuffer count:1]; - [self.commandQueue signalDrawable:view.currentDrawable]; - [view.currentDrawable present]; + [self.commandQueue signalDrawable:drawable]; + [drawable present]; // Update and Render additional Platform Windows if (io.ConfigFlags & ImGuiConfigFlags_ViewportsEnable) @@ -228,10 +483,46 @@ ImGui::UpdatePlatformWindows(); ImGui::RenderPlatformWindowsDefault(); } + + // Release the frames-in-flight gate once all of this frame's GPU work (main + secondary viewports) + // completes: the listener signals the semaphore when the shared event reaches this frame's value. + // Capture the semaphore itself — not self — so the completion block does not create a retain cycle. + uint64_t v = ++self.frameValue; + [self.commandQueue signalEvent:self.frameEvent value:v]; + dispatch_semaphore_t sema = self.inFlightSemaphore; + [self.frameEvent notifyListener:self.frameListener atValue:v block:^(id, uint64_t){ + dispatch_semaphore_signal(sema); + }]; } --(void)mtkView:(MTKView*)view drawableSizeWillChange:(CGSize)size +-(void)shutdown { +#if TARGET_OS_OSX + if (_eventMonitor) + { + [NSEvent removeMonitor:_eventMonitor]; + _eventMonitor = nil; + } + if (_displayLink) + { + CVDisplayLinkStop(_displayLink); + CVDisplayLinkRelease(_displayLink); + _displayLink = NULL; + } +#else + [self.metalView stopDisplayLink]; +#endif + + ImGui_ImplMetal4_Shutdown(); +#if TARGET_OS_OSX + ImGui_ImplOSX_Shutdown(); +#endif + ImGui::DestroyContext(); +} + +-(void)dealloc +{ + [self shutdown]; } //----------------------------------------------------------------------------------- @@ -240,17 +531,19 @@ #if TARGET_OS_OSX -- (void)viewWillAppear +-(void)viewWillAppear { [super viewWillAppear]; self.view.window.delegate = self; + // Deliver mouse-moved events so the input monitor can keep rendering active while hovering. + self.view.window.acceptsMouseMovedEvents = YES; } -- (void)windowWillClose:(NSNotification *)notification +-(void)windowWillClose:(NSNotification *)notification { - ImGui_ImplMetal4_Shutdown(); - ImGui_ImplOSX_Shutdown(); - ImGui::DestroyContext(); + // Closing the main window terminates the app (matching the GLFW example), so any torn-off + // viewport windows are torn down with it instead of being left behind as orphan windows. + [NSApp terminate:nil]; } #else