Repository navigation
feat(sokol): Metal/IOSurface producer for macOS (#125) - #128
Conversation
PR SummaryMedium Risk Overview Updates the sokol backend to expose Reviewed by Cursor Bugbot for commit 4584c6a. Bugbot is set up for automated code reviews on this repo. Configure here. |
There was a problem hiding this comment.
Code Review
This pull request introduces a Metal/IOSurface readback path for the Sokol backend on macOS and iOS, facilitating frame capture on Darwin platforms. It implements Objective-C runtime bridging to blit swapchain textures to staging textures and read pixels back to the CPU, alongside updates to the build system for framework linking and new lifecycle hooks in the code generation templates. A review comment identifies an improvement opportunity to reset module-scope state variables during cleanup to prevent potential issues during application re-initialization.
| \\ if (_preview_mtl_pixel_buf.len != 0) { | ||
| \\ _preview_allocator.free(_preview_mtl_pixel_buf); | ||
| \\ _preview_mtl_pixel_buf = &[_]u8{}; | ||
| \\ } | ||
| \\ } |
There was a problem hiding this comment.
The cleanup block should reset the module-scope state variables (_preview_mtl_last_w, _preview_mtl_last_h, and _preview_mtl_initialized) to their initial values. If the application is re-initialized within the same process (e.g., during tests), failing to reset these will cause the resize logic to be skipped, leading to null pointer dereferences when attempting to use the command queue or staging texture.
if (_preview_mtl_pixel_buf.len != 0) {
_preview_allocator.free(_preview_mtl_pixel_buf);
_preview_mtl_pixel_buf = &[_]u8{};
}
_preview_mtl_last_w = 0;
_preview_mtl_last_h = 0;
_preview_mtl_initialized = false;
Slice 2 of the sokol PIE viewport work (after #124's GL slice). Emits a Metal/IOSurface readback path alongside the GL block — mutually exclusive gates (_sokol_preview_gl_enabled = .linux, _sokol_preview_metal_enabled = .macos/.ios) keep exactly one path active per target, with zero linker symbols from the other. The Metal path: - libobjc msgSend bridging (selectors + typed @extern wrappers cached in a comptime-gated struct namespace so non-Darwin builds carry no objc references) - blit copyFromTexture from the swapchain drawable to a managed-storage MTLTexture, synchronizeResource (macOS only — iOS shared storage no-ops), waitUntilCompleted, getBytes - BGRA8 -> RGBA8 swizzle, then Preview.publishFrameIOSurface (labelle-engine#547) which swizzles back into the IOSurface ring - placed AFTER window.endFrame() (post sg.commit) so the swapchain texture has the rendered contents — GL block stays pre-endFrame - new {{preview_readback_post}} template slot in both desktop.txt and mobile.txt - IOSurface + CoreFoundation frameworks added to the sokol exe link line on macos/ios Engine API: sufficient as-is for Path B; the double-swizzle (producer BGRA->RGBA, engine RGBA->BGRA) is a known wart that a future publishFrameIOSurfaceBgra would eliminate. Stub: window.metalCurrentDrawable currently returns null. The pinned sokol-zig replaced the legacy sapp_metal_get_current_drawable accessor with a struct-returning sapp_get_swapchain() that calls [CAMetalLayer nextDrawable] on every call — invoking it from our readback would acquire a fresh empty drawable, not the one sokol rendered into. All wiring is in place; flipping the stub once the upstream API gap is resolved is a single-line change. Readback no-ops cleanly until then. Tests: - new "sokol emits Metal/IOSurface readback block alongside GL block": asserts the gate, namespace, selectors, IOSurface lifecycle, source- order (begin before publish, readback after endFrame, cleanup before bye), AST parse cleanliness - extended sokol GL test to keep passing unchanged - extended raylib + non-sokol leak-through tests to also lock out the Metal block Verified locally: zig build + zig build test --summary all (300/304 pass, 4 pre-existing skips). zig build of the regenerated examples/sokol/.labelle/sokol_desktop on macOS arm64 links cleanly against IOSurface, CoreFoundation, Metal, AppKit, libobjc. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…able The upstream sokol-zig (floooh/sokol-zig#154) doesn't expose sapp_metal_get_current_drawable in its current shape — its sapp_get_swapchain() acquires a fresh [CAMetalLayer nextDrawable] on every call, so re-calling it post-render hands us an empty drawable rather than the one sokol-gfx just rendered into. Forked the repo as labelle-toolkit/sokol-zig and added the accessor on branch `feat/expose-cached-metal-drawable` (commit 92cca34): it caches the drawable acquired by _sapp_macos_mtl_swapchain_next() (and the iOS equivalent) in a new _sapp.macos.mtl.cur_drawable field and re-exposes it via the public sapp_metal_get_current_drawable() C accessor, with a matching Zig binding `sapp.metalGetCurrentDrawable()`. This PR pins backends/sokol to the fork branch and wires the previously-stubbed metalCurrentDrawable() in window.zig to call through. Verified by linking the generated examples/sokol target and confirming _sapp_metal_get_current_drawable resolves in the output binary — the Metal/IOSurface readback path can now actually run instead of no-op'ing behind the null-drawable gate. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
8db008c to
4584c6a
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 4584c6a. Configure here.
| \\ _SokolPreviewMetal.release(old); | ||
| \\ _preview_mtl_staging_texture = null; | ||
| \\ } | ||
| \\ _preview_mtl_staging_texture = _SokolPreviewMetal.createStagingTexture(_device, _sw, _sh); |
There was a problem hiding this comment.
Staging texture released before replacement is validated
Low Severity
In the Metal resize path, the old staging texture is unconditionally released before the replacement is confirmed valid. If createStagingTexture returns null, the old texture is already freed and _preview_mtl_staging_texture is null. Because the failure prevents _preview_mtl_last_w/h from updating, the resize block retries correctly on subsequent frames at the new size — but if the window returns to its original dimensions, dims match the stale _preview_mtl_last_w/h, the resize block is never re-entered, and readback is permanently broken (staging texture is null, pixel buffer may be empty). The GL path avoids this because it reuses PBOs in place via bufferData rather than freeing GPU resources before replacement.
Reviewed by Cursor Bugbot for commit 4584c6a. Configure here.
| // Metal/IOSurface readback path below. Upstream tracking: | ||
| // floooh/sokol-zig#154. Bumping requires `zig fetch <url>`. | ||
| .url = "https://github.com/labelle-toolkit/sokol-zig/archive/refs/heads/feat/expose-cached-metal-drawable.tar.gz", | ||
| .hash = "sokol-0.1.0-pb1HK0lpNwDe6o44qZwZ9THFraqfm68MKix7xr00z7-U", |
There was a problem hiding this comment.
Dependency URL uses branch ref instead of commit
Low Severity
The sokol dependency URL was changed from a pinned commit hash to a branch reference (refs/heads/feat/expose-cached-metal-drawable). While the .hash field ensures reproducibility for existing builds, the branch head can change if the fork is rebased or force-pushed, causing a hash mismatch and a confusing build failure for anyone running zig fetch. The previous dependency used a stable commit-SHA URL — this regresses that pattern.
Reviewed by Cursor Bugbot for commit 4584c6a. Configure here.
Minor release shipping the full sokol PIE viewport producer matrix plus raylib's macOS zero-copy iosurface mode: - #121 — raylib: prefer IOSurface mode on macOS. Comptime platform switch in `PREVIEW_READBACK_SETUP`/`_LOOP` selects `beginFrameStreamIOSurface`/`publishFrameIOSurface`/`endFrameStreamIOSurface` on `os.tag == .macos`, falls back to SHM mode elsewhere. - #122 / #124 — sokol slice 1: GLCORE/GLES3 PBO readback for Linux desktop + Android. 3-deep PBO ring with 2-frame priming, gated on `_sokol_preview_gl_enabled = (os.tag == .linux)`. - #122 / #127 — sokol slice 3: D3D11 staging-texture readback for Windows. COM-vtable dispatch on `IDXGISwapChain::GetBuffer` + 3- buffer staging ring + BGRA→RGBA swizzle. Gated on `_sokol_preview_d3d11_enabled = (os.tag == .windows)`. - #122 / #128 — sokol slice 2: Metal/IOSurface zero-copy producer for macOS + iOS. Path B (MTLBlitCommandEncoder copy from swapchain to IOSurface-backed MTLTexture). Pinned to the labelle-toolkit/sokol-zig fork (`feat/expose-cached-metal-drawable` branch) which restores the `sapp_metal_get_current_drawable` accessor — upstream tracking at floooh/sokol-zig#154. - #549 — preview_mode: extract `allocShmName` helper (refactor). The three sokol slices coexist via mutually-exclusive comptime gates; the editor (labelle-gui v0.3.0) dispatches consumer mode based on `frame_offer.format` ("bgra8" → SHM, "iosurface_bgra8" → zero-copy). Paired downstream: labelle-engine v1.37.3 (Preview frame-stream APIs) and labelle-gui v0.3.0 (iosurface consumer + FBO blit). Out of scope (filed as follow-ups): - engine#551 — Windows port of preview_mode (POSIX TCP + shm). - engine#552 — `publishFrameIOSurfaceBgra` variant to skip the producer↔engine double-swizzle. - floooh/sokol-zig#154 — restore the upstream cached-drawable accessor. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…et (#132) Replaces the post-frame `MTLBlitCommandEncoder` blit pipeline shipped in #128 (Path B) with floooh's recommended shape from floooh/sokol#1510: own the render target via IOSurface-backed `MTLTexture`s, signal each frame via `Preview.signalSlotReady` — no drawable plumbing, no blit chain, no post-frame readback. Path B was permanently gated behind a stub because sokol-zig's `sapp_get_swapchain()` returns the *next* drawable each call, and Apple recycles drawables to the pool after present — by the time our readback fired, the drawable's `texture` was either invalid or owned by a different frame. The `metalCurrentDrawable` wrapper + sokol-zig fork accessor are gone from the generated source. What lands runtime-wise: - IOSurface ring allocation via `Preview.beginFrameStreamIOSurface` - Per-surface MTLTexture wrap via `[device newTextureWithDescriptor:iosurface:plane:]` - Parallel sokol-gfx `Image` ring with `mtl_textures[]` injection and `color_attachment = true` for the eventual render-redirect - Per-frame `signalSlotReady(slot)` (uses labelle-engine#553 API) Known gap (TODO): redirecting the game's render pass into the offscreen sg_image is a research project of its own — needs either a gfx.setEditorRenderTarget shim or a separate render path for editor mode. Today the IOSurface contents are whatever IOSurfaceCreate left there (zero-filled in practice). The editor sees a black Game View but the SHM handshake + frame_published cadence work end-to-end. The sokol-zig fork (`labelle-toolkit/sokol-zig` branch `feat/expose-cached-metal-drawable`) is now vestigial — un-pinning is a separate cleanup step per #131. Refs: labelle-assembler#131, labelle-engine#553, floooh/sokol#1510 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
#134) The fork's `feat/expose-cached-metal-drawable` branch restored a `sapp_metal_get_current_drawable` accessor for the early Path B Metal/IOSurface readback experiment (#128). Path A (#132 / v0.22.0) renders directly into IOSurfaces via offscreen render targets and never reaches for the post-frame drawable, so the fork patch is vestigial. Re-pin to floooh/sokol-zig@8b1a651 (current upstream master, the exact commit the fork was branched from). See floooh/sokol#1510 (closed) for the upstream rationale. `zig build test`: 16/16 steps green, 305/309 pass + 4 skipped. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>


Summary
Slice 2 of the sokol PIE viewport work (after #124's GL slice). Closes #125. Emits a Metal/IOSurface readback path alongside the GL block, sharing the existing module-scope state and
Previewlifecycle while being gated by a mutually exclusive_sokol_preview_metal_enabledcomptime flag (.macos/.iosonly).Path A vs Path B — which and why
Path B (Metal blit + CPU copy + engine's existing
publishFrameIOSurface(rgba)). Path A (render-to-IOSurface as swapchain override) was deferred for the reasons the ticket called out:IOSurfaceRefs per ring slot so we can bind them asMTLTexturerender targets via[MTLDevice newTextureWithDescriptor:iosurface:plane:]. That's a real engine API extension, not a wiring detail.waitUntilCompleted.The implementation:
@externobjc_msgSendwrappers cached in a comptime-gated_SokolPreviewMetalstruct namespace. Non-Darwin builds carry zero objc references at link time.copyFromTexture:from the swapchain drawable → managed-storage stagingMTLTexture,synchronizeResource:(macOS only — iOS shared storage no-ops),waitUntilCompleted,getBytes:.Preview.publishFrameIOSurface(labelle-engine#547) which swizzles back into the IOSurface ring.window.endFrame()(postsg.commit()) so the swapchain texture is in a readable state — GL stays pre-endFrame()(different sokol-gfx semantics). A new{{preview_readback_post}}template slot indesktop.txt+mobile.txtcarries the post-commit block.Engine-API gaps encountered
None blocking. The engine's
Preview.beginFrameStreamIOSurface/publishFrameIOSurface(rgba)/endFrameStreamIOSurfacetriple is sufficient for Path B as-shipped.A non-blocking nice-to-have is filed for follow-up:
Preview.publishFrameIOSurfaceBgra(bgra)— accept BGRA8 directly, no producer-side swizzle. Metal swapchains are BGRA8 natively; the existing API forces a double-swizzle.Stub flagged for upstream sokol
window.metalCurrentDrawablecurrently returnsnullwith a documented limitation:The pinned
sokol-zig(pb1HKxTaNgA8rXBh41wxEucgmR4YPuoOXGeXbFyGPvTK) has replaced the legacysapp_metal_get_current_drawableaccessor (cached frame drawable) with a struct-returningsapp_get_swapchain()whose macOS implementation calls[CAMetalLayer nextDrawable]on every call. Invoking it from our readback would acquire a fresh empty drawable, not the one sokol-gfx just rendered into viasglue_swapchain()→ defeating the entire pipeline.The fix lives upstream: expose the currently-acquired drawable from sokol-gfx's per-frame cache (the equivalent of the legacy accessor). Until that lands, all the wiring is in place and the entire blit chain gates on this returning non-null — so today the macOS readback cleanly no-ops (control plane still emits hello/heartbeat/bye via the existing
Previewtriple). Flipping the stub once upstream resolves the gap is a single-line change.Test additions
sokol emits Metal/IOSurface readback block alongside GL block (slice 2 / #125)— new dedicated test asserting:_sokol_preview_metal_enabledgate +.macos, .ios => trueswitch_SokolPreviewMetalnamespace + selectors + msgSend clusterMTLPixelFormatBGRA8Unorm,blitCommandEncoder,copyFromTexture:sourceSlice:sourceLevel:,getBytes:bytesPerRow:fromRegion:mipmapLevel:window.metalDevice()/window.metalCurrentDrawable()wrappers used (legacy C symbols not referenced)_preview_mtl_*vs_preview_*)beginFrameStreamIOSurface→publishFrameIOSurface→endFrameStreamIOSurface)beginFrameStreamIOSurfacebefore firstpublishFrameIOSurface, Metal readback afterwindow.endFrame(),endFrameStreamIOSurfacebeforesendBye(.normal)raylib desktop still emits its own PBO readbackandnon-loop, non-sokol backends do not pull in any preview pixel publish pathto also lock out_sokol_preview_metal_enabled,_SokolPreviewMetal,_p.publishFrameIOSurface(, andwindow.metalDevice()from those code paths.What I verified locally
zig build(Zig 0.16) — clean.zig build test --summary all— 300/304 tests pass, 4 pre-existing skips (none touched by this PR).examples/sokol/.labelle/sokol_desktop/main.zigvia the assembler binary and ranzig buildin that directory on macOS arm64 — links cleanly against IOSurface, CoreFoundation, Metal, AppKit, libobjc.otool -Lon the resulting binary confirms framework deps land as expected.Test plan
zig build testpasses locallywindow.metalCurrentDrawableto return the cached drawable once upstream sokol surfaces it.Out of scope
publishFrameIOSurfaceBgraengine API addition (filed as engine follow-up)metalCurrentDrawableupstream sokol API gap🤖 Generated with Claude Code