From d084e03191ef099dd927c46b0d74d9ec89a882d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Rouleau?= Date: Sat, 13 Jun 2026 00:24:08 -0400 Subject: [PATCH 1/3] metal: honor Conf.sample_count for MSAA on iOS + matching pipelines Three coupled changes that together light up MSAA on the iOS Metal backend: 1. `create_metal_view` calls `setSampleCount:` on `MTKView` from `Conf.sample_count` (the param was previously prefixed `_` and silently dropped). macOS already did this. 2. `new_pipeline` reads the view's `sampleCount` and sets it on `MTLRenderPipelineDescriptor`. A pipeline's sampleCount must match every render-pass color attachment it draws to; using the view's value as the canonical app-wide count keeps the main drawable and `render_target_msaa()` targets compatible with the same pipeline. 3. `new_texture` for `RenderTarget` access with `sample_count > 1` creates the texture as `MTLTextureType::D2Multisample` with the matching `setSampleCount:`, `setMipmapLevelCount:1`, and `RenderTarget`-only usage. The single-sample resolve texture for the same render target is created separately by the caller (e.g. `render_target_ex` in macroquad) and passed in via `new_render_pass_mrt`'s `resolve_img` slice. Constraint: every render-pass color attachment must use the view's sample count. Mixing single-sample and multisample targets in one app would require per-target pipeline states, which Metal allows but miniquad's "one pipeline per material" model does not currently express. Callers that opt into `Conf.sample_count > 1` should use `render_target_msaa()` (or matching) for every offscreen color target. --- src/graphics/metal.rs | 39 +++++++++++++++++++++++++++++++++------ src/native/ios.rs | 5 ++++- 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/src/graphics/metal.rs b/src/graphics/metal.rs index 39004579..67f1cc13 100644 --- a/src/graphics/metal.rs +++ b/src/graphics/metal.rs @@ -715,12 +715,31 @@ impl RenderingBackend for MetalContext { msg_send_![descriptor, setPixelFormat: pixel_format]; } msg_send_![descriptor, setStorageMode: MTLStorageMode::Private]; - msg_send_![ - descriptor, - setUsage: MTLTextureUsage::RenderTarget as u64 - | MTLTextureUsage::ShaderRead as u64 - | MTLTextureUsage::ShaderWrite as u64 - ]; + if params.sample_count > 1 { + // MSAA render target. The matching single-sample + // resolve texture is created with `sample_count = 1` + // and passed alongside in `new_render_pass_mrt`'s + // `resolve_img` slice. Multisample textures can't be + // shader-sampled directly (they're sampled via the + // resolve pass) and can't have mipmaps. + msg_send_![ + descriptor, + setTextureType: MTLTextureType::D2Multisample + ]; + msg_send_![descriptor, setSampleCount: params.sample_count as u64]; + msg_send_![descriptor, setMipmapLevelCount: 1u64]; + msg_send_![ + descriptor, + setUsage: MTLTextureUsage::RenderTarget as u64 + ]; + } else { + msg_send_![ + descriptor, + setUsage: MTLTextureUsage::RenderTarget as u64 + | MTLTextureUsage::ShaderRead as u64 + | MTLTextureUsage::ShaderWrite as u64 + ]; + } } else { #[cfg(target_os = "macos")] { @@ -985,6 +1004,14 @@ impl RenderingBackend for MetalContext { descriptor, setStencilAttachmentPixelFormat: MTLPixelFormat::Depth32Float_Stencil8 ]; + // Match the view's sample count. The pipeline's sampleCount + // must equal the sampleCount of every render-pass color + // attachment it draws to. We use the view's value as the + // canonical app-wide sample count: MSAA render targets + // (`render_target_msaa()`) match it, and the main drawable + // is configured from `Conf.sample_count` in the view setup. + let view_sample_count: u64 = msg_send![self.view, sampleCount]; + msg_send_![descriptor, setSampleCount: view_sample_count]; let mut error: ObjcId = nil; let pipeline_state: ObjcId = msg_send![ diff --git a/src/native/ios.rs b/src/native/ios.rs index 53bf8539..d5400a59 100644 --- a/src/native/ios.rs +++ b/src/native/ios.rs @@ -451,7 +451,7 @@ unsafe fn create_opengl_view(screen_rect: NSRect, _sample_count: i32, high_dpi: } } -unsafe fn create_metal_view(screen_rect: NSRect, _sample_count: i32, _high_dpi: bool) -> View { +unsafe fn create_metal_view(screen_rect: NSRect, sample_count: i32, _high_dpi: bool) -> View { let mtk_view_obj: ObjcId = msg_send![define_glk_or_mtk_view(class!(MTKView)), alloc]; let mtk_view_obj: ObjcId = msg_send![mtk_view_obj, initWithFrame: screen_rect]; @@ -470,6 +470,9 @@ unsafe fn create_metal_view(screen_rect: NSRect, _sample_count: i32, _high_dpi: let device = MTLCreateSystemDefaultDevice(); msg_send_![mtk_view_obj, setDevice: device]; msg_send_![mtk_view_obj, setUserInteractionEnabled: YES]; + if sample_count > 1 { + msg_send_![mtk_view_obj, setSampleCount: sample_count as u64]; + } View { view: mtk_view_obj, From 8fcd91eb73bf7b5e409ad1aa6d9068e5b4abcf89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Rouleau?= Date: Sat, 13 Jun 2026 00:30:53 -0400 Subject: [PATCH 2/3] metal: pick begin_pass storeAction based on resolve attachment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `begin_pass` unconditionally set the color attachment's storeAction to `MTLStoreAction::Store`. That clobbered: - MTKView's `currentRenderPassDescriptor`, which already wires a `resolveTexture` (and matching `MultisampleResolve` storeAction) on the color attachment when the view's `sampleCount > 1`. - The descriptor built in `new_render_pass_mrt` for offscreen MSAA targets, which has the same shape. Both paths require `MultisampleResolve` — `Store` on a color attachment with a resolve texture trips Metal validation: RenderPass Descriptor Validation MTLRenderPassAttachmentDescriptor resolveTexture must have storeAction of MTLStoreActionMultisampleResolve, MTLStoreActionStoreAndMultisampleResolve or MTLStoreActionUnknown Inspect the attachment's `resolveTexture` and pick the storeAction accordingly. --- src/graphics/metal.rs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/graphics/metal.rs b/src/graphics/metal.rs index 67f1cc13..9d0ed72f 100644 --- a/src/graphics/metal.rs +++ b/src/graphics/metal.rs @@ -1217,7 +1217,20 @@ impl RenderingBackend for MetalContext { let color_attachments = msg_send_![descriptor, colorAttachments]; let color_attachment = msg_send_![color_attachments, objectAtIndexedSubscript: 0]; - msg_send_![color_attachment, setStoreAction: MTLStoreAction::Store]; + // Pick the store action based on whether the attachment + // has a resolve texture. MTKView's + // `currentRenderPassDescriptor` already wires a + // `resolveTexture` when the view's `sampleCount > 1`, and + // `new_render_pass_mrt` does the same for offscreen MSAA + // targets. Both paths require `MultisampleResolve` — + // forcing `Store` here trips Metal validation. + let resolve_texture: ObjcId = msg_send![color_attachment, resolveTexture]; + let store_action = if resolve_texture.is_null() { + MTLStoreAction::Store + } else { + MTLStoreAction::MultisampleResolve + }; + msg_send_![color_attachment, setStoreAction: store_action]; match action { PassAction::Clear { color, .. } => { From 7c74c42e89e04a8150fb61c834adb94e014b7adf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Rouleau?= Date: Sat, 13 Jun 2026 10:16:57 -0400 Subject: [PATCH 3/3] metal: preserve multisample texture across passes (StoreAndMultisampleResolve) The previous commit replaced an unconditional `Store` with `MultisampleResolve` whenever the color attachment had a resolveTexture, fixing the validation crash. But Metal allows `MultisampleResolve` to discard the multisample texture's contents after the resolve, and macroquad issues one Metal pass per draw call with `loadAction = Load` so it can accumulate draws on top of a single clear at frame start. On the iOS 27 simulator the discarded contents are loaded back as magenta, so every draw after the clear renders on top of magenta and the frame ends up magenta-tinted everywhere except the pixels touched by the very last draw call. `StoreAndMultisampleResolve` both writes the resolved data to the drawable AND preserves the multisample texture, so a subsequent `Load` reads back the accumulated frame. --- src/graphics/metal.rs | 33 +++++++++++++-------------------- 1 file changed, 13 insertions(+), 20 deletions(-) diff --git a/src/graphics/metal.rs b/src/graphics/metal.rs index 9d0ed72f..39a002e4 100644 --- a/src/graphics/metal.rs +++ b/src/graphics/metal.rs @@ -716,12 +716,9 @@ impl RenderingBackend for MetalContext { } msg_send_![descriptor, setStorageMode: MTLStorageMode::Private]; if params.sample_count > 1 { - // MSAA render target. The matching single-sample - // resolve texture is created with `sample_count = 1` - // and passed alongside in `new_render_pass_mrt`'s - // `resolve_img` slice. Multisample textures can't be - // shader-sampled directly (they're sampled via the - // resolve pass) and can't have mipmaps. + // MSAA target — render-only, no mipmaps, no + // shader-sample (the resolve texture is sampled + // instead). msg_send_![ descriptor, setTextureType: MTLTextureType::D2Multisample @@ -1004,12 +1001,9 @@ impl RenderingBackend for MetalContext { descriptor, setStencilAttachmentPixelFormat: MTLPixelFormat::Depth32Float_Stencil8 ]; - // Match the view's sample count. The pipeline's sampleCount - // must equal the sampleCount of every render-pass color - // attachment it draws to. We use the view's value as the - // canonical app-wide sample count: MSAA render targets - // (`render_target_msaa()`) match it, and the main drawable - // is configured from `Conf.sample_count` in the view setup. + // Pipeline sampleCount must match every render-pass + // color attachment it binds to; use the view's as the + // canonical app-wide value. let view_sample_count: u64 = msg_send![self.view, sampleCount]; msg_send_![descriptor, setSampleCount: view_sample_count]; @@ -1217,18 +1211,17 @@ impl RenderingBackend for MetalContext { let color_attachments = msg_send_![descriptor, colorAttachments]; let color_attachment = msg_send_![color_attachments, objectAtIndexedSubscript: 0]; - // Pick the store action based on whether the attachment - // has a resolve texture. MTKView's - // `currentRenderPassDescriptor` already wires a - // `resolveTexture` when the view's `sampleCount > 1`, and - // `new_render_pass_mrt` does the same for offscreen MSAA - // targets. Both paths require `MultisampleResolve` — - // forcing `Store` here trips Metal validation. + // If the attachment has a resolve texture, use + // `StoreAndMultisampleResolve` (not just + // `MultisampleResolve`) so the multisample texture + // survives target-switch boundaries within a frame — + // a later `Load` on the same target would otherwise + // read discarded memory. let resolve_texture: ObjcId = msg_send![color_attachment, resolveTexture]; let store_action = if resolve_texture.is_null() { MTLStoreAction::Store } else { - MTLStoreAction::MultisampleResolve + MTLStoreAction::StoreAndMultisampleResolve }; msg_send_![color_attachment, setStoreAction: store_action];