Skip to content

feat(sokol): Metal/IOSurface producer for macOS (#125) - #128

Merged
apotema merged 2 commits into
mainfrom
feat/sokol-metal-iosurface-125
May 16, 2026
Merged

apotema merged 2 commits into
mainfrom
feat/sokol-metal-iosurface-125

Conversation

@apotema

@apotema apotema commented May 16, 2026

Copy link
Copy Markdown
Contributor

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 Preview lifecycle while being gated by a mutually exclusive _sokol_preview_metal_enabled comptime flag (.macos/.ios only).

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:

  • Path A needs the engine to expose IOSurfaceRefs per ring slot so we can bind them as MTLTexture render targets via [MTLDevice newTextureWithDescriptor:iosurface:plane:]. That's a real engine API extension, not a wiring detail.
  • Path A also needs to wrestle sokol's swapchain config to either redirect into our textures or run an offscreen render pass we copy into the drawable for the on-screen window — both intrusive.
  • Path B works with zero engine API changes today. The double-swizzle (producer BGRA→RGBA, engine RGBA→BGRA) is a known wart but the cost is one cache-friendly pass over the staging buffer per frame at ~ms scale on 1080p — invisible compared to a GPU blit + waitUntilCompleted.

The implementation:

  • libobjc bridging: selectors + typed @extern objc_msgSend wrappers cached in a comptime-gated _SokolPreviewMetal struct namespace. Non-Darwin builds carry zero objc references at link time.
  • Blit copyFromTexture: from the swapchain drawable → managed-storage staging 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 is in a readable state — GL stays pre-endFrame() (different sokol-gfx semantics). A new {{preview_readback_post}} template slot in desktop.txt + mobile.txt carries the post-commit block.

Engine-API gaps encountered

None blocking. The engine's Preview.beginFrameStreamIOSurface / publishFrameIOSurface(rgba) / endFrameStreamIOSurface triple 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.metalCurrentDrawable currently returns null with a documented limitation:

The pinned sokol-zig (pb1HKxTaNgA8rXBh41wxEucgmR4YPuoOXGeXbFyGPvTK) has replaced the legacy sapp_metal_get_current_drawable accessor (cached frame drawable) with a struct-returning sapp_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 via sglue_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 Preview triple). 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_enabled gate + .macos, .ios => true switch
    • _SokolPreviewMetal namespace + selectors + msgSend cluster
    • MTLPixelFormatBGRA8Unorm, blitCommandEncoder, copyFromTexture:sourceSlice:sourceLevel:, getBytes:bytesPerRow:fromRegion:mipmapLevel:
    • window.metalDevice() / window.metalCurrentDrawable() wrappers used (legacy C symbols not referenced)
    • Module-scope state names don't collide with the GL block (_preview_mtl_* vs _preview_*)
    • IOSurface lifecycle (beginFrameStreamIOSurface → publishFrameIOSurface → endFrameStreamIOSurface)
    • Source-order: beginFrameStreamIOSurface before first publishFrameIOSurface, Metal readback after window.endFrame(), endFrameStreamIOSurface before sendBye(.normal)
    • GL block from feat(sokol): GLCORE/GLES3 PBO readback + publishFrame (#122 slice 1) #124 still emits unchanged (regression lock for slice 1)
    • AST parses cleanly
  • Extended raylib desktop still emits its own PBO readback and non-loop, non-sokol backends do not pull in any preview pixel publish path to also lock out _sokol_preview_metal_enabled, _SokolPreviewMetal, _p.publishFrameIOSurface(, and window.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).
  • Regenerated examples/sokol/.labelle/sokol_desktop/main.zig via the assembler binary and ran zig build in that directory on macOS arm64 — links cleanly against IOSurface, CoreFoundation, Metal, AppKit, libobjc.
  • otool -L on the resulting binary confirms framework deps land as expected.

Test plan

  • zig build test passes locally
  • Generated sokol-on-macOS example compiles and links
  • E2E: launch a generated sokol-macOS game against the editor's iosurface consumer and confirm graceful no-op (current state) — this only needs the control plane to stay healthy; pixel publish gated on the stub.
  • Follow-up PR: flip window.metalCurrentDrawable to return the cached drawable once upstream sokol surfaces it.

Out of scope

  • D3D11 / Windows (slice 3 — sokol slice 3: D3D11 staging-texture readback (Windows) #126)
  • iOS-specific UX (touch, lifecycle)
  • The render-to-IOSurface FBO path (Path A, deferred per ticket guidance)
  • The publishFrameIOSurfaceBgra engine API addition (filed as engine follow-up)
  • Resolving the metalCurrentDrawable upstream sokol API gap

🤖 Generated with Claude Code

@cursor

cursor Bot commented May 16, 2026 •

Copy link
Copy Markdown

PR Summary

Medium Risk
Adds a new Metal readback path (libobjc calls, swapchain drawable access, and new framework link deps) that changes generated game code and build/link behavior on Darwin targets; main risk is build/link/runtime issues specific to macOS/iOS preview mode.

Overview
Enables a new Darwin-only Play-in-Editor preview pixel publish path for sokol by generating a Metal-based swapchain readback that blits the current drawable into a staging texture, copies bytes to CPU, swizzles BGRA→RGBA, and publishes via Preview.publishFrameIOSurface.

Updates the sokol backend to expose window.metalDevice() and window.metalCurrentDrawable() (requiring a pinned sokol-zig fork), adds a post-window.endFrame() template slot ({{preview_readback_post}}) to run the Metal readback after sg.commit(), and links IOSurface/CoreFoundation frameworks on .macos/.ios. Adds/extends generator tests to assert the new Metal block is emitted for sokol and does not leak into non-sokol templates.

Reviewed by Cursor Bugbot for commit 4584c6a. Bugbot is set up for automated code reviews on this repo. Configure here.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/main_zig.zig
Comment on lines +1897 to +1901
\\ if (_preview_mtl_pixel_buf.len != 0) {
\\ _preview_allocator.free(_preview_mtl_pixel_buf);
\\ _preview_mtl_pixel_buf = &[_]u8{};
\\ }
\\ }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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;

apotema and others added 2 commits May 16, 2026 11:02
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>
@apotema
apotema force-pushed the feat/sokol-metal-iosurface-125 branch from 8db008c to 4584c6a Compare May 16, 2026 14:03

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

❌ 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.

Comment thread src/main_zig.zig
\\ _SokolPreviewMetal.release(old);
\\ _preview_mtl_staging_texture = null;
\\ }
\\ _preview_mtl_staging_texture = _SokolPreviewMetal.createStagingTexture(_device, _sw, _sh);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

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",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 4584c6a. Configure here.

@apotema
apotema merged commit 84cd396 into main May 16, 2026
4 checks passed
@apotema
apotema deleted the feat/sokol-metal-iosurface-125 branch May 16, 2026 14:28
apotema added a commit that referenced this pull request May 16, 2026
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>
apotema added a commit that referenced this pull request May 16, 2026
…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>
apotema added a commit that referenced this pull request May 16, 2026
#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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

sokol slice 2: Metal/IOSurface producer (macOS, iOS)

1 participant