we (web engine): Experimental web browser project to understand the limits of Claude
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

Implement Metal texture management and image upload

Add GPU texture creation for RGBA8 images and R8 grayscale glyph bitmaps,
nearest-neighbor sampler for pixel-accurate rendering, and TextureCache
to avoid redundant GPU uploads across frames.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+378 -1
+378 -1
crates/platform/src/metal.rs
··· 12 12 use crate::cf::CfString; 13 13 use crate::objc::Id; 14 14 use crate::{class, msg_send}; 15 + use std::collections::HashMap; 15 16 use std::os::raw::c_void; 16 17 17 18 // --------------------------------------------------------------------------- ··· 29 30 // --------------------------------------------------------------------------- 30 31 // Metal pixel format constants 31 32 // --------------------------------------------------------------------------- 33 + 34 + /// `MTLPixelFormatR8Unorm` — single-channel 8-bit, unsigned normalized. 35 + pub const MTL_PIXEL_FORMAT_R8_UNORM: u64 = 10; 36 + 37 + /// `MTLPixelFormatRGBA8Unorm` — 8-bit RGBA, unsigned normalized. 38 + pub const MTL_PIXEL_FORMAT_RGBA8_UNORM: u64 = 70; 32 39 33 40 /// `MTLPixelFormatBGRA8Unorm` — 8-bit BGRA, unsigned normalized. 34 41 pub const MTL_PIXEL_FORMAT_BGRA8_UNORM: u64 = 80; ··· 321 328 /// Wrapper around `id<MTLTexture>`. 322 329 pub struct Texture { 323 330 id: Id, 331 + width: u32, 332 + height: u32, 333 + } 334 + 335 + impl Texture { 336 + /// Return the underlying Objective-C object. 337 + pub fn id(&self) -> Id { 338 + self.id 339 + } 340 + 341 + /// Texture width in pixels. 342 + pub fn width(&self) -> u32 { 343 + self.width 344 + } 345 + 346 + /// Texture height in pixels. 347 + pub fn height(&self) -> u32 { 348 + self.height 349 + } 324 350 } 325 351 326 352 // --------------------------------------------------------------------------- ··· 446 472 bytesPerRow: 4u64 447 473 ]; 448 474 449 - Some(Texture { id: tex_id }) 475 + Some(Texture { 476 + id: tex_id, 477 + width: 1, 478 + height: 1, 479 + }) 480 + } 481 + 482 + /// Create a texture with the given pixel format and upload pixel data. 483 + /// 484 + /// `bytes_per_row` is the stride in bytes between consecutive rows. 485 + pub fn new_texture( 486 + &self, 487 + width: u32, 488 + height: u32, 489 + pixel_format: u64, 490 + data: &[u8], 491 + bytes_per_row: u32, 492 + ) -> Option<Texture> { 493 + if width == 0 || height == 0 || data.is_empty() { 494 + return None; 495 + } 496 + 497 + let cls = class!("MTLTextureDescriptor")?; 498 + let desc: *mut c_void = msg_send![ 499 + cls.as_ptr(), 500 + texture2DDescriptorWithPixelFormat: pixel_format, 501 + width: width as u64, 502 + height: height as u64, 503 + mipmapped: false 504 + ]; 505 + let desc_id = unsafe { Id::from_raw(desc as *mut _) }?; 506 + 507 + // Set usage to ShaderRead (1) 508 + let _: *mut c_void = msg_send![desc_id.as_ptr(), setUsage: 1u64]; 509 + 510 + let tex: *mut c_void = 511 + msg_send![self.id.as_ptr(), newTextureWithDescriptor: desc_id.as_ptr()]; 512 + let tex_id = unsafe { Id::from_raw(tex as *mut _) }?; 513 + 514 + let region = MtlRegion { 515 + origin: MtlOrigin { x: 0, y: 0, z: 0 }, 516 + size: MtlSize { 517 + width: width as u64, 518 + height: height as u64, 519 + depth: 1, 520 + }, 521 + }; 522 + let _: *mut c_void = msg_send![ 523 + tex_id.as_ptr(), 524 + replaceRegion: region, 525 + mipmapLevel: 0u64, 526 + withBytes: data.as_ptr() as *mut c_void, 527 + bytesPerRow: bytes_per_row as u64 528 + ]; 529 + 530 + Some(Texture { 531 + id: tex_id, 532 + width, 533 + height, 534 + }) 535 + } 536 + 537 + /// Create a texture from RGBA8 pixel data. 538 + pub fn new_texture_rgba8(&self, width: u32, height: u32, data: &[u8]) -> Option<Texture> { 539 + self.new_texture(width, height, MTL_PIXEL_FORMAT_RGBA8_UNORM, data, width * 4) 540 + } 541 + 542 + /// Create a texture from single-channel grayscale (R8) pixel data. 543 + pub fn new_texture_r8(&self, width: u32, height: u32, data: &[u8]) -> Option<Texture> { 544 + self.new_texture(width, height, MTL_PIXEL_FORMAT_R8_UNORM, data, width) 450 545 } 451 546 452 547 /// Create a sampler state with linear filtering. ··· 459 554 // MTLSamplerMinMagFilterLinear = 1 460 555 let _: *mut c_void = msg_send![desc_id.as_ptr(), setMinFilter: 1u64]; 461 556 let _: *mut c_void = msg_send![desc_id.as_ptr(), setMagFilter: 1u64]; 557 + 558 + let sampler: *mut c_void = 559 + msg_send![self.id.as_ptr(), newSamplerStateWithDescriptor: desc_id.as_ptr()]; 560 + let id = unsafe { Id::from_raw(sampler as *mut _) }?; 561 + Some(SamplerState { id }) 562 + } 563 + 564 + /// Create a sampler state with nearest-neighbor filtering and clamp-to-edge. 565 + pub fn new_sampler_state_nearest(&self) -> Option<SamplerState> { 566 + let cls = class!("MTLSamplerDescriptor")?; 567 + let desc: *mut c_void = msg_send![cls.as_ptr(), alloc]; 568 + let desc: *mut c_void = msg_send![desc, init]; 569 + let desc_id = unsafe { Id::from_raw(desc as *mut _) }?; 570 + 571 + // MTLSamplerMinMagFilterNearest = 0 572 + let _: *mut c_void = msg_send![desc_id.as_ptr(), setMinFilter: 0u64]; 573 + let _: *mut c_void = msg_send![desc_id.as_ptr(), setMagFilter: 0u64]; 574 + 575 + // MTLSamplerAddressModeClampToEdge = 0 (default, but explicit for clarity) 576 + let _: *mut c_void = msg_send![desc_id.as_ptr(), setSAddressMode: 0u64]; 577 + let _: *mut c_void = msg_send![desc_id.as_ptr(), setTAddressMode: 0u64]; 462 578 463 579 let sampler: *mut c_void = 464 580 msg_send![self.id.as_ptr(), newSamplerStateWithDescriptor: desc_id.as_ptr()]; ··· 819 935 } 820 936 821 937 // --------------------------------------------------------------------------- 938 + // TextureCache — avoid redundant GPU uploads 939 + // --------------------------------------------------------------------------- 940 + 941 + /// Cache of GPU textures keyed by node ID. 942 + /// 943 + /// Avoids re-uploading unchanged images to the GPU on every frame. 944 + /// Call [`remove`] or [`clear`] to release textures when nodes are removed. 945 + pub struct TextureCache { 946 + entries: HashMap<usize, Texture>, 947 + } 948 + 949 + impl Default for TextureCache { 950 + fn default() -> Self { 951 + Self::new() 952 + } 953 + } 954 + 955 + impl TextureCache { 956 + /// Create an empty texture cache. 957 + pub fn new() -> Self { 958 + TextureCache { 959 + entries: HashMap::new(), 960 + } 961 + } 962 + 963 + /// Look up a cached texture by node ID. 964 + pub fn get(&self, node_id: usize) -> Option<&Texture> { 965 + self.entries.get(&node_id) 966 + } 967 + 968 + /// Insert or replace a texture for the given node ID. 969 + pub fn insert(&mut self, node_id: usize, texture: Texture) { 970 + self.entries.insert(node_id, texture); 971 + } 972 + 973 + /// Remove a texture from the cache, returning it if present. 974 + pub fn remove(&mut self, node_id: usize) -> Option<Texture> { 975 + self.entries.remove(&node_id) 976 + } 977 + 978 + /// Remove all cached textures. 979 + pub fn clear(&mut self) { 980 + self.entries.clear(); 981 + } 982 + 983 + /// Return the number of cached textures. 984 + pub fn len(&self) -> usize { 985 + self.entries.len() 986 + } 987 + 988 + /// Return `true` if the cache is empty. 989 + pub fn is_empty(&self) -> bool { 990 + self.entries.is_empty() 991 + } 992 + 993 + /// Return `true` if the cache contains a texture for the given node ID. 994 + pub fn contains(&self, node_id: usize) -> bool { 995 + self.entries.contains_key(&node_id) 996 + } 997 + } 998 + 999 + // --------------------------------------------------------------------------- 822 1000 // Tests 823 1001 // --------------------------------------------------------------------------- 824 1002 ··· 1141 1319 fn blend_factor_constants() { 1142 1320 assert_eq!(MTL_BLEND_FACTOR_SOURCE_ALPHA, 4); 1143 1321 assert_eq!(MTL_BLEND_FACTOR_ONE_MINUS_SOURCE_ALPHA, 5); 1322 + } 1323 + 1324 + // -- Pixel format constants --------------------------------------------- 1325 + 1326 + #[test] 1327 + fn pixel_format_r8_unorm() { 1328 + assert_eq!(MTL_PIXEL_FORMAT_R8_UNORM, 10); 1329 + } 1330 + 1331 + #[test] 1332 + fn pixel_format_rgba8_unorm() { 1333 + assert_eq!(MTL_PIXEL_FORMAT_RGBA8_UNORM, 70); 1334 + } 1335 + 1336 + // -- Texture creation tests --------------------------------------------- 1337 + 1338 + #[test] 1339 + fn create_texture_rgba8() { 1340 + let _pool = crate::appkit::AutoreleasePool::new(); 1341 + let device = match Device::system_default() { 1342 + Some(d) => d, 1343 + None => return, 1344 + }; 1345 + // 2×2 red RGBA image 1346 + let data: Vec<u8> = vec![ 1347 + 255, 0, 0, 255, 0, 255, 0, 255, // row 0 1348 + 0, 0, 255, 255, 255, 255, 255, 255, // row 1 1349 + ]; 1350 + let texture = device.new_texture_rgba8(2, 2, &data); 1351 + assert!(texture.is_some(), "RGBA8 texture should be created"); 1352 + let texture = texture.unwrap(); 1353 + assert_eq!(texture.width(), 2); 1354 + assert_eq!(texture.height(), 2); 1355 + } 1356 + 1357 + #[test] 1358 + fn create_texture_r8() { 1359 + let _pool = crate::appkit::AutoreleasePool::new(); 1360 + let device = match Device::system_default() { 1361 + Some(d) => d, 1362 + None => return, 1363 + }; 1364 + // 4×4 grayscale glyph bitmap 1365 + let data: Vec<u8> = vec![ 1366 + 0, 64, 128, 255, 32, 96, 160, 224, 0, 0, 0, 0, 255, 255, 255, 255, 1367 + ]; 1368 + let texture = device.new_texture_r8(4, 4, &data); 1369 + assert!(texture.is_some(), "R8 texture should be created"); 1370 + let texture = texture.unwrap(); 1371 + assert_eq!(texture.width(), 4); 1372 + assert_eq!(texture.height(), 4); 1373 + } 1374 + 1375 + #[test] 1376 + fn create_texture_zero_dimension_returns_none() { 1377 + let _pool = crate::appkit::AutoreleasePool::new(); 1378 + let device = match Device::system_default() { 1379 + Some(d) => d, 1380 + None => return, 1381 + }; 1382 + assert!(device.new_texture_rgba8(0, 10, &[0; 40]).is_none()); 1383 + assert!(device.new_texture_rgba8(10, 0, &[0; 40]).is_none()); 1384 + assert!(device.new_texture_rgba8(10, 10, &[]).is_none()); 1385 + } 1386 + 1387 + #[test] 1388 + fn create_texture_large() { 1389 + let _pool = crate::appkit::AutoreleasePool::new(); 1390 + let device = match Device::system_default() { 1391 + Some(d) => d, 1392 + None => return, 1393 + }; 1394 + // 256×256 RGBA image 1395 + let data = vec![128u8; 256 * 256 * 4]; 1396 + let texture = device.new_texture_rgba8(256, 256, &data); 1397 + assert!(texture.is_some()); 1398 + let texture = texture.unwrap(); 1399 + assert_eq!(texture.width(), 256); 1400 + assert_eq!(texture.height(), 256); 1401 + } 1402 + 1403 + #[test] 1404 + fn dummy_texture_dimensions() { 1405 + let _pool = crate::appkit::AutoreleasePool::new(); 1406 + let device = match Device::system_default() { 1407 + Some(d) => d, 1408 + None => return, 1409 + }; 1410 + let texture = device.new_dummy_texture().unwrap(); 1411 + assert_eq!(texture.width(), 1); 1412 + assert_eq!(texture.height(), 1); 1413 + } 1414 + 1415 + // -- Sampler tests ------------------------------------------------------ 1416 + 1417 + #[test] 1418 + fn create_sampler_state_nearest() { 1419 + let _pool = crate::appkit::AutoreleasePool::new(); 1420 + let device = match Device::system_default() { 1421 + Some(d) => d, 1422 + None => return, 1423 + }; 1424 + let sampler = device.new_sampler_state_nearest(); 1425 + assert!( 1426 + sampler.is_some(), 1427 + "nearest-neighbor sampler should be created" 1428 + ); 1429 + } 1430 + 1431 + // -- TextureCache tests ------------------------------------------------- 1432 + 1433 + #[test] 1434 + fn texture_cache_new_is_empty() { 1435 + let cache = TextureCache::new(); 1436 + assert!(cache.is_empty()); 1437 + assert_eq!(cache.len(), 0); 1438 + } 1439 + 1440 + #[test] 1441 + fn texture_cache_insert_and_get() { 1442 + let _pool = crate::appkit::AutoreleasePool::new(); 1443 + let device = match Device::system_default() { 1444 + Some(d) => d, 1445 + None => return, 1446 + }; 1447 + let mut cache = TextureCache::new(); 1448 + let data = vec![255u8; 4 * 4]; // 2×2 RGBA 1449 + let texture = device.new_texture_rgba8(2, 2, &data[..16]).unwrap(); 1450 + 1451 + cache.insert(42, texture); 1452 + assert_eq!(cache.len(), 1); 1453 + assert!(cache.contains(42)); 1454 + assert!(!cache.contains(99)); 1455 + 1456 + let cached = cache.get(42); 1457 + assert!(cached.is_some()); 1458 + assert_eq!(cached.unwrap().width(), 2); 1459 + } 1460 + 1461 + #[test] 1462 + fn texture_cache_remove() { 1463 + let _pool = crate::appkit::AutoreleasePool::new(); 1464 + let device = match Device::system_default() { 1465 + Some(d) => d, 1466 + None => return, 1467 + }; 1468 + let mut cache = TextureCache::new(); 1469 + let data = vec![255u8; 16]; 1470 + let texture = device.new_texture_rgba8(2, 2, &data).unwrap(); 1471 + 1472 + cache.insert(1, texture); 1473 + assert_eq!(cache.len(), 1); 1474 + 1475 + let removed = cache.remove(1); 1476 + assert!(removed.is_some()); 1477 + assert!(cache.is_empty()); 1478 + 1479 + assert!(cache.remove(1).is_none()); 1480 + } 1481 + 1482 + #[test] 1483 + fn texture_cache_clear() { 1484 + let _pool = crate::appkit::AutoreleasePool::new(); 1485 + let device = match Device::system_default() { 1486 + Some(d) => d, 1487 + None => return, 1488 + }; 1489 + let mut cache = TextureCache::new(); 1490 + let data = vec![255u8; 16]; 1491 + 1492 + for i in 0..5 { 1493 + let texture = device.new_texture_rgba8(2, 2, &data).unwrap(); 1494 + cache.insert(i, texture); 1495 + } 1496 + assert_eq!(cache.len(), 5); 1497 + 1498 + cache.clear(); 1499 + assert!(cache.is_empty()); 1500 + } 1501 + 1502 + #[test] 1503 + fn texture_cache_replace() { 1504 + let _pool = crate::appkit::AutoreleasePool::new(); 1505 + let device = match Device::system_default() { 1506 + Some(d) => d, 1507 + None => return, 1508 + }; 1509 + let mut cache = TextureCache::new(); 1510 + 1511 + let data_small = vec![255u8; 4]; // 1×1 1512 + let tex1 = device.new_texture_rgba8(1, 1, &data_small).unwrap(); 1513 + cache.insert(7, tex1); 1514 + assert_eq!(cache.get(7).unwrap().width(), 1); 1515 + 1516 + let data_big = vec![255u8; 16]; // 2×2 1517 + let tex2 = device.new_texture_rgba8(2, 2, &data_big).unwrap(); 1518 + cache.insert(7, tex2); 1519 + assert_eq!(cache.len(), 1); 1520 + assert_eq!(cache.get(7).unwrap().width(), 2); 1144 1521 } 1145 1522 }