diff --git a/crates/platform/src/metal.rs b/crates/platform/src/metal.rs index 848c490..59deaf5 100644 --- a/crates/platform/src/metal.rs +++ b/crates/platform/src/metal.rs @@ -12,6 +12,7 @@ use crate::cf::CfString; use crate::objc::Id; use crate::{class, msg_send}; +use std::collections::HashMap; use std::os::raw::c_void; // --------------------------------------------------------------------------- @@ -30,6 +31,12 @@ extern "C" {} // Metal pixel format constants // --------------------------------------------------------------------------- +/// `MTLPixelFormatR8Unorm` — single-channel 8-bit, unsigned normalized. +pub const MTL_PIXEL_FORMAT_R8_UNORM: u64 = 10; + +/// `MTLPixelFormatRGBA8Unorm` — 8-bit RGBA, unsigned normalized. +pub const MTL_PIXEL_FORMAT_RGBA8_UNORM: u64 = 70; + /// `MTLPixelFormatBGRA8Unorm` — 8-bit BGRA, unsigned normalized. pub const MTL_PIXEL_FORMAT_BGRA8_UNORM: u64 = 80; @@ -321,6 +328,25 @@ pub struct RenderPipeline { /// Wrapper around `id`. pub struct Texture { id: Id, + width: u32, + height: u32, +} + +impl Texture { + /// Return the underlying Objective-C object. + pub fn id(&self) -> Id { + self.id + } + + /// Texture width in pixels. + pub fn width(&self) -> u32 { + self.width + } + + /// Texture height in pixels. + pub fn height(&self) -> u32 { + self.height + } } // --------------------------------------------------------------------------- @@ -446,7 +472,76 @@ impl Device { bytesPerRow: 4u64 ]; - Some(Texture { id: tex_id }) + Some(Texture { + id: tex_id, + width: 1, + height: 1, + }) + } + + /// Create a texture with the given pixel format and upload pixel data. + /// + /// `bytes_per_row` is the stride in bytes between consecutive rows. + pub fn new_texture( + &self, + width: u32, + height: u32, + pixel_format: u64, + data: &[u8], + bytes_per_row: u32, + ) -> Option { + if width == 0 || height == 0 || data.is_empty() { + return None; + } + + let cls = class!("MTLTextureDescriptor")?; + let desc: *mut c_void = msg_send![ + cls.as_ptr(), + texture2DDescriptorWithPixelFormat: pixel_format, + width: width as u64, + height: height as u64, + 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]; + + 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 _) }?; + + let region = MtlRegion { + origin: MtlOrigin { x: 0, y: 0, z: 0 }, + size: MtlSize { + width: width as u64, + height: height as u64, + depth: 1, + }, + }; + let _: *mut c_void = msg_send![ + tex_id.as_ptr(), + replaceRegion: region, + mipmapLevel: 0u64, + withBytes: data.as_ptr() as *mut c_void, + bytesPerRow: bytes_per_row as u64 + ]; + + Some(Texture { + id: tex_id, + width, + height, + }) + } + + /// Create a texture from RGBA8 pixel data. + pub fn new_texture_rgba8(&self, width: u32, height: u32, data: &[u8]) -> Option { + self.new_texture(width, height, MTL_PIXEL_FORMAT_RGBA8_UNORM, data, width * 4) + } + + /// Create a texture from single-channel grayscale (R8) pixel data. + pub fn new_texture_r8(&self, width: u32, height: u32, data: &[u8]) -> Option { + self.new_texture(width, height, MTL_PIXEL_FORMAT_R8_UNORM, data, width) } /// Create a sampler state with linear filtering. @@ -465,6 +560,27 @@ impl Device { let id = unsafe { Id::from_raw(sampler as *mut _) }?; Some(SamplerState { id }) } + + /// Create a sampler state with nearest-neighbor filtering and clamp-to-edge. + pub fn new_sampler_state_nearest(&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 _) }?; + + // MTLSamplerMinMagFilterNearest = 0 + let _: *mut c_void = msg_send![desc_id.as_ptr(), setMinFilter: 0u64]; + let _: *mut c_void = msg_send![desc_id.as_ptr(), setMagFilter: 0u64]; + + // MTLSamplerAddressModeClampToEdge = 0 (default, but explicit for clarity) + let _: *mut c_void = msg_send![desc_id.as_ptr(), setSAddressMode: 0u64]; + let _: *mut c_void = msg_send![desc_id.as_ptr(), setTAddressMode: 0u64]; + + 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 }) + } } // --------------------------------------------------------------------------- @@ -818,6 +934,68 @@ impl MetalRenderer { } } +// --------------------------------------------------------------------------- +// TextureCache — avoid redundant GPU uploads +// --------------------------------------------------------------------------- + +/// Cache of GPU textures keyed by node ID. +/// +/// Avoids re-uploading unchanged images to the GPU on every frame. +/// Call [`remove`] or [`clear`] to release textures when nodes are removed. +pub struct TextureCache { + entries: HashMap, +} + +impl Default for TextureCache { + fn default() -> Self { + Self::new() + } +} + +impl TextureCache { + /// Create an empty texture cache. + pub fn new() -> Self { + TextureCache { + entries: HashMap::new(), + } + } + + /// Look up a cached texture by node ID. + pub fn get(&self, node_id: usize) -> Option<&Texture> { + self.entries.get(&node_id) + } + + /// Insert or replace a texture for the given node ID. + pub fn insert(&mut self, node_id: usize, texture: Texture) { + self.entries.insert(node_id, texture); + } + + /// Remove a texture from the cache, returning it if present. + pub fn remove(&mut self, node_id: usize) -> Option { + self.entries.remove(&node_id) + } + + /// Remove all cached textures. + pub fn clear(&mut self) { + self.entries.clear(); + } + + /// Return the number of cached textures. + pub fn len(&self) -> usize { + self.entries.len() + } + + /// Return `true` if the cache is empty. + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// Return `true` if the cache contains a texture for the given node ID. + pub fn contains(&self, node_id: usize) -> bool { + self.entries.contains_key(&node_id) + } +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -1142,4 +1320,203 @@ mod tests { assert_eq!(MTL_BLEND_FACTOR_SOURCE_ALPHA, 4); assert_eq!(MTL_BLEND_FACTOR_ONE_MINUS_SOURCE_ALPHA, 5); } + + // -- Pixel format constants --------------------------------------------- + + #[test] + fn pixel_format_r8_unorm() { + assert_eq!(MTL_PIXEL_FORMAT_R8_UNORM, 10); + } + + #[test] + fn pixel_format_rgba8_unorm() { + assert_eq!(MTL_PIXEL_FORMAT_RGBA8_UNORM, 70); + } + + // -- Texture creation tests --------------------------------------------- + + #[test] + fn create_texture_rgba8() { + let _pool = crate::appkit::AutoreleasePool::new(); + let device = match Device::system_default() { + Some(d) => d, + None => return, + }; + // 2×2 red RGBA image + let data: Vec = vec![ + 255, 0, 0, 255, 0, 255, 0, 255, // row 0 + 0, 0, 255, 255, 255, 255, 255, 255, // row 1 + ]; + let texture = device.new_texture_rgba8(2, 2, &data); + assert!(texture.is_some(), "RGBA8 texture should be created"); + let texture = texture.unwrap(); + assert_eq!(texture.width(), 2); + assert_eq!(texture.height(), 2); + } + + #[test] + fn create_texture_r8() { + let _pool = crate::appkit::AutoreleasePool::new(); + let device = match Device::system_default() { + Some(d) => d, + None => return, + }; + // 4×4 grayscale glyph bitmap + let data: Vec = vec![ + 0, 64, 128, 255, 32, 96, 160, 224, 0, 0, 0, 0, 255, 255, 255, 255, + ]; + let texture = device.new_texture_r8(4, 4, &data); + assert!(texture.is_some(), "R8 texture should be created"); + let texture = texture.unwrap(); + assert_eq!(texture.width(), 4); + assert_eq!(texture.height(), 4); + } + + #[test] + fn create_texture_zero_dimension_returns_none() { + let _pool = crate::appkit::AutoreleasePool::new(); + let device = match Device::system_default() { + Some(d) => d, + None => return, + }; + assert!(device.new_texture_rgba8(0, 10, &[0; 40]).is_none()); + assert!(device.new_texture_rgba8(10, 0, &[0; 40]).is_none()); + assert!(device.new_texture_rgba8(10, 10, &[]).is_none()); + } + + #[test] + fn create_texture_large() { + let _pool = crate::appkit::AutoreleasePool::new(); + let device = match Device::system_default() { + Some(d) => d, + None => return, + }; + // 256×256 RGBA image + let data = vec![128u8; 256 * 256 * 4]; + let texture = device.new_texture_rgba8(256, 256, &data); + assert!(texture.is_some()); + let texture = texture.unwrap(); + assert_eq!(texture.width(), 256); + assert_eq!(texture.height(), 256); + } + + #[test] + fn dummy_texture_dimensions() { + let _pool = crate::appkit::AutoreleasePool::new(); + let device = match Device::system_default() { + Some(d) => d, + None => return, + }; + let texture = device.new_dummy_texture().unwrap(); + assert_eq!(texture.width(), 1); + assert_eq!(texture.height(), 1); + } + + // -- Sampler tests ------------------------------------------------------ + + #[test] + fn create_sampler_state_nearest() { + let _pool = crate::appkit::AutoreleasePool::new(); + let device = match Device::system_default() { + Some(d) => d, + None => return, + }; + let sampler = device.new_sampler_state_nearest(); + assert!( + sampler.is_some(), + "nearest-neighbor sampler should be created" + ); + } + + // -- TextureCache tests ------------------------------------------------- + + #[test] + fn texture_cache_new_is_empty() { + let cache = TextureCache::new(); + assert!(cache.is_empty()); + assert_eq!(cache.len(), 0); + } + + #[test] + fn texture_cache_insert_and_get() { + let _pool = crate::appkit::AutoreleasePool::new(); + let device = match Device::system_default() { + Some(d) => d, + None => return, + }; + let mut cache = TextureCache::new(); + let data = vec![255u8; 4 * 4]; // 2×2 RGBA + let texture = device.new_texture_rgba8(2, 2, &data[..16]).unwrap(); + + cache.insert(42, texture); + assert_eq!(cache.len(), 1); + assert!(cache.contains(42)); + assert!(!cache.contains(99)); + + let cached = cache.get(42); + assert!(cached.is_some()); + assert_eq!(cached.unwrap().width(), 2); + } + + #[test] + fn texture_cache_remove() { + let _pool = crate::appkit::AutoreleasePool::new(); + let device = match Device::system_default() { + Some(d) => d, + None => return, + }; + let mut cache = TextureCache::new(); + let data = vec![255u8; 16]; + let texture = device.new_texture_rgba8(2, 2, &data).unwrap(); + + cache.insert(1, texture); + assert_eq!(cache.len(), 1); + + let removed = cache.remove(1); + assert!(removed.is_some()); + assert!(cache.is_empty()); + + assert!(cache.remove(1).is_none()); + } + + #[test] + fn texture_cache_clear() { + let _pool = crate::appkit::AutoreleasePool::new(); + let device = match Device::system_default() { + Some(d) => d, + None => return, + }; + let mut cache = TextureCache::new(); + let data = vec![255u8; 16]; + + for i in 0..5 { + let texture = device.new_texture_rgba8(2, 2, &data).unwrap(); + cache.insert(i, texture); + } + assert_eq!(cache.len(), 5); + + cache.clear(); + assert!(cache.is_empty()); + } + + #[test] + fn texture_cache_replace() { + let _pool = crate::appkit::AutoreleasePool::new(); + let device = match Device::system_default() { + Some(d) => d, + None => return, + }; + let mut cache = TextureCache::new(); + + let data_small = vec![255u8; 4]; // 1×1 + let tex1 = device.new_texture_rgba8(1, 1, &data_small).unwrap(); + cache.insert(7, tex1); + assert_eq!(cache.get(7).unwrap().width(), 1); + + let data_big = vec![255u8; 16]; // 2×2 + let tex2 = device.new_texture_rgba8(2, 2, &data_big).unwrap(); + cache.insert(7, tex2); + assert_eq!(cache.len(), 1); + assert_eq!(cache.get(7).unwrap().width(), 2); + } }