From 5336a21a6d529914a14276179ff4ffe0e9dfd688 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benoi=CC=82t=20Rouleau?= Date: Fri, 9 Oct 2026 16:51:18 -0400 Subject: [PATCH 1/2] metal: build pipeline states for render targets' attachment formats A Metal pipeline state is built for one set of attachment formats, and Metal only draws with it into a render pass whose attachments match. Every pipeline state was built for the view's: its color format, its depth/stencil format and its sample count. A render target that differs in any of them failed validation as soon as a pipeline was applied in it. On macOS, where the view has a depth/stencil buffer, that's every render target made without a depth texture: For depth attachment, the renderPipelineState pixelFormat must be MTLPixelFormatInvalid, as no texture is set. For stencil attachment, the renderPipelineState pixelFormat must be MTLPixelFormatInvalid, as no texture is set. With `sample_count: 4`, every 1x render target failed too, and with the default of 1, every multisampled one: For color attachment 0, the texture sample count (1) does not match the renderPipelineState colorSampleCount (4). A pipeline now keeps its descriptor. It still builds the view's pipeline state when it's created, so an app that only draws to the screen compiles the same state at the same time as before. The first time a pass whose attachments differ applies it, it builds a state for those formats too, and keeps it for later passes. Only other formats are built on first use: a pipeline doesn't know which render targets it will draw into, and building every combination up front would make every app compile states it never uses. An app that doesn't want a compile mid-frame can draw into each kind of target once while it loads. Verified on macOS with `MTL_DEBUG_LAYER=1`: with the view at 1x and at 4x, drawing into the window and into 1x and 4x render targets, with and without depth, in the same frame raises no validation error. With only the window drawn into, no pipeline state is built after `new_pipeline`. Co-Authored-By: Claude Opus 5.5 --- src/graphics/metal.rs | 145 ++++++++++++++++++++++++++++++------------ 1 file changed, 104 insertions(+), 41 deletions(-) diff --git a/src/graphics/metal.rs b/src/graphics/metal.rs index e18ec9f75..931dbca57 100644 --- a/src/graphics/metal.rs +++ b/src/graphics/metal.rs @@ -240,9 +240,60 @@ struct RenderPassInternal { _depth_texture: Option, } +/// The pixel formats and sample count of a render pass's attachments. +/// Metal only draws with a pipeline state built for exactly these. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct AttachmentFormats { + color: MTLPixelFormat, + depth: MTLPixelFormat, + stencil: MTLPixelFormat, + sample_count: u64, +} + +impl AttachmentFormats { + /// The formats of the view's own render passes. + unsafe fn of_view(view: ObjcId) -> Self { + // `Invalid` when the view has no depth/stencil buffer. + let depth_stencil_format: MTLPixelFormat = msg_send![view, depthStencilPixelFormat]; + AttachmentFormats { + color: msg_send![view, colorPixelFormat], + depth: depth_stencil_format, + stencil: depth_stencil_format, + sample_count: msg_send![view, sampleCount], + } + } + + unsafe fn of_pass(render_pass_desc: ObjcId) -> Self { + let format_of = |attachment: ObjcId| { + let texture: ObjcId = msg_send![attachment, texture]; + if texture.is_null() { + MTLPixelFormat::Invalid + } else { + msg_send![texture, pixelFormat] + } + }; + let color_attachment = msg_send_![ + msg_send_![render_pass_desc, colorAttachments], + objectAtIndexedSubscript: 0usize + ]; + let color_texture: ObjcId = msg_send![color_attachment, texture]; + AttachmentFormats { + color: format_of(color_attachment), + depth: format_of(msg_send_![render_pass_desc, depthAttachment]), + stencil: format_of(msg_send_![render_pass_desc, stencilAttachment]), + sample_count: msg_send![color_texture, sampleCount], + } + } +} + #[derive(Clone, Debug)] struct PipelineInternal { - pipeline_state: ObjcId, + /// Everything but the attachment formats, which each pipeline state + /// fills in with its own. + descriptor: ObjcId, + /// The view's, built with the pipeline, then one for each other set + /// of formats a render pass has applied it with. + pipeline_states: Vec<(AttachmentFormats, ObjcId)>, depth_stencil_state: ObjcId, //layout: Vec, //attributes: Vec, @@ -250,6 +301,44 @@ struct PipelineInternal { //params: PipelineParams, } +impl PipelineInternal { + /// The pipeline state for `formats`, built the first time they're + /// asked for. + fn pipeline_state(&mut self, device: ObjcId, formats: AttachmentFormats) -> ObjcId { + if let Some(&(_, pipeline_state)) = self + .pipeline_states + .iter() + .find(|(built_for, _)| *built_for == formats) + { + return pipeline_state; + } + unsafe { + let color_attachment = msg_send_![ + msg_send_![self.descriptor, colorAttachments], + objectAtIndexedSubscript: 0usize + ]; + msg_send_![color_attachment, setPixelFormat: formats.color]; + msg_send_![self.descriptor, setDepthAttachmentPixelFormat: formats.depth]; + msg_send_![self.descriptor, setStencilAttachmentPixelFormat: formats.stencil]; + msg_send_![self.descriptor, setSampleCount: formats.sample_count]; + + let mut error: ObjcId = nil; + let pipeline_state: ObjcId = msg_send![ + device, + newRenderPipelineStateWithDescriptor: self.descriptor + error: &mut error + ]; + if pipeline_state.is_null() { + let description: ObjcId = msg_send![error, localizedDescription]; + let string = apple_util::nsstring_to_string(description); + panic!("newRenderPipelineStateWithDescriptor error: {}", string); + } + self.pipeline_states.push((formats, pipeline_state)); + pipeline_state + } + } +} + #[derive(Clone, Copy)] struct Texture { texture: ObjcId, @@ -289,6 +378,7 @@ pub struct MetalContext { /// for `apply_scissor_rect`'s Y-flip + clamp. current_pass_width: u32, current_pass_height: u32, + current_pass_formats: Option, // Bookkeeping for the deferred-end pass-merge in `begin_pass` // / `end_render_pass`. current_pass_target: Option>, @@ -386,6 +476,7 @@ impl MetalContext { render_encoder: None, current_pass_width: 0, current_pass_height: 0, + current_pass_formats: None, current_pass_target: None, pending_end_encoder: false, view, @@ -1054,8 +1145,6 @@ impl RenderingBackend for MetalContext { let color_attachments = msg_send_![descriptor, colorAttachments]; for i in 0..1usize { let color_attachment = msg_send_![color_attachments, objectAtIndexedSubscript: i]; - let view_pixel_format: MTLPixelFormat = msg_send![self.view, colorPixelFormat]; - msg_send_![color_attachment, setPixelFormat: view_pixel_format]; if let Some(color_blend) = params.color_blend { msg_send_![color_attachment, setBlendingEnabled: true]; @@ -1099,40 +1188,6 @@ impl RenderingBackend for MetalContext { ]; } } - // Pipeline depth/stencil formats must match whatever - // render pass uses this pipeline. MTKView reports - // `Invalid` when no depth/stencil is configured — - // leave the descriptor's defaults (also `Invalid`) - // alone in that case; otherwise mirror the view. - let view_depth_stencil_format: MTLPixelFormat = - msg_send![self.view, depthStencilPixelFormat]; - if view_depth_stencil_format != MTLPixelFormat::Invalid { - msg_send_![ - descriptor, - setDepthAttachmentPixelFormat: view_depth_stencil_format - ]; - msg_send_![ - descriptor, - setStencilAttachmentPixelFormat: view_depth_stencil_format - ]; - } - // 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]; - - let mut error: ObjcId = nil; - let pipeline_state: ObjcId = msg_send![ - self.device, - newRenderPipelineStateWithDescriptor: descriptor - error: &mut error - ]; - if pipeline_state.is_null() { - let description: ObjcId = msg_send![error, localizedDescription]; - let string = apple_util::nsstring_to_string(description); - panic!("newRenderPipelineStateWithDescriptor error: {}", string); - } let depth_stencil_desc = msg_send_![class!(MTLDepthStencilDescriptor), new]; msg_send_![depth_stencil_desc, setDepthWriteEnabled: BOOL::from(params.depth_write)]; @@ -1167,14 +1222,19 @@ impl RenderingBackend for MetalContext { newDepthStencilStateWithDescriptor: depth_stencil_desc ]; - let pipeline = PipelineInternal { - pipeline_state, + let mut pipeline = PipelineInternal { + descriptor, + pipeline_states: vec![], depth_stencil_state, //layout: buffer_layout.to_vec(), //attributes: vertex_layout, _shader: shader, //params, }; + // Compiling a pipeline state takes milliseconds, so the view's + // is built now rather than mid-frame. A render target's formats + // aren't known until a pass draws into it. + pipeline.pipeline_state(self.device, AttachmentFormats::of_view(self.view)); self.pipelines.push(pipeline); @@ -1191,9 +1251,11 @@ impl RenderingBackend for MetalContext { unsafe { self.current_pipeline = Some(*pipeline); - let pipeline = &self.pipelines[pipeline.0]; + let pipeline = &mut self.pipelines[pipeline.0]; + let formats = self.current_pass_formats.unwrap(); + let pipeline_state = pipeline.pipeline_state(self.device, formats); - msg_send_![render_encoder, setRenderPipelineState: pipeline.pipeline_state]; + msg_send_![render_encoder, setRenderPipelineState: pipeline_state]; msg_send_![render_encoder, setDepthStencilState:pipeline.depth_stencil_state]; // render_encoder.set_front_facing_winding(pipeline.params.front_face_order.into()); // render_encoder.set_cull_mode(pipeline.params.cull_face.into()); @@ -1349,6 +1411,7 @@ impl RenderingBackend for MetalContext { assert!(!descriptor.is_null()); self.current_pass_width = width_px; self.current_pass_height = height_px; + self.current_pass_formats = Some(AttachmentFormats::of_pass(descriptor)); let color_attachments = msg_send_![descriptor, colorAttachments]; let color_attachment = msg_send_![color_attachments, objectAtIndexedSubscript: 0]; From 88e398155cabe0b296c42f6405eb7e562c465d74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benoi=CC=82t=20Rouleau?= Date: Sat, 10 Oct 2026 10:43:04 -0400 Subject: [PATCH 2/2] metal: build pipeline states asynchronously, and start them ahead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Building a Metal pipeline state takes milliseconds: 3–25 ms each on an iPhone 17 Pro with nothing cached, in a macroquad game (CHOMP). A pipeline builds one for the view as it's made and one for each other set of attachment formats it draws into, each with the synchronous `newRenderPipelineStateWithDescriptor:error:`, so an app that makes or prepares many pipelines while it loads waits for each in turn. Each state is now built with `newRenderPipelineStateWithDescriptor:completionHandler:`, which Metal runs on threads of its own, so states started together build at once. The first draw that needs one waits for it, if it isn't done yet. `new_pipeline` starts the view's as before, without waiting for it. `RenderingBackend::prepare_pipeline` starts a pipeline's state for a render pass's attachment formats, or the view's, ahead of its first draw there. An app that doesn't want a build mid-frame can start every pipeline it has for every kind of target it draws into, then draw into each once while it loads. It does nothing on OpenGL, which has no such states. The completion handler is a block literal laid out by hand, with no copy or dispose helpers: it holds a copy of the descriptor and a counted reference to where the result goes, and releases both when Metal calls it, once. Co-Authored-By: Claude Opus 5.5 --- src/graphics.rs | 6 ++ src/graphics/metal.rs | 184 +++++++++++++++++++++++++++++++++++------- 2 files changed, 160 insertions(+), 30 deletions(-) diff --git a/src/graphics.rs b/src/graphics.rs index dbdfffc65..bebea9e34 100644 --- a/src/graphics.rs +++ b/src/graphics.rs @@ -1256,6 +1256,12 @@ pub trait RenderingBackend { shader: ShaderId, params: PipelineParams, ) -> Pipeline; + /// Starts getting `pipeline` ready to draw into `pass`, or the screen + /// if `None`, so its first draw there waits less, or not at all. Metal + /// builds a pipeline state for each set of attachment formats a + /// pipeline draws into, which takes milliseconds, and builds the ones + /// started together at once. Other backends have nothing to build. + fn prepare_pipeline(&mut self, _pipeline: &Pipeline, _pass: Option) {} fn apply_pipeline(&mut self, pipeline: &Pipeline); fn delete_pipeline(&mut self, pipeline: Pipeline); diff --git a/src/graphics/metal.rs b/src/graphics/metal.rs index 931dbca57..3cd6bd0a3 100644 --- a/src/graphics/metal.rs +++ b/src/graphics/metal.rs @@ -7,6 +7,9 @@ use crate::native::apple::{ use super::*; +use std::ffi::c_void; +use std::sync::{Arc, Condvar, Mutex}; + // https://developer.apple.com/metal/Metal-Feature-Set-Tables.pdf const MAX_UNIFORM_BUFFER_SIZE: u64 = 4 * 1024 * 1024; const NUM_INFLIGHT_FRAMES: usize = 3; @@ -286,14 +289,97 @@ impl AttachmentFormats { } } -#[derive(Clone, Debug)] +/// A pipeline state for one set of attachment formats. +#[derive(Debug)] +enum PipelineState { + /// Being built by Metal, on threads of its own. + Building(Arc), + Built(ObjcId), +} + +/// Where Metal's completion handler leaves the pipeline state it built, +/// or why it couldn't. +#[derive(Debug, Default)] +struct BuildingPipelineState { + result: Mutex>, + finished: Condvar, +} + +#[derive(Debug)] +struct BuildResult(Result); + +// SAFETY: a built pipeline state is immutable, and Metal objects are +// reference counted atomically. +unsafe impl Send for BuildResult {} + +impl BuildingPipelineState { + fn wait(&self) -> Result { + let mut result = self.result.lock().unwrap(); + loop { + if let Some(BuildResult(built)) = result.take() { + return built; + } + result = self.finished.wait(result).unwrap(); + } + } +} + +/// The block Metal calls once a pipeline state is built, laid out as +/// the blocks runtime expects. It needs no copy or dispose helpers, as +/// what it holds it releases when called, which Metal does once. +#[repr(C)] +struct CompletionBlock { + isa: *const c_void, + flags: i32, + reserved: i32, + invoke: unsafe extern "C" fn(*mut CompletionBlock, ObjcId, ObjcId), + descriptor: *const CompletionBlockDescriptor, + /// The descriptor the state is built from, which the build may read + /// until it's done. + pipeline_descriptor: ObjcId, + /// Where the state goes, from `Arc::into_raw`. + building: *const BuildingPipelineState, +} + +#[repr(C)] +struct CompletionBlockDescriptor { + reserved: u64, + size: u64, +} + +static COMPLETION_BLOCK_DESCRIPTOR: CompletionBlockDescriptor = CompletionBlockDescriptor { + reserved: 0, + size: std::mem::size_of::() as u64, +}; + +unsafe extern "C" fn pipeline_state_built( + block: *mut CompletionBlock, + pipeline_state: ObjcId, + error: ObjcId, +) { + let block = &*block; + msg_send_![block.pipeline_descriptor, release]; + let building = Arc::from_raw(block.building); + let result = if pipeline_state.is_null() { + let description: ObjcId = msg_send![error, localizedDescription]; + Err(apple_util::nsstring_to_string(description)) + } else { + // Metal only lends it to the handler. + msg_send_![pipeline_state, retain]; + Ok(pipeline_state) + }; + *building.result.lock().unwrap() = Some(BuildResult(result)); + building.finished.notify_all(); +} + +#[derive(Debug)] struct PipelineInternal { /// Everything but the attachment formats, which each pipeline state /// fills in with its own. descriptor: ObjcId, - /// The view's, built with the pipeline, then one for each other set - /// of formats a render pass has applied it with. - pipeline_states: Vec<(AttachmentFormats, ObjcId)>, + /// The view's, started with the pipeline, then one for each other + /// set of formats it's been prepared for or drawn with. + pipeline_states: Vec<(AttachmentFormats, PipelineState)>, depth_stencil_state: ObjcId, //layout: Vec, //attributes: Vec, @@ -302,40 +388,68 @@ struct PipelineInternal { } impl PipelineInternal { - /// The pipeline state for `formats`, built the first time they're - /// asked for. - fn pipeline_state(&mut self, device: ObjcId, formats: AttachmentFormats) -> ObjcId { - if let Some(&(_, pipeline_state)) = self + /// Starts building the pipeline state for `formats`, unless it's + /// built or being built already. Metal builds it on threads of its + /// own, so states started together build at once. + fn start_building(&mut self, device: ObjcId, formats: AttachmentFormats) { + if self .pipeline_states .iter() - .find(|(built_for, _)| *built_for == formats) + .any(|(built_for, _)| *built_for == formats) { - return pipeline_state; + return; } + let building = Arc::new(BuildingPipelineState::default()); unsafe { + // A copy, so the next build's formats don't change this one's. + let descriptor = msg_send_![self.descriptor, copy]; let color_attachment = msg_send_![ - msg_send_![self.descriptor, colorAttachments], + msg_send_![descriptor, colorAttachments], objectAtIndexedSubscript: 0usize ]; msg_send_![color_attachment, setPixelFormat: formats.color]; - msg_send_![self.descriptor, setDepthAttachmentPixelFormat: formats.depth]; - msg_send_![self.descriptor, setStencilAttachmentPixelFormat: formats.stencil]; - msg_send_![self.descriptor, setSampleCount: formats.sample_count]; - - let mut error: ObjcId = nil; - let pipeline_state: ObjcId = msg_send![ + msg_send_![descriptor, setDepthAttachmentPixelFormat: formats.depth]; + msg_send_![descriptor, setStencilAttachmentPixelFormat: formats.stencil]; + msg_send_![descriptor, setSampleCount: formats.sample_count]; + + let block = CompletionBlock { + isa: _NSConcreteStackBlock.as_ptr() as *const c_void, + flags: 0, + reserved: 0, + invoke: pipeline_state_built, + descriptor: &COMPLETION_BLOCK_DESCRIPTOR, + pipeline_descriptor: descriptor, + building: Arc::into_raw(building.clone()), + }; + msg_send_![ device, - newRenderPipelineStateWithDescriptor: self.descriptor - error: &mut error + newRenderPipelineStateWithDescriptor: descriptor + completionHandler: &block ]; - if pipeline_state.is_null() { - let description: ObjcId = msg_send![error, localizedDescription]; - let string = apple_util::nsstring_to_string(description); - panic!("newRenderPipelineStateWithDescriptor error: {}", string); - } - self.pipeline_states.push((formats, pipeline_state)); - pipeline_state } + self.pipeline_states + .push((formats, PipelineState::Building(building))); + } + + /// The pipeline state for `formats`, waiting for it to finish + /// building, or building it now if it hasn't started. + fn pipeline_state(&mut self, device: ObjcId, formats: AttachmentFormats) -> ObjcId { + self.start_building(device, formats); + let (_, state) = self + .pipeline_states + .iter_mut() + .find(|(built_for, _)| *built_for == formats) + .unwrap(); + if let PipelineState::Building(building) = state { + let built = building.wait().unwrap_or_else(|error| { + panic!("newRenderPipelineStateWithDescriptor error: {}", error) + }); + *state = PipelineState::Built(built); + } + let PipelineState::Built(pipeline_state) = state else { + unreachable!() + }; + *pipeline_state } } @@ -1231,10 +1345,10 @@ impl RenderingBackend for MetalContext { _shader: shader, //params, }; - // Compiling a pipeline state takes milliseconds, so the view's - // is built now rather than mid-frame. A render target's formats - // aren't known until a pass draws into it. - pipeline.pipeline_state(self.device, AttachmentFormats::of_view(self.view)); + // Building a pipeline state takes milliseconds, so the view's + // starts now rather than at the first draw. A render target's + // formats aren't known until it's prepared for or drawn into. + pipeline.start_building(self.device, AttachmentFormats::of_view(self.view)); self.pipelines.push(pipeline); @@ -1242,6 +1356,16 @@ impl RenderingBackend for MetalContext { } } + fn prepare_pipeline(&mut self, pipeline: &Pipeline, pass: Option) { + let formats = unsafe { + match pass { + None => AttachmentFormats::of_view(self.view), + Some(pass) => AttachmentFormats::of_pass(self.passes[pass.0].render_pass_desc), + } + }; + self.pipelines[pipeline.0].start_building(self.device, formats); + } + fn apply_pipeline(&mut self, pipeline: &Pipeline) { assert!( self.render_encoder.is_some(),