diff --git a/crates/platform/src/metal.rs b/crates/platform/src/metal.rs index 2a5f0fe..848c490 100644 --- a/crates/platform/src/metal.rs +++ b/crates/platform/src/metal.rs @@ -9,6 +9,7 @@ //! QuartzCore.framework. The `platform` crate is one of the few crates //! where `unsafe` is permitted. +use crate::cf::CfString; use crate::objc::Id; use crate::{class, msg_send}; use std::os::raw::c_void; @@ -79,6 +80,258 @@ pub struct MetalSize { pub height: f64, } +// --------------------------------------------------------------------------- +// MTLPrimitiveType constants +// --------------------------------------------------------------------------- + +/// `MTLPrimitiveTypeTriangle` — render triangles from triples of vertices. +const MTL_PRIMITIVE_TYPE_TRIANGLE: u64 = 3; + +// --------------------------------------------------------------------------- +// MTLBlendFactor constants +// --------------------------------------------------------------------------- + +/// `MTLBlendFactorSourceAlpha` +const MTL_BLEND_FACTOR_SOURCE_ALPHA: u64 = 4; + +/// `MTLBlendFactorOneMinusSourceAlpha` +const MTL_BLEND_FACTOR_ONE_MINUS_SOURCE_ALPHA: u64 = 5; + +// --------------------------------------------------------------------------- +// MTLRegion (for texture upload) +// --------------------------------------------------------------------------- + +#[repr(C)] +#[derive(Clone, Copy)] +struct MtlOrigin { + x: u64, + y: u64, + z: u64, +} + +#[repr(C)] +#[derive(Clone, Copy)] +struct MtlSize { + width: u64, + height: u64, + depth: u64, +} + +#[repr(C)] +#[derive(Clone, Copy)] +struct MtlRegion { + origin: MtlOrigin, + size: MtlSize, +} + +// --------------------------------------------------------------------------- +// Vertex format for 2D rendering +// --------------------------------------------------------------------------- + +/// A vertex for 2D quad rendering via the Metal pipeline. +/// +/// Layout matches the MSL `Vertex` struct (packed floats, 36 bytes total). +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct Vertex { + /// Position in pixel coordinates. + pub position: [f32; 2], + /// RGBA color (0.0–1.0). + pub color: [f32; 4], + /// UV texture coordinates (unused for solid-color quads). + pub tex_coord: [f32; 2], + /// 0.0 = solid color, 1.0 = sample from texture. + pub use_texture: f32, +} + +/// Uniform data passed to the vertex shader. +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct Uniforms { + /// Viewport dimensions in pixels (width, height). + pub viewport_size: [f32; 2], +} + +/// A 2D axis-aligned rectangle for batched rendering. +#[derive(Debug, Clone, Copy)] +pub struct Quad { + /// X position in pixels. + pub x: f32, + /// Y position in pixels. + pub y: f32, + /// Width in pixels. + pub width: f32, + /// Height in pixels. + pub height: f32, + /// RGBA color (0.0–1.0). + pub color: [f32; 4], +} + +impl Quad { + /// Decompose this quad into 6 vertices (2 triangles) and append to `out`. + pub fn triangulate(&self, out: &mut Vec) { + let x0 = self.x; + let y0 = self.y; + let x1 = self.x + self.width; + let y1 = self.y + self.height; + let c = self.color; + let tc = [0.0, 0.0]; + let ut = 0.0; + + // Triangle 1: top-left, top-right, bottom-left + out.push(Vertex { + position: [x0, y0], + color: c, + tex_coord: tc, + use_texture: ut, + }); + out.push(Vertex { + position: [x1, y0], + color: c, + tex_coord: tc, + use_texture: ut, + }); + out.push(Vertex { + position: [x0, y1], + color: c, + tex_coord: tc, + use_texture: ut, + }); + + // Triangle 2: top-right, bottom-right, bottom-left + out.push(Vertex { + position: [x1, y0], + color: c, + tex_coord: tc, + use_texture: ut, + }); + out.push(Vertex { + position: [x1, y1], + color: c, + tex_coord: tc, + use_texture: ut, + }); + out.push(Vertex { + position: [x0, y1], + color: c, + tex_coord: tc, + use_texture: ut, + }); + } +} + +// --------------------------------------------------------------------------- +// MSL shader source +// --------------------------------------------------------------------------- + +/// Metal Shading Language source for the 2D rendering pipeline. +/// +/// Vertex shader: converts pixel coordinates to clip space using a viewport +/// uniform. Fragment shader: solid-color fill or textured sampling with +/// color tint (selected per-vertex via `use_texture`). +const SHADER_SOURCE: &str = r#" +#include +using namespace metal; + +struct Vertex { + packed_float2 position; + packed_float4 color; + packed_float2 tex_coord; + float use_texture; +}; + +struct Uniforms { + float2 viewport_size; +}; + +struct VertexOut { + float4 position [[position]]; + float4 color; + float2 tex_coord; + float use_texture; +}; + +vertex VertexOut vertex_main( + const device Vertex* vertices [[buffer(0)]], + constant Uniforms& uniforms [[buffer(1)]], + uint vid [[vertex_id]] +) { + VertexOut out; + float2 pos = float2(vertices[vid].position); + out.position = float4( + (pos.x / uniforms.viewport_size.x) * 2.0 - 1.0, + 1.0 - (pos.y / uniforms.viewport_size.y) * 2.0, + 0.0, + 1.0 + ); + out.color = float4(vertices[vid].color); + out.tex_coord = float2(vertices[vid].tex_coord); + out.use_texture = vertices[vid].use_texture; + return out; +} + +fragment float4 fragment_main( + VertexOut in [[stage_in]], + texture2d tex [[texture(0)]], + sampler samp [[sampler(0)]] +) { + if (in.use_texture > 0.5) { + float4 tex_color = tex.sample(samp, in.tex_coord); + return tex_color * in.color; + } + return in.color; +} +"#; + +// --------------------------------------------------------------------------- +// Buffer — wraps id +// --------------------------------------------------------------------------- + +/// Wrapper around `id`. +pub struct Buffer { + id: Id, + length: usize, +} + +impl Buffer { + /// Return the underlying Objective-C object. + pub fn id(&self) -> Id { + self.id + } + + /// Return the buffer size in bytes. + pub fn length(&self) -> usize { + self.length + } +} + +// --------------------------------------------------------------------------- +// RenderPipeline — wraps id +// --------------------------------------------------------------------------- + +/// Wrapper around `id`. +pub struct RenderPipeline { + id: Id, +} + +// --------------------------------------------------------------------------- +// Texture — wraps id +// --------------------------------------------------------------------------- + +/// Wrapper around `id`. +pub struct Texture { + id: Id, +} + +// --------------------------------------------------------------------------- +// SamplerState — wraps id +// --------------------------------------------------------------------------- + +/// Wrapper around `id`. +pub struct SamplerState { + id: Id, +} + // --------------------------------------------------------------------------- // Device — wraps id // --------------------------------------------------------------------------- @@ -109,6 +362,109 @@ impl Device { pub fn id(&self) -> Id { self.id } + + /// Compile MSL source into a library. + pub fn new_library_with_source(&self, source: &str) -> Option { + let ns_source = CfString::new(source)?; + let mut error_ptr: *mut c_void = std::ptr::null_mut(); + let library: *mut c_void = msg_send![ + self.id.as_ptr(), + newLibraryWithSource: ns_source.as_void_ptr() as *mut c_void, + options: std::ptr::null_mut::(), + error: &mut error_ptr as *mut *mut c_void + ]; + unsafe { Id::from_raw(library as *mut _) } + } + + /// Create a render pipeline state from a descriptor. + pub fn new_render_pipeline_state(&self, descriptor: Id) -> Option { + let mut error_ptr: *mut c_void = std::ptr::null_mut(); + let state: *mut c_void = msg_send![ + self.id.as_ptr(), + newRenderPipelineStateWithDescriptor: descriptor.as_ptr(), + error: &mut error_ptr as *mut *mut c_void + ]; + let id = unsafe { Id::from_raw(state as *mut _) }?; + Some(RenderPipeline { id }) + } + + /// Create a buffer from vertex data. + pub fn new_buffer_with_vertices(&self, vertices: &[Vertex]) -> Option { + let byte_len = std::mem::size_of_val(vertices); + if byte_len == 0 { + return None; + } + let buf: *mut c_void = msg_send![ + self.id.as_ptr(), + newBufferWithBytes: vertices.as_ptr() as *mut c_void, + length: byte_len as u64, + options: 0u64 + ]; + let id = unsafe { Id::from_raw(buf as *mut _) }?; + Some(Buffer { + id, + length: byte_len, + }) + } + + /// Create a 1×1 white BGRA texture (used as a dummy when no texture is bound). + pub fn new_dummy_texture(&self) -> Option { + // Create texture descriptor via class method + let cls = class!("MTLTextureDescriptor")?; + let desc: *mut c_void = msg_send![ + cls.as_ptr(), + texture2DDescriptorWithPixelFormat: MTL_PIXEL_FORMAT_BGRA8_UNORM, + width: 1u64, + height: 1u64, + mipmapped: false + ]; + let desc_id = unsafe { Id::from_raw(desc as *mut _) }?; + + // Set usage to ShaderRead (1) + let _: *mut c_void = msg_send![desc_id.as_ptr(), setUsage: 1u64]; + + // Create texture + let tex: *mut c_void = + msg_send![self.id.as_ptr(), newTextureWithDescriptor: desc_id.as_ptr()]; + let tex_id = unsafe { Id::from_raw(tex as *mut _) }?; + + // Upload 1 white pixel (BGRA: 0xFF 0xFF 0xFF 0xFF) + let pixel: [u8; 4] = [0xFF, 0xFF, 0xFF, 0xFF]; + let region = MtlRegion { + origin: MtlOrigin { x: 0, y: 0, z: 0 }, + size: MtlSize { + width: 1, + height: 1, + depth: 1, + }, + }; + let _: *mut c_void = msg_send![ + tex_id.as_ptr(), + replaceRegion: region, + mipmapLevel: 0u64, + withBytes: pixel.as_ptr() as *mut c_void, + bytesPerRow: 4u64 + ]; + + Some(Texture { id: tex_id }) + } + + /// Create a sampler state with linear filtering. + pub fn new_sampler_state(&self) -> Option { + let cls = class!("MTLSamplerDescriptor")?; + let desc: *mut c_void = msg_send![cls.as_ptr(), alloc]; + let desc: *mut c_void = msg_send![desc, init]; + let desc_id = unsafe { Id::from_raw(desc as *mut _) }?; + + // MTLSamplerMinMagFilterLinear = 1 + let _: *mut c_void = msg_send![desc_id.as_ptr(), setMinFilter: 1u64]; + let _: *mut c_void = msg_send![desc_id.as_ptr(), setMagFilter: 1u64]; + + let sampler: *mut c_void = + msg_send![self.id.as_ptr(), newSamplerStateWithDescriptor: desc_id.as_ptr()]; + let id = unsafe { Id::from_raw(sampler as *mut _) }?; + Some(SamplerState { id }) + } } // --------------------------------------------------------------------------- @@ -168,6 +524,61 @@ pub struct RenderCommandEncoder { } impl RenderCommandEncoder { + /// Set the render pipeline state for subsequent draw calls. + pub fn set_render_pipeline_state(&self, pipeline: &RenderPipeline) { + let _: *mut c_void = + msg_send![self.id.as_ptr(), setRenderPipelineState: pipeline.id.as_ptr()]; + } + + /// Bind a vertex buffer at the given index. + pub fn set_vertex_buffer(&self, buffer: &Buffer, offset: u64, index: u64) { + let _: *mut c_void = msg_send![ + self.id.as_ptr(), + setVertexBuffer: buffer.id.as_ptr(), + offset: offset, + atIndex: index + ]; + } + + /// Pass uniform data directly to the vertex shader at the given index. + pub fn set_vertex_bytes(&self, data: &T, index: u64) { + let length = std::mem::size_of::() as u64; + let _: *mut c_void = msg_send![ + self.id.as_ptr(), + setVertexBytes: data as *const T as *mut c_void, + length: length, + atIndex: index + ]; + } + + /// Bind a texture to the fragment shader at the given index. + pub fn set_fragment_texture(&self, texture: &Texture, index: u64) { + let _: *mut c_void = msg_send![ + self.id.as_ptr(), + setFragmentTexture: texture.id.as_ptr(), + atIndex: index + ]; + } + + /// Bind a sampler state to the fragment shader at the given index. + pub fn set_fragment_sampler_state(&self, sampler: &SamplerState, index: u64) { + let _: *mut c_void = msg_send![ + self.id.as_ptr(), + setFragmentSamplerState: sampler.id.as_ptr(), + atIndex: index + ]; + } + + /// Issue a draw call for non-indexed primitives. + pub fn draw_primitives(&self, primitive_type: u64, vertex_start: u64, vertex_count: u64) { + let _: *mut c_void = msg_send![ + self.id.as_ptr(), + drawPrimitives: primitive_type, + vertexStart: vertex_start, + vertexCount: vertex_count + ]; + } + /// End encoding commands. pub fn end_encoding(&self) { let _: *mut c_void = msg_send![self.id.as_ptr(), endEncoding]; @@ -269,6 +680,144 @@ pub fn drawable_texture(drawable: Id) -> Option { unsafe { Id::from_raw(texture as *mut _) } } +// --------------------------------------------------------------------------- +// Render pipeline creation +// --------------------------------------------------------------------------- + +/// Create the 2D render pipeline state from compiled MSL shaders. +/// +/// Configures alpha blending: `source * sourceAlpha + dest * (1 - sourceAlpha)`. +fn create_render_pipeline(device: &Device) -> Option { + // Compile shader source + let library = device.new_library_with_source(SHADER_SOURCE)?; + + // Get vertex and fragment functions + let vert_name = CfString::new("vertex_main")?; + let frag_name = CfString::new("fragment_main")?; + + let vert_fn: *mut c_void = + msg_send![library.as_ptr(), newFunctionWithName: vert_name.as_void_ptr() as *mut c_void]; + let vert_fn_id = unsafe { Id::from_raw(vert_fn as *mut _) }?; + + let frag_fn: *mut c_void = + msg_send![library.as_ptr(), newFunctionWithName: frag_name.as_void_ptr() as *mut c_void]; + let frag_fn_id = unsafe { Id::from_raw(frag_fn as *mut _) }?; + + // Create pipeline descriptor + let cls = class!("MTLRenderPipelineDescriptor")?; + let desc: *mut c_void = msg_send![cls.as_ptr(), alloc]; + let desc: *mut c_void = msg_send![desc, init]; + let desc_id = unsafe { Id::from_raw(desc as *mut _) }?; + + // Set vertex and fragment functions + let _: *mut c_void = msg_send![desc_id.as_ptr(), setVertexFunction: vert_fn_id.as_ptr()]; + let _: *mut c_void = msg_send![desc_id.as_ptr(), setFragmentFunction: frag_fn_id.as_ptr()]; + + // Configure color attachment 0 + let attachments: *mut c_void = msg_send![desc_id.as_ptr(), colorAttachments]; + let attachment: *mut c_void = msg_send![attachments, objectAtIndexedSubscript: 0u64]; + + // Pixel format = BGRA8Unorm + let _: *mut c_void = msg_send![attachment, setPixelFormat: MTL_PIXEL_FORMAT_BGRA8_UNORM]; + + // Enable alpha blending + let _: *mut c_void = msg_send![attachment, setBlendingEnabled: true]; + + // source * sourceAlpha + dest * (1 - sourceAlpha) + let _: *mut c_void = + msg_send![attachment, setSourceRGBBlendFactor: MTL_BLEND_FACTOR_SOURCE_ALPHA]; + let _: *mut c_void = msg_send![ + attachment, + setDestinationRGBBlendFactor: MTL_BLEND_FACTOR_ONE_MINUS_SOURCE_ALPHA + ]; + let _: *mut c_void = + msg_send![attachment, setSourceAlphaBlendFactor: MTL_BLEND_FACTOR_SOURCE_ALPHA]; + let _: *mut c_void = msg_send![ + attachment, + setDestinationAlphaBlendFactor: MTL_BLEND_FACTOR_ONE_MINUS_SOURCE_ALPHA + ]; + + device.new_render_pipeline_state(desc_id) +} + +// --------------------------------------------------------------------------- +// MetalRenderer — batched 2D quad renderer +// --------------------------------------------------------------------------- + +/// A batched 2D renderer using the Metal GPU pipeline. +/// +/// Renders solid-color and textured quads via a single vertex buffer and +/// draw call per batch. Alpha blending is enabled for semi-transparent fills. +pub struct MetalRenderer { + device: Device, + pipeline: RenderPipeline, + dummy_texture: Texture, + sampler: SamplerState, +} + +impl MetalRenderer { + /// Create a new `MetalRenderer` on the given device. + /// + /// Compiles the MSL shaders, creates the pipeline state, and sets up a + /// dummy 1×1 white texture for solid-color rendering. + /// + /// Returns `None` if shader compilation or pipeline creation fails. + pub fn new(device: Device) -> Option { + let pipeline = create_render_pipeline(&device)?; + let dummy_texture = device.new_dummy_texture()?; + let sampler = device.new_sampler_state()?; + Some(MetalRenderer { + device, + pipeline, + dummy_texture, + sampler, + }) + } + + /// Build a vertex buffer from a batch of quads. + /// + /// Each quad is decomposed into 2 triangles (6 vertices). Returns the + /// buffer and the total vertex count. + pub fn build_vertex_buffer(&self, quads: &[Quad]) -> Option<(Buffer, usize)> { + let mut vertices = Vec::with_capacity(quads.len() * 6); + for quad in quads { + quad.triangulate(&mut vertices); + } + let count = vertices.len(); + let buffer = self.device.new_buffer_with_vertices(&vertices)?; + Some((buffer, count)) + } + + /// Encode draw commands for a batch of quads into a render command encoder. + /// + /// The encoder must already be created from a command buffer with an + /// appropriate render pass descriptor. + pub fn encode_draw( + &self, + encoder: &RenderCommandEncoder, + vertex_buffer: &Buffer, + vertex_count: usize, + viewport_width: f32, + viewport_height: f32, + ) { + let uniforms = Uniforms { + viewport_size: [viewport_width, viewport_height], + }; + + encoder.set_render_pipeline_state(&self.pipeline); + encoder.set_vertex_buffer(vertex_buffer, 0, 0); + encoder.set_vertex_bytes(&uniforms, 1); + encoder.set_fragment_texture(&self.dummy_texture, 0); + encoder.set_fragment_sampler_state(&self.sampler, 0); + encoder.draw_primitives(MTL_PRIMITIVE_TYPE_TRIANGLE, 0, vertex_count as u64); + } + + /// Return a reference to the underlying device. + pub fn device(&self) -> &Device { + &self.device + } +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -341,4 +890,256 @@ mod tests { layer.set_framebuffer_only(true); layer.set_drawable_size(800.0, 600.0); } + + // -- Vertex layout tests ------------------------------------------------ + + #[test] + fn vertex_size_and_alignment() { + // The Vertex struct must be 36 bytes to match the MSL packed layout. + assert_eq!(std::mem::size_of::(), 36); + assert_eq!(std::mem::align_of::(), 4); + } + + #[test] + fn uniforms_size() { + assert_eq!(std::mem::size_of::(), 8); + } + + // -- Quad triangulation tests ------------------------------------------- + + #[test] + fn quad_triangulate_produces_6_vertices() { + let quad = Quad { + x: 10.0, + y: 20.0, + width: 100.0, + height: 50.0, + color: [1.0, 0.0, 0.0, 1.0], + }; + let mut verts = Vec::new(); + quad.triangulate(&mut verts); + assert_eq!(verts.len(), 6); + } + + #[test] + fn quad_triangulate_covers_corners() { + let quad = Quad { + x: 0.0, + y: 0.0, + width: 100.0, + height: 50.0, + color: [1.0, 1.0, 1.0, 1.0], + }; + let mut verts = Vec::new(); + quad.triangulate(&mut verts); + + // Collect all unique positions + let positions: Vec<[f32; 2]> = verts.iter().map(|v| v.position).collect(); + + // Should have all 4 corners represented + assert!(positions.contains(&[0.0, 0.0])); + assert!(positions.contains(&[100.0, 0.0])); + assert!(positions.contains(&[0.0, 50.0])); + assert!(positions.contains(&[100.0, 50.0])); + } + + #[test] + fn quad_triangulate_solid_color() { + let quad = Quad { + x: 0.0, + y: 0.0, + width: 10.0, + height: 10.0, + color: [0.5, 0.3, 0.1, 0.8], + }; + let mut verts = Vec::new(); + quad.triangulate(&mut verts); + + for v in &verts { + assert_eq!(v.color, [0.5, 0.3, 0.1, 0.8]); + assert_eq!(v.use_texture, 0.0); + assert_eq!(v.tex_coord, [0.0, 0.0]); + } + } + + #[test] + fn multiple_quads_batch() { + let quads = vec![ + Quad { + x: 0.0, + y: 0.0, + width: 50.0, + height: 50.0, + color: [1.0, 0.0, 0.0, 1.0], + }, + Quad { + x: 60.0, + y: 0.0, + width: 50.0, + height: 50.0, + color: [0.0, 1.0, 0.0, 1.0], + }, + Quad { + x: 120.0, + y: 0.0, + width: 50.0, + height: 50.0, + color: [0.0, 0.0, 1.0, 1.0], + }, + ]; + let mut verts = Vec::new(); + for q in &quads { + q.triangulate(&mut verts); + } + assert_eq!(verts.len(), 18); // 3 quads × 6 vertices + + // First quad vertices should be red + for v in &verts[..6] { + assert_eq!(v.color, [1.0, 0.0, 0.0, 1.0]); + } + // Second quad vertices should be green + for v in &verts[6..12] { + assert_eq!(v.color, [0.0, 1.0, 0.0, 1.0]); + } + } + + // -- GPU tests (require Metal device) ----------------------------------- + + #[test] + fn compile_shader_source() { + let device = match Device::system_default() { + Some(d) => d, + None => return, + }; + let library = device.new_library_with_source(SHADER_SOURCE); + assert!( + library.is_some(), + "MSL shader should compile without errors" + ); + } + + #[test] + fn create_pipeline_state() { + let _pool = crate::appkit::AutoreleasePool::new(); + let device = match Device::system_default() { + Some(d) => d, + None => return, + }; + let pipeline = create_render_pipeline(&device); + assert!( + pipeline.is_some(), + "render pipeline state should be created" + ); + } + + #[test] + fn create_vertex_buffer() { + let device = match Device::system_default() { + Some(d) => d, + None => return, + }; + let quad = Quad { + x: 0.0, + y: 0.0, + width: 100.0, + height: 100.0, + color: [1.0, 0.0, 0.0, 1.0], + }; + let mut verts = Vec::new(); + quad.triangulate(&mut verts); + + let buffer = device.new_buffer_with_vertices(&verts); + assert!(buffer.is_some()); + let buffer = buffer.unwrap(); + assert_eq!(buffer.length(), 6 * std::mem::size_of::()); + } + + #[test] + fn create_dummy_texture() { + let _pool = crate::appkit::AutoreleasePool::new(); + let device = match Device::system_default() { + Some(d) => d, + None => return, + }; + let texture = device.new_dummy_texture(); + assert!(texture.is_some(), "dummy texture should be created"); + } + + #[test] + fn create_sampler_state() { + let _pool = crate::appkit::AutoreleasePool::new(); + let device = match Device::system_default() { + Some(d) => d, + None => return, + }; + let sampler = device.new_sampler_state(); + assert!(sampler.is_some(), "sampler state should be created"); + } + + #[test] + fn metal_renderer_new() { + let _pool = crate::appkit::AutoreleasePool::new(); + let device = match Device::system_default() { + Some(d) => d, + None => return, + }; + let renderer = MetalRenderer::new(device); + assert!(renderer.is_some(), "MetalRenderer should be created"); + } + + #[test] + fn metal_renderer_build_vertex_buffer() { + let _pool = crate::appkit::AutoreleasePool::new(); + let device = match Device::system_default() { + Some(d) => d, + None => return, + }; + let renderer = MetalRenderer::new(device).expect("renderer should be created"); + + let quads = vec![ + Quad { + x: 10.0, + y: 10.0, + width: 200.0, + height: 100.0, + color: [1.0, 0.0, 0.0, 1.0], + }, + Quad { + x: 50.0, + y: 50.0, + width: 100.0, + height: 100.0, + color: [0.0, 0.0, 1.0, 0.5], + }, + ]; + + let result = renderer.build_vertex_buffer(&quads); + assert!(result.is_some()); + let (buffer, count) = result.unwrap(); + assert_eq!(count, 12); // 2 quads × 6 vertices + assert_eq!(buffer.length(), 12 * std::mem::size_of::()); + } + + #[test] + fn empty_quad_list() { + let _pool = crate::appkit::AutoreleasePool::new(); + let device = match Device::system_default() { + Some(d) => d, + None => return, + }; + let renderer = MetalRenderer::new(device).expect("renderer should be created"); + let result = renderer.build_vertex_buffer(&[]); + assert!(result.is_none(), "empty quad list should return None"); + } + + #[test] + fn primitive_type_constant() { + assert_eq!(MTL_PRIMITIVE_TYPE_TRIANGLE, 3); + } + + #[test] + fn blend_factor_constants() { + assert_eq!(MTL_BLEND_FACTOR_SOURCE_ALPHA, 4); + assert_eq!(MTL_BLEND_FACTOR_ONE_MINUS_SOURCE_ALPHA, 5); + } }