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/backends/imgui_impl_osx.mm b/backends/imgui_impl_osx.mm index 4f457f722..64e6e64c3 100644 --- a/backends/imgui_impl_osx.mm +++ b/backends/imgui_impl_osx.mm @@ -267,8 +267,11 @@ static bool ImGui_ImplOSX_HandleEvent(NSEvent* event, NSView* view); @end +static void ImGui_ImplOSX_RestackSecondaryWindows(bool active); // keep torn-off windows above the main window (see definition) + @interface ImGuiObserver : NSObject +- (void)onApplicationWillBecomeActive:(NSNotification*)aNotification; - (void)onApplicationBecomeActive:(NSNotification*)aNotification; - (void)onApplicationBecomeInactive:(NSNotification*)aNotification; - (void)displaysDidChange:(NSNotification*)aNotification; @@ -277,16 +280,37 @@ static bool ImGui_ImplOSX_HandleEvent(NSEvent* event, NSView* view); @implementation ImGuiObserver +- (void)onApplicationWillBecomeActive:(NSNotification*)aNotification +{ + // Restack on WILL (before the app is actually activated) as well as DID: clicking the main window to + // reactivate brings it to the front of the normal level, and doing the restack only on DID leaves the + // main window on top of the floating windows for one frame before they are raised. Doing it here, + // before activation completes, avoids that flash. (DID still runs below to cover paths that don't post + // a WILL notification and to re-assert once the app is truly frontmost.) + if (ImGui_ImplOSX_Data* bd = ImGui_ImplOSX_GetBackendData()) + [bd->Window orderFront:nil]; + ImGui_ImplOSX_RestackSecondaryWindows(true); +} + - (void)onApplicationBecomeActive:(NSNotification*)aNotification { ImGuiIO& io = ImGui::GetIO(); io.AddFocusEvent(true); + // App is frontmost again. Clicking a floating window activates the app but doesn't itself bring the + // main window forward, so bring it front here (it stays below the floating windows, which are raised + // back above it just below), so the whole app comes forward as a unit above the previously-front app. + if (ImGui_ImplOSX_Data* bd = ImGui_ImplOSX_GetBackendData()) + [bd->Window orderFront:nil]; + ImGui_ImplOSX_RestackSecondaryWindows(true); } - (void)onApplicationBecomeInactive:(NSNotification*)aNotification { ImGuiIO& io = ImGui::GetIO(); io.AddFocusEvent(false); + // Another app took focus: drop floating windows to the normal level so its windows occlude them + // (instead of the floating windows staying on top of every other app). + ImGui_ImplOSX_RestackSecondaryWindows(false); } - (void)displaysDidChange:(NSNotification*)aNotification @@ -500,6 +524,10 @@ bool ImGui_ImplOSX_Init(NSView* view) return s_clipboard.Data; }; + [[NSNotificationCenter defaultCenter] addObserver:bd->Observer + selector:@selector(onApplicationWillBecomeActive:) + name:NSApplicationWillBecomeActiveNotification + object:nil]; [[NSNotificationCenter defaultCenter] addObserver:bd->Observer selector:@selector(onApplicationBecomeActive:) name:NSApplicationDidBecomeActiveNotification @@ -903,6 +931,48 @@ struct ImGui_ImplOSX_ViewportData @end +// Re-stack all torn-off (secondary) viewport windows above the main window, keeping their relative order. +// While the app is ACTIVE they are independent windows at NSFloatingWindowLevel: above the main window, +// but NOT children — so dragging the main window does not drag them along. While the app is INACTIVE they +// become children of the main window at the normal level: children are occluded together with the app by +// other apps, and — crucially — the window server keeps a child above its parent at ALL times, so a click +// on the main window to reactivate can't flash it over them for a frame (which a same-level sibling would). +// Only visible windows are touched (ordering an ordered-out/destroyed window would resurrect it as an empty +// floater). The main window is excluded (it is not an ImGui_ImplOSX_Window). +static void ImGui_ImplOSX_RestackSecondaryWindows(bool active) +{ + ImGui_ImplOSX_Data* bd = ImGui_ImplOSX_GetBackendData(); + if (bd == nullptr) + return; + + NSMutableArray* windows = [NSMutableArray array]; + for (NSWindow* window in NSApp.orderedWindows) // front-to-back + if (window != bd->Window && window.isVisible && [window isKindOfClass:[ImGui_ImplOSX_Window class]]) + [windows addObject:window]; + + if (active) + { + for (NSWindow* window in windows) + { + if (window.parentWindow != nil) + [window.parentWindow removeChildWindow:window]; + window.level = NSFloatingWindowLevel; + } + // Front-to-back: each ordered directly above the main window, so the front-most stays on top. + for (NSWindow* window in windows) + [window orderWindow:NSWindowAbove relativeTo:bd->Window.windowNumber]; + } + else + { + for (NSWindow* window in windows) + window.level = NSNormalWindowLevel; + // Back-to-front: each child added above the previous, so the front-most ends up the topmost child. + for (NSWindow* window in windows.reverseObjectEnumerator) + if (window.parentWindow != bd->Window) + [bd->Window addChildWindow:window ordered:NSWindowAbove]; + } +} + static void ConvertNSRect(NSRect* r) { NSRect firstScreenFrame = NSScreen.screens[0].frame; @@ -929,14 +999,24 @@ static void ImGui_ImplOSX_CreateWindow(ImGuiViewport* viewport) NSWindow* window = [[ImGui_ImplOSX_Window alloc] initWithContentRect:rect styleMask:styleMask backing:NSBackingStoreBuffered - defer:YES + defer:NO screen:screen]; - if (viewport->Flags & ImGuiViewportFlags_TopMost) - [window setLevel:NSFloatingWindowLevel]; - window.title = @"Untitled"; window.opaque = YES; + // Disable the macOS fade/zoom "appear" animation (a pop on every tear-off). New windows are torn off + // while the app is active, so start them at NSFloatingWindowLevel (above the main window, and winning + // the z-order race during the tear-off drag). ImGui_ImplOSX_RestackSecondaryWindows then manages + // level/parenting as the app gains/loses focus. + [window setAnimationBehavior:NSWindowAnimationBehaviorNone]; + window.level = NSFloatingWindowLevel; + // Parent the new window to the main window for the tear-off: the window server then composites it in the + // SAME pass as its parent so it appears immediately. A fresh top-level window otherwise reports + // occlusionState=NotVisible for one compositor pass and the torn-off window blinks out for a frame. + // ImGui_ImplOSX_UpdateWindow detaches it into an independent floating window once the drag is released. + //if (bd->Window != nil) + [bd->Window addChildWindow:window ordered:NSWindowAbove]; + KeyEventResponder* view = [[KeyEventResponder alloc] initWithFrame:rect]; if (floor(NSAppKitVersionNumber) > NSAppKitVersionNumber10_6 && ceil(NSAppKitVersionNumber) < NSAppKitVersionNumber10_15) { @@ -962,6 +1042,8 @@ static void ImGui_ImplOSX_DestroyWindow(ImGuiViewport* viewport) NSWindow* window = vd->Window; if (window != nil && vd->WindowOwned) { + if (window.parentWindow != nil) + [window.parentWindow removeChildWindow:window]; // detach if parented (inactive state) before hiding window.contentView = nil; window.contentViewController = nil; [window orderOut:nil]; @@ -985,6 +1067,29 @@ static void ImGui_ImplOSX_ShowWindow(ImGuiViewport* viewport) [vd->Window setIsVisible:YES]; } +static void ImGui_ImplOSX_UpdateWindow(ImGuiViewport* viewport) +{ + ImGui_ImplOSX_Data* bd = ImGui_ImplOSX_GetBackendData(); + ImGui_ImplOSX_ViewportData* vd = (ImGui_ImplOSX_ViewportData*)viewport->PlatformUserData; + if (vd == nullptr || vd->Window == nil || bd->Window == nil) { + return; + } + + // The new window is created as a child of the main window so it composites without a one-frame blink (see + // ImGui_ImplOSX_CreateWindow). Detach it into an independent floating window once (a) it is actually + // on-screen (occlusionState visible) and (b) the tear-off drag is released (no mouse button held), while + // the app is active. The occlusion check matters because Platform_UpdateWindow runs every frame INCLUDING + // the creation frame and BEFORE Platform_ShowWindow, so detaching earlier would undo the parenting before + // the window is ever composited and bring the blink back. While inactive it stays a child (see + // ImGui_ImplOSX_RestackSecondaryWindows). + if (vd->Window.parentWindow == bd->Window && NSApp.active && [NSEvent pressedMouseButtons] == 0 + && (vd->Window.occlusionState & NSWindowOcclusionStateVisible) != 0) { + [bd->Window removeChildWindow:vd->Window]; + vd->Window.level = NSFloatingWindowLevel; + [vd->Window orderWindow:NSWindowAbove relativeTo:bd->Window.windowNumber]; + } +} + static ImVec2 ImGui_ImplOSX_GetWindowPos(ImGuiViewport* viewport) { ImGui_ImplOSX_ViewportData* vd = (ImGui_ImplOSX_ViewportData*)viewport->PlatformUserData; @@ -1121,6 +1226,7 @@ static void ImGui_ImplOSX_InitMultiViewportSupport() platform_io.Platform_CreateWindow = ImGui_ImplOSX_CreateWindow; platform_io.Platform_DestroyWindow = ImGui_ImplOSX_DestroyWindow; platform_io.Platform_ShowWindow = ImGui_ImplOSX_ShowWindow; + platform_io.Platform_UpdateWindow = ImGui_ImplOSX_UpdateWindow; platform_io.Platform_SetWindowPos = ImGui_ImplOSX_SetWindowPos; platform_io.Platform_GetWindowPos = ImGui_ImplOSX_GetWindowPos; platform_io.Platform_SetWindowSize = ImGui_ImplOSX_SetWindowSize; diff --git a/examples/example_apple_metal4/main.mm b/examples/example_apple_metal4/main.mm index 2bfb415ed..482f71eb4 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,25 @@ @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 +@property (nonatomic, strong) id becomeActiveObserver; // resumes rendering when the app is reactivated (Cmd-Tab) +#endif @end #define FRAMES_IN_FLIGHT 2 -//----------------------------------------------------------------------------------- -// AppViewController -//----------------------------------------------------------------------------------- - @implementation AppViewController -(instancetype)initWithNibName:(nullable NSString *)nibNameOrNil bundle:(nullable NSBundle *)nibBundleOrNil @@ -49,7 +211,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 +282,137 @@ 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; + }]; + // Reactivating the app (Cmd-Tab, or clicking a window) doesn't produce a local input event, so bump the + // render counter on activation too — otherwise the throttle leaves the window on the slow heartbeat for + // up to ~1s after switching back, which looks like a lag. + self.becomeActiveObserver = [NSNotificationCenter.defaultCenter addObserverForName:NSApplicationDidBecomeActiveNotification + object:nil + queue:NSOperationQueue.mainQueue + usingBlock:^(NSNotification*) { + weakView.activeFrames = kActiveRenderFrames; + }]; + + // 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 +462,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 +493,51 @@ 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 (_becomeActiveObserver) + { + [NSNotificationCenter.defaultCenter removeObserver:_becomeActiveObserver]; + _becomeActiveObserver = 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 +546,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