Skip to content

feat(sokol): GLCORE/GLES3 PBO readback + publishFrame (#122 slice 1) - #124

Merged
apotema merged 2 commits into
mainfrom
sokol-gl-pbo-readback
May 16, 2026
Merged

apotema merged 2 commits into
mainfrom
sokol-gl-pbo-readback

Conversation

@apotema

@apotema apotema commented May 16, 2026

Copy link
Copy Markdown
Contributor

Closes the first slice of #122. Extends the per-frame PBO readback that landed for raylib in #120 (labelle-engine#544) to sokol-backend games on GL paths — default sokol-zig picks GLCORE on Linux desktop and GLES3 on Android/web. Metal (Darwin) and D3D11 (Windows) are deferred to slices 2 and 3 of #122 and emit no-op fallbacks here.

What lands in the generated main

(a) Emit-point in the sokol template — new {{preview_readback}} placeholder in backends/sokol/templates/desktop.txt + mobile.txt, sitting right before window.endFrame(). window.endFrame internally calls sg.endPass(); sg.commit(); — glReadPixels on GL_BACK must run before the swap, mirroring raylib's pre-endDrawing placement.

(b) GL backend gating shape — module-scope _sokol_preview_gl_enabled: bool derived from builtin.os.tag (.linux → true, else false; Android is os.tag == .linux with abi == .android, so the single arm covers both desktop GLCORE and Android GLES3). The GL extern decls + constants live inside a _SokolPreviewGl = if (_sokol_preview_gl_enabled) struct { ... } else struct {} namespace — the else struct {} branch has no symbols, so a Metal / D3D11 sokol build never references unresolved GL externs at link time. sokol_clib on Linux already links libGL via the upstream sokol-zig build, so the GL path resolves transitively without needing the per-OS linker shim raylib's #120 added to src/templates/build_zig.txt. Per-frame block + init seed + cleanup teardown are also each wrapped in if (comptime _sokol_preview_gl_enabled) { ... } so the entire path evaporates on Metal / D3D11 builds.

PBO state lives at module scope (_preview_pbos: [3]c_uint, frame counter, last published dims, _preview_pixel_buf, _preview_allocator) because sokol's init / frame / cleanup callbacks have no shared local stack frame. The allocator is stashed in init so frame + cleanup can grow / free the CPU staging buffer without reaching back through g. Cleanup teardown (endFrameStream + glDeleteBuffers + free) runs BEFORE _p.sendBye(.normal) so SHM / PBO unwind happens while the engine still holds the socket open (LIFO with the graceful protocol close).

(c) Test additions:

  • "sokol desktop emits PBO readback + publishFrame between flushScene and endFrame" — asserts the GL externs, the _sokol_preview_gl_enabled comptime gate + else struct {} branch, frame counters, beginFrameStream / publishFrame ordering, source-order placement relative to window.endFrame(), cleanup-teardown-before-bye ordering, and that the generated source parses cleanly via std.zig.Ast.parse.
  • "raylib desktop still emits its own PBO readback after the sokol slice lands" — regression lock asserting the raylib codepath is untouched (no sokol identifiers leak into raylib output, and vice-versa).
  • "non-loop, non-sokol backends do not pull in any preview pixel publish path" — locks the gate so sdl / bgfx / wgpu (loop branch) and wasm-raylib (callback branch, non-sokol) all regress loudly if the conditional ever drifts.

(d) Verified locally:

  • zig build && zig build test (Zig 0.16): 297/301 pass (4 pre-existing skipped — Zig 0.16 preview migration stubs), up from baseline 294/298 with the 3 new tests all passing.
  • Regenerated examples/sokol/.labelle/sokol_desktop + examples/sokol_imgui/.labelle/sokol_desktop via the assembler binary directly and built both with zig build and zig build -Doptimize=ReleaseSafe. On macOS the comptime gate elides the GL block (Metal default backend) so both link cleanly with the Metal framework and no undefined-symbol errors. Regenerated examples/raylib/.labelle/raylib_desktop and built — raylib codepath unchanged.

(e) Out of scope (explicit, separate slices of #122):

  • Metal / IOSurface readback — slice 2.
  • D3D11 staging-texture readback — slice 3.
  • macOS sokol pixel publish — depends on slice 2's IOSurface path; this PR's Metal build is a no-op fallback on macOS, the control-plane wiring (hello / heartbeat / bye) still works.
  • WebGL2 / emscripten preview — limited PBO support, follow-up.
  • Other loop backends (sdl / bgfx / wgpu) — separate tickets.

Test plan

  • zig build && zig build test clean (297/301 pass, 4 pre-existing skipped)
  • Regenerate sokol example with assembler binary and zig build it on macOS (Metal — comptime gate elides GL path)
  • Regenerate sokol_imgui example and zig build it on macOS
  • Confirm raylib generated game still builds (no regression in feat(raylib): per-frame PBO readback + publishFrame for PIE viewport #120's path)
  • Linux end-to-end smoke against editor SHM consumer (depends on CI / Linux hardware; deferred to integration step)
  • Real editor PIE viewport rendering — paired with the editor-side ack work

🤖 Generated with Claude Code

@cursor

cursor Bot commented May 16, 2026 •

Copy link
Copy Markdown

PR Summary

Medium Risk
Adds per-frame GPU readback and shared-memory publishing in the sokol callback lifecycle, which can affect frame timing, GL resource management, and platform-specific build/link behavior. The feature is comptime-gated to GL targets, reducing risk on Metal/D3D11 but still touches critical render-loop ordering.

Overview
Adds preview-mode pixel streaming for sokol builds on GL targets by generating a 3-PBO async glReadPixels readback path and publishing frames via Preview.publishFrame.

The sokol desktop/mobile templates now include a {{preview_readback}} hook placed just before window.endFrame() to ensure readback occurs before the swap/commit, and the main.zig generator injects comptime-gated GL externs + module-scope PBO/CPU buffer state, allocator seeding in init, and teardown (end stream + delete buffers + free) before the graceful bye.

Updates tests to assert the new codegen (gating, ordering, teardown) and adds regression checks that raylib’s existing readback path and non-sokol/non-loop backends remain unaffected.

Reviewed by Cursor Bugbot for commit b2a3977. 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 Sokol-specific PBO readback for preview mode, implementing a 3-deep PBO ring with asynchronous DMA for OpenGL-based platforms. The changes include new template blocks for initialization, per-frame readback, and cleanup, as well as comprehensive tests for the generated code. Reviewers identified a potential compatibility issue with glMapBuffer on Android GLES 3.0, a logic bug where dimension state is updated even if buffer allocation fails, and suggested checking the return value of glUnmapBuffer to prevent publishing corrupt data.

Comment thread src/main_zig.zig
Comment on lines +1248 to +1287
\\const _SokolPreviewGl = if (_sokol_preview_gl_enabled) struct {
\\ pub const PIXEL_PACK_BUFFER: c_uint = 0x88EB;
\\ pub const STREAM_READ: c_uint = 0x88E1;
\\ pub const READ_ONLY: c_uint = 0x88B8;
\\ pub const PACK_ALIGNMENT: c_uint = 0x0D05;
\\ pub const RGBA: c_uint = 0x1908;
\\ pub const UNSIGNED_BYTE: c_uint = 0x1401;
\\ pub const pixelStorei = @extern(
\\ *const fn (pname: c_uint, param: c_int) callconv(.c) void,
\\ .{ .name = "glPixelStorei" },
\\ );
\\ pub const genBuffers = @extern(
\\ *const fn (n: c_int, buffers: [*]c_uint) callconv(.c) void,
\\ .{ .name = "glGenBuffers" },
\\ );
\\ pub const deleteBuffers = @extern(
\\ *const fn (n: c_int, buffers: [*]const c_uint) callconv(.c) void,
\\ .{ .name = "glDeleteBuffers" },
\\ );
\\ pub const bindBuffer = @extern(
\\ *const fn (target: c_uint, buffer: c_uint) callconv(.c) void,
\\ .{ .name = "glBindBuffer" },
\\ );
\\ pub const bufferData = @extern(
\\ *const fn (target: c_uint, size: isize, data: ?*const anyopaque, usage: c_uint) callconv(.c) void,
\\ .{ .name = "glBufferData" },
\\ );
\\ pub const readPixels = @extern(
\\ *const fn (x: c_int, y: c_int, w: c_int, h: c_int, fmt: c_uint, ty: c_uint, data: ?*anyopaque) callconv(.c) void,
\\ .{ .name = "glReadPixels" },
\\ );
\\ pub const mapBuffer = @extern(
\\ *const fn (target: c_uint, access: c_uint) callconv(.c) ?*anyopaque,
\\ .{ .name = "glMapBuffer" },
\\ );
\\ pub const unmapBuffer = @extern(
\\ *const fn (target: c_uint) callconv(.c) u8,
\\ .{ .name = "glUnmapBuffer" },
\\ );
\\} else struct {};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The use of glMapBuffer via @extern may cause issues on Android (GLES 3.0). While glMapBuffer is standard in Desktop OpenGL, it is not part of the GLES 3.0 core specification (which uses glMapBufferRange). Many Android drivers provide it as an extension, but it is not guaranteed to be present as a linkable symbol.

Since this PR targets GLES3 for Android, consider using glMapBufferRange or verifying that glMapBuffer is indeed available on the target Android environments. If you switch to glMapBufferRange, you will also need the GL_MAP_READ_BIT constant (0x0001).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Applied — switched extern to glMapBufferRange (universal across GL 3.0+ and GLES 3.0+) with a new MAP_READ_BIT = 0x0001 constant. Avoids the GLES3 GL_OES_mapbuffer dependency entirely. See e75ed83.

Comment thread src/main_zig.zig
Comment on lines +1346 to +1353
\\ if (_preview_pixel_buf.len != _needed_bytes) {
\\ if (_preview_pixel_buf.len != 0) _preview_allocator.free(_preview_pixel_buf);
\\ _preview_pixel_buf = _preview_allocator.alloc(u8, _needed_bytes) catch &[_]u8{};
\\ }
\\ _preview_last_w = _sw;
\\ _preview_last_h = _sh;
\\ _preview_frame_idx = 0;
\\ }

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

If the CPU staging buffer allocation fails (line 1348), the preview readback is effectively disabled until the next window resize. This happens because _preview_last_w and _preview_last_h are updated (lines 1350-1351) even if the allocation fails, preventing the resize block from re-running in subsequent frames.

To make the readback more resilient to transient memory pressure, consider only updating the 'last' dimensions if the allocation succeeds.

                    if (_preview_pixel_buf.len != _needed_bytes) {
                        if (_preview_pixel_buf.len != 0) _preview_allocator.free(_preview_pixel_buf);
                        _preview_pixel_buf = _preview_allocator.alloc(u8, _needed_bytes) catch &[_]u8{};
                    }
                    if (_preview_pixel_buf.len == _needed_bytes) {
                        _preview_last_w = _sw;
                        _preview_last_h = _sh;
                        _preview_frame_idx = 0;
                    }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Applied — _preview_last_w/h (and _preview_frame_idx = 0) now only commit when the alloc succeeded (_preview_pixel_buf.len == _needed_bytes). A transient OOM heals on the next frame instead of stranding the readback. See e75ed83.

Comment thread src/main_zig.zig Outdated
\\ const _dst_row = _preview_pixel_buf.ptr + (@as(usize, _y) * _row_bytes);
\\ @memcpy(_dst_row[0.._row_bytes], _src_row[0.._row_bytes]);
\\ }
\\ _ = _SokolPreviewGl.unmapBuffer(_SokolPreviewGl.PIXEL_PACK_BUFFER);

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 return value of glUnmapBuffer is ignored. While usually safe, glUnmapBuffer returns GL_FALSE if the buffer content became corrupt during the mapping (e.g., due to a screen resolution change or context loss). In such cases, the data in _preview_pixel_buf might be invalid and should ideally not be published.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Applied — branched on glUnmapBuffer return; publishFrame only fires when it returns non-zero. Memcpy still runs with potentially-garbage data, but the editor won't see a torn frame. See e75ed83.

@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 1 potential issue.

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 dcaa08c. Configure here.

Comment thread src/main_zig.zig
apotema added a commit that referenced this pull request May 16, 2026
Addresses three bot findings on PR #124:

**gemini high + cursor medium** — `glMapBuffer` is not in core GLES3
(only `GL_OES_mapbuffer`), so Android GLES3 builds covered by the
`_sokol_preview_gl_enabled` gate (`os.tag == .linux` which includes
Android) would fail to link. Switched the extern to `glMapBufferRange`
(core in GL 3.0+ AND GLES 3.0+), the universal portable choice. New
`MAP_READ_BIT = 0x0001` constant replaces `READ_ONLY = 0x88B8`.

**gemini medium** — CPU staging buffer alloc failure was non-recoverable:
`_preview_last_w/h` were updated unconditionally, so subsequent frames
saw matching dims, skipped the resize block, and hit the
`_preview_pixel_buf.len != _needed_bytes` guard's `break :_readback`
forever. Now `_preview_last_w/h` and `_preview_frame_idx = 0` only
commit if the alloc actually produced a buffer of the right size, so
a transient OOM heals on the next frame.

**gemini medium** — `glUnmapBuffer` returns `GL_FALSE` (0) when the
buffer contents became corrupt during the map (context loss, screen-
resolution change racing the readback). Now branches on the return:
the memcpy already ran with potentially-garbage data, but we skip
`publishFrame` so the editor doesn't display a torn frame.

`zig build test --summary all` — 297/301 pass (4 pre-existing skips,
unchanged from baseline).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
apotema and others added 2 commits May 16, 2026 10:02
Extends the per-frame PBO readback that landed for raylib in #120
(labelle-engine#544) to sokol-backend games on GL paths — default
sokol-zig picks GLCORE on Linux desktop, GLES3 on Android/web.
Metal (Darwin) and D3D11 (Windows) are deferred to slices 2 and 3
of #122 and emit no-op fallbacks here.

What lands in the generated main:

- Module-scope `@extern` decls for the PBO entry points
  (glReadPixels / glGenBuffers / glBindBuffer / glBufferData /
  glMapBuffer / glUnmapBuffer / glPixelStorei / glDeleteBuffers)
  + GL 2.1+ constants, wrapped in a `_SokolPreviewGl = if
  (_sokol_preview_gl_enabled) struct { ... } else struct {}`
  namespace. The `else struct {}` branch declares no symbols, so a
  Metal / D3D11 sokol build never references unresolved GL externs
  at link time — sokol_clib on Linux already links libGL via the
  upstream sokol-zig build, so the GL path resolves transitively
  without needing the per-OS linker shim raylib's PR added to
  build_zig.txt.
- Module-scope PBO state (`_preview_pbos`, frame counter, last
  published dims, `_preview_pixel_buf`, `_preview_allocator`).
  Sokol's `init` / `frame` / `cleanup` callbacks have no shared
  local stack, so the locals raylib's main() uses for the same
  purpose live at file scope here. `_preview_allocator` is stashed
  in the `init` callback so the frame + cleanup callbacks can
  grow / free the CPU staging buffer without reaching back through
  `g`.
- Per-frame block between `window.flushScene()` / GUI draw and
  `window.endFrame()`: bind pbo[N%3] -> glReadPixels (async DMA);
  from frame 2 onwards glMapBuffer pbo[(N-2)%3] -> memcpy with
  bottom-up -> top-down row flip -> `Preview.publishFrame`. Same
  3-deep ring + 2-frame priming gap as raylib's loop (and the
  imgui-preview-poc reference).
- Resize handling: dims read every frame via `window.width()` /
  `window.height()`. On change, re-issue `Preview.beginFrameStream`
  (idempotent — engine tears down the old SHM ring), re-glBufferData
  the PBOs, re-alloc the CPU buffer, reset the priming counter.
- Cleanup callback: `endFrameStream` + `glDeleteBuffers` + free the
  CPU buffer BEFORE `_p.sendBye(.normal)` so SHM / PBO teardown
  runs while the engine still holds the socket open (LIFO with the
  graceful protocol close — same shape as raylib's `defer` stack).

Plumbing changes:

- New `{{preview_readback}}` placeholder in
  `backends/sokol/templates/desktop.txt` + `mobile.txt`, sitting
  right before `window.endFrame()`. `window.endFrame` internally
  calls `sg.endPass(); sg.commit();` — glReadPixels on GL_BACK
  must run before the swap, mirroring raylib's pre-endDrawing
  placement.
- `PREVIEW_READBACK_HELPERS_SOKOL` (module-scope `_sokol_preview_gl_enabled`
  + PBO state + `_SokolPreviewGl` namespace) + `PREVIEW_READBACK_INIT_SOKOL`
  (allocator stash) + `PREVIEW_READBACK_FRAME_SOKOL` (per-frame body)
  + `PREVIEW_READBACK_CLEANUP_SOKOL` (PBO teardown) snippets in
  `src/main_zig.zig`. Concatenated only when `cfg.backend == .sokol`
  so wasm-raylib (which also takes the callback branch but isn't
  sokol) keeps emitting just the control-plane wiring.

The `_sokol_preview_gl_enabled` comptime flag is a single-switch
on `builtin.os.tag` (`.linux` -> true, else false). Android in Zig
is `os.tag == .linux` with `abi == .android`, so the `.linux` arm
covers both desktop GLCORE and Android GLES3. Emscripten / wasm32
is left as a no-op for slice 1 — WebGL2's PBO support is limited
and slice 1 doesn't ship a wasm story.

Tests:

- New "sokol desktop emits PBO readback + publishFrame between
  flushScene and endFrame" asserts the GL externs, the
  `_sokol_preview_gl_enabled` comptime gate + `else struct {}`
  branch, frame counters, beginFrameStream / publishFrame
  ordering, source-order placement relative to `window.endFrame()`,
  cleanup-teardown-before-bye ordering, and that the generated
  source parses cleanly via `std.zig.Ast.parse`.
- New "raylib desktop still emits its own PBO readback after the
  sokol slice lands" regression lock — asserts the raylib codepath
  is untouched (no sokol identifiers leak into raylib output, and
  vice-versa).
- New "non-loop, non-sokol backends do not pull in any preview
  pixel publish path" locks the cfg.backend gate so sdl / bgfx /
  wgpu (loop branch) and wasm-raylib (callback branch, non-sokol)
  all regress loudly if the conditional ever drifts.
- Full suite: 297/301 pass (4 pre-existing skipped — Zig 0.16
  preview migration stubs), up from 294/298 (3 new tests, all
  pass).
- End-to-end: regenerated `examples/sokol/.labelle/sokol_desktop`
  + `examples/sokol_imgui/.labelle/sokol_desktop` via the
  assembler binary and built both with `zig build` (default
  Metal backend on macOS — comptime gate elides the GL block)
  and `zig build -Doptimize=ReleaseSafe` — both link cleanly
  with the Metal framework, no undefined-symbol errors.

Out of scope (explicit, separate slices of #122):
- Metal / IOSurface readback (slice 2).
- D3D11 staging-texture readback (slice 3).
- macOS sokol pixel publish (depends on slice 2's IOSurface
  path; this PR's Metal build is a no-op fallback on macOS).
- WebGL2 / emscripten preview (limited PBO support, follow-up).
- Other loop backends (sdl / bgfx / wgpu) — separate tickets.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addresses three bot findings on PR #124:

**gemini high + cursor medium** — `glMapBuffer` is not in core GLES3
(only `GL_OES_mapbuffer`), so Android GLES3 builds covered by the
`_sokol_preview_gl_enabled` gate (`os.tag == .linux` which includes
Android) would fail to link. Switched the extern to `glMapBufferRange`
(core in GL 3.0+ AND GLES 3.0+), the universal portable choice. New
`MAP_READ_BIT = 0x0001` constant replaces `READ_ONLY = 0x88B8`.

**gemini medium** — CPU staging buffer alloc failure was non-recoverable:
`_preview_last_w/h` were updated unconditionally, so subsequent frames
saw matching dims, skipped the resize block, and hit the
`_preview_pixel_buf.len != _needed_bytes` guard's `break :_readback`
forever. Now `_preview_last_w/h` and `_preview_frame_idx = 0` only
commit if the alloc actually produced a buffer of the right size, so
a transient OOM heals on the next frame.

**gemini medium** — `glUnmapBuffer` returns `GL_FALSE` (0) when the
buffer contents became corrupt during the map (context loss, screen-
resolution change racing the readback). Now branches on the return:
the memcpy already ran with potentially-garbage data, but we skip
`publishFrame` so the editor doesn't display a torn frame.

`zig build test --summary all` — 297/301 pass (4 pre-existing skips,
unchanged from baseline).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@apotema
apotema force-pushed the sokol-gl-pbo-readback branch from e75ed83 to b2a3977 Compare May 16, 2026 13:02
@apotema
apotema merged commit d8fcc71 into main May 16, 2026
4 checks passed
@apotema
apotema deleted the sokol-gl-pbo-readback branch May 16, 2026 13:12
apotema added a commit that referenced this pull request May 16, 2026
Slice 2 of labelle-assembler#122. Adds the Windows sokol-on-D3D11
preview readback path, matching the protocol shape and 3-buffer ring
+ 2-frame priming gap that slice 1 (#124) ships for sokol-on-GL.

Sokol back-buffer access goes through the public sokol-zig API
(`sg_d3d11_device`, `sg_d3d11_device_context`, `sapp_d3d11_get_swap_chain`)
plus COM vtable dispatch on `IDXGISwapChain::GetBuffer`. Swizzles
BGRA→RGBA during the memcpy because the DXGI swapchain defaults to
`DXGI_FORMAT_B8G8R8A8_UNORM` while the SHM consumer expects RGBA.

The new helper/init/frame/cleanup blocks live behind a
`_sokol_preview_d3d11_enabled` comptime gate (`builtin.os.tag ==
.windows`) so the COM-style externs never reach the link line on
Linux/Darwin builds. The GL block from #124 stays unchanged.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
apotema added a commit that referenced this pull request May 16, 2026
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>
apotema added a commit that referenced this pull request May 16, 2026
* feat(sokol): Metal/IOSurface producer for macOS (#125)

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>

* feat(sokol-metal): use labelle-toolkit/sokol-zig fork for cached drawable

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>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
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.

1 participant