@@ -178,6 +178,9 @@ - (instancetype)initWithDevice:(id<MTLDevice>)d
178178 _layer.framebufferOnly = NO ;
179179 _layer.displaySyncEnabled = YES ;
180180#endif
181+ /* Configure drawable pool for triple-buffering */
182+ if (@available (iOS 13.0 , macOS 10.15.4 , tvOS 13.0 , *))
183+ _layer.maximumDrawableCount = MAX_INFLIGHT;
181184 _library = l;
182185 _commandQueue = [_device newCommandQueue ];
183186 _clearColor = MTLClearColorMake (0 , 0 , 0 , 1 );
@@ -319,6 +322,12 @@ - (bool)_initClearState
319322 psd.vertexFunction = [_library newFunctionWithName: @" stock_vertex" ];
320323 psd.fragmentFunction = [_library newFunctionWithName: @" stock_fragment_color" ];
321324
325+ if (!psd.vertexFunction || !psd.fragmentFunction )
326+ {
327+ RARCH_ERR (" [Metal] Failed to load clear state shader functions.\n " );
328+ return NO ;
329+ }
330+
322331 _clearState = [_device newRenderPipelineStateWithDescriptor: psd error: &err];
323332 if (err != nil )
324333 {
@@ -349,6 +358,12 @@ - (bool)_initMenuStates
349358 psd.vertexFunction = [_library newFunctionWithName: @" stock_vertex" ];
350359 psd.fragmentFunction = [_library newFunctionWithName: @" stock_fragment" ];
351360
361+ if (!psd.vertexFunction || !psd.fragmentFunction )
362+ {
363+ RARCH_ERR (" [Metal] Failed to load stock shader functions.\n " );
364+ return NO ;
365+ }
366+
352367 _states[VIDEO_SHADER_STOCK_BLEND][0 ] = [_device newRenderPipelineStateWithDescriptor: psd error: &err];
353368 if (err != nil )
354369 {
@@ -572,10 +587,22 @@ - (Texture *)newTexture:(struct texture_image)image filter:(enum texture_filter_
572587
573588- (void )convertFormat : (RPixelFormat)fmt from : (id <MTLTexture >)src to : (id <MTLTexture >)dst
574589{
575- assert (src.width == dst.width && src.height == dst.height );
576- assert (fmt >= 0 && fmt < RPixelFormatCount);
590+ if (src.width != dst.width || src.height != dst.height )
591+ {
592+ RARCH_ERR (" [Metal] convertFormat: texture dimensions mismatch\n " );
593+ return ;
594+ }
595+ if (fmt < 0 || fmt >= RPixelFormatCount)
596+ {
597+ RARCH_ERR (" [Metal] convertFormat: invalid pixel format %u \n " , (unsigned )fmt);
598+ return ;
599+ }
577600 Filter *conv = _filters[fmt];
578- assert (conv != nil );
601+ if (!conv)
602+ {
603+ RARCH_ERR (" [Metal] convertFormat: no filter for format %u \n " , (unsigned )fmt);
604+ return ;
605+ }
579606 [conv apply: self .blitCommandBuffer in: src out: dst];
580607}
581608
@@ -656,24 +683,46 @@ - (bool)readBackBuffer:(uint8_t *)buffer
656683
657684- (void )begin
658685{
659- assert (_commandBuffer == nil );
660- dispatch_semaphore_wait (_inflightSemaphore, DISPATCH_TIME_FOREVER);
686+ if (_commandBuffer != nil )
687+ {
688+ RARCH_WARN (" [Metal] begin called with active command buffer - resetting\n " );
689+ _commandBuffer = nil ;
690+ }
691+
692+ /* Don't use semaphore for frame pacing - let nextDrawable handle it.
693+ * CAMetalLayer.nextDrawable will block if no drawable is available,
694+ * which naturally paces us to the display refresh rate.
695+ * Using a semaphore on top of this causes timing mismatches because
696+ * the semaphore signals on presentation but the drawable isn't
697+ * released until the NEXT vsync. */
698+
661699 _commandBuffer = [_commandQueue commandBuffer ];
662700 _commandBuffer.label = @" Frame command buffer" ;
663701 _backBuffer = nil ;
664702}
665703
666704- (id <MTLRenderCommandEncoder >)rce
667705{
668- assert (_commandBuffer != nil );
706+ if (_commandBuffer == nil )
707+ {
708+ RARCH_ERR (" [Metal] rce called without active command buffer\n " );
709+ return nil ;
710+ }
669711 if (_rce == nil )
670712 {
713+ id <CAMetalDrawable > drawable = self.nextDrawable ;
714+ if (!drawable || !drawable.texture )
715+ {
716+ RARCH_WARN (" [Metal] Failed to acquire drawable - frame dropped\n " );
717+ return nil ;
718+ }
719+
671720 MTLRenderPassDescriptor *rpd = [MTLRenderPassDescriptor new ];
672721 rpd.colorAttachments [0 ].clearColor = _clearColor;
673722 rpd.colorAttachments [0 ].loadAction = MTLLoadActionClear ;
674- rpd.colorAttachments [0 ].texture = self. nextDrawable .texture ;
723+ rpd.colorAttachments [0 ].texture = drawable .texture ;
675724 if (_captureEnabled)
676- _backBuffer = self. nextDrawable .texture ;
725+ _backBuffer = drawable .texture ;
677726 _rce = [_commandBuffer renderCommandEncoderWithDescriptor: rpd];
678727 _rce.label = @" Frame command encoder" ;
679728 }
@@ -729,7 +778,11 @@ - (void)drawQuadX:(float)x y:(float)y w:(float)w h:(float)h
729778
730779- (void )end
731780{
732- assert (_commandBuffer != nil );
781+ if (_commandBuffer == nil )
782+ {
783+ RARCH_WARN (" [Metal] end called without active command buffer\n " );
784+ return ;
785+ }
733786
734787 [_chain[_currentChain] commitRanges ];
735788
@@ -743,9 +796,10 @@ - (void)end
743796 [bce endEncoding ];
744797 }
745798#endif
746- /* Pending blits for mipmaps or render passes for slang shaders */
799+ /* Pending blits for mipmaps or render passes for slang shaders.
800+ * Metal command queues guarantee commit-order execution, so we don't
801+ * need to block the CPU waiting for completion. */
747802 [_blitCommandBuffer commit ];
748- [_blitCommandBuffer waitUntilCompleted ];
749803 _blitCommandBuffer = nil ;
750804 }
751805
@@ -755,23 +809,40 @@ - (void)end
755809 _rce = nil ;
756810 }
757811
758- __block dispatch_semaphore_t inflight = _inflightSemaphore;
759- [_commandBuffer addCompletedHandler: ^(id <MTLCommandBuffer > _) {
760- dispatch_semaphore_signal (inflight);
761- }];
812+ id <CAMetalDrawable > drawable = self.nextDrawable ;
762813
763- if (self. nextDrawable )
814+ if (drawable )
764815 {
765- [_commandBuffer presentDrawable: self .nextDrawable];
816+ /* Use addScheduledHandler to present, following Apple's recommendation.
817+ * According to Apple (and used by MoltenVK), it is more performant to call
818+ * [drawable present] from within a scheduled-handler than to use
819+ * [commandBuffer presentDrawable:]. This provides better frame pacing
820+ * because presentation is queued when the command buffer is scheduled
821+ * (added to GPU queue), not when it completes. */
822+ [_commandBuffer addScheduledHandler: ^(id <MTLCommandBuffer > _Nonnull buffer) {
823+ [drawable present ];
824+ }];
766825 }
767826
768827 [_commandBuffer commit ];
769828
770829 _commandBuffer = nil ;
771- _drawable = nil ;
772830 [self _nextChain ];
773831}
774832
833+ - (void )swapBuffers
834+ {
835+ /* Acquire the next drawable after presentation, matching Vulkan's
836+ * swap_buffers timing where acquisition happens AFTER presenting.
837+ *
838+ * We explicitly clear _drawable first to force a fresh acquisition.
839+ * nextDrawable will block if no drawable is available (all 3 are
840+ * in-flight), which naturally paces us to the display refresh rate.
841+ * This blocking behavior is intentional for proper frame pacing. */
842+ _drawable = nil ;
843+ _drawable = _layer.nextDrawable ;
844+ }
845+
775846- (bool )allocRange : (BufferRange *)range length : (NSUInteger )length
776847{
777848 return [_chain[_currentChain] allocRange: range length: length];
@@ -981,7 +1052,7 @@ - (void)apply:(id<MTLCommandBuffer>)cb inBuf:(id<MTLBuffer>)tin outTex:(id<MTLTe
9811052 [self .delegate configure: ce];
9821053
9831054 MTLSize size = MTLSizeMake (32 , 1 , 1 );
984- MTLSize count = MTLSizeMake ((tin.length + 00 ) / 32 , 1 , 1 );
1055+ MTLSize count = MTLSizeMake ((tin.length + 31 ) / 32 , 1 , 1 );
9851056
9861057 [ce dispatchThreadgroups: count threadsPerThreadgroup: size];
9871058 [ce endEncoding ];
0 commit comments