Skip to content

feat(sokol-metal): route game render into Path-A offscreen target (#133) - #135

Merged
apotema merged 2 commits into
mainfrom
feat/133-sokol-metal-render-target
May 16, 2026
Merged

apotema merged 2 commits into
mainfrom
feat/133-sokol-metal-render-target

Conversation

@apotema

@apotema apotema commented May 16, 2026

Copy link
Copy Markdown
Contributor

Closes #133.

Shim API

Added to backends/sokol/src/window.zig:

var current_editor_render_target: ?sg.Attachments = null;

pub fn setEditorRenderTarget(attachments: sg.Attachments) void { … }
pub fn clearEditorRenderTarget() void { … }

pub fn beginPass(pass_action: sg.PassAction) void {
    if (current_editor_render_target) |attachments| {
        sg.beginPass(.{ .action = pass_action, .attachments = attachments });
        return;
    }
    sg.beginPass(.{ .action = pass_action, .swapchain = sglue.swapchain() });
}

Plus a pub const gfx_types = sokol.gfx; re-export so the generated main.zig can reach sg.Image / sg.View / sg.Attachments / makeView / destroyView without needing a direct sokol dep on the root module — fixing a latent build break from #132 where the root main.zig referenced @import("sokol").gfx without a corresponding module import.

Path-A frame state (backends/sokol/src/main_zig.zig codegen, PREVIEW_READBACK_HELPERS_METAL_SOKOL) grew two per-slot arrays and two scalar flags:

  • _preview_mtl_views[ring] — color-attachment Views built off the per-slot sg.Image
  • _preview_mtl_attachments[ring] — .colors[0] = view pre-built once, struct-copied per frame
  • _preview_mtl_write_slot — slot index shared between pre-render and post-render arms
  • _preview_mtl_target_active — flag the post-render arm reads to decide whether to publish

Where the override fires

PREVIEW_READBACK_FRAME_METAL_SOKOL split into two halves:

  1. Pre-render (new {{preview_pre_render}} template slot, fires after g.tick(dt) and before window.beginFrame() / window.beginPass):

    • (Re)negotiates the ring on resize: tears down old textures/views/images, calls beginFrameStreamIOSurface, walks getIOSurfaceAt → createIOSurfaceTexture → makeImage(mtl_textures[…]) → makeView(.color_attachment) → Attachments.colors[0] = view.
    • If isFrameAccepted(): picks _write_slot = _preview_frame_idx % ring_size, calls window.setEditorRenderTarget(_preview_mtl_attachments[_write_slot]), sets _preview_mtl_target_active = true.
  2. Post-render (existing {{preview_readback}} slot, fires before window.endFrame()):

    • If _preview_mtl_target_active: _p.signalSlotReady(_preview_mtl_write_slot) + _preview_frame_idx +%= 1.
    • Always calls window.clearEditorRenderTarget() so the next frame's beginPass defaults back to the swapchain even if the pre-render arm bailed.

Template placeholder {{preview_pre_render}} added to backends/sokol/templates/desktop.txt and mobile.txt. Empty-string for non-iosurface backend variants (the block is comptime-gated on _sokol_preview_metal_enabled, so non-Darwin sokol builds evaporate it entirely).

Smoke check

  • Codegen tests (zig build test): 306/310 pass, 4 skipped, 0 failures. New test sokol Metal Path-A routes game render into the offscreen IOSurface target (#133) asserts the exact ordering setEditorRenderTarget → g.render() → signalSlotReady → clearEditorRenderTarget, plus cleanup-side clear-before-teardown invariant.
  • Generated example builds cleanly: examples/sokol/.labelle/sokol_desktop/zig-out/bin/game (macOS native, zig build exit 0) — the window.gfx_types re-export fixes a latent compile break in feat(sokol-metal): rework to Path A — offscreen IOSurface render target (#131) #132 where @import("sokol") wasn't reachable from the root module.
  • Non-zero pixel readback NOT confirmed in a real macOS run. A graphical sokol game launched from a backgrounded shell does not progress through NSApplication event-loop setup (the NSApp blocker called out in the ticket), so the headless LABELLE_PREVIEW=… smoke against a TCP listener that locks an IOSurface and reads back bytes wasn't runnable from this environment. The codegen tests are the strongest signal here.

Test plan

  • zig build (assembler) succeeds
  • zig build test (assembler) — 306/310 pass, 4 skipped, 0 failures
  • Regenerate examples/sokol/.labelle/sokol_desktop/ with the new assembler, zig build succeeds on macOS arm64
  • Manual: editor sees non-black Game View when running the example against a real Play-in-Editor session (needs GUI session — out of scope here)
  • Verify on Linux GL build path (mutually-exclusive comptime gate; no codegen change to that path)

Path-A (#131/#132) allocated the IOSurface ring + MTLTexture wrappers +
sokol-gfx external images, but the game's render pass still targeted
the swapchain — so the editor's Game View saw a correctly-formed
frame_offer / frame_published exchange against a zero-filled surface.

This adds a small `window.setEditorRenderTarget` /
`clearEditorRenderTarget` shim plus a per-slot `sg.View` + `sg.Attachments`
cache, and splits the per-frame Metal block into a pre-render arm
(rebuilds the ring on resize, picks `_write_slot`, arms the shim before
`window.beginPass`) and a post-render publish (`signalSlotReady` +
clear). The next `window.beginPass` then routes `g.render()` directly
into the IOSurface bytes the editor will sample.

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

cursor Bot commented May 16, 2026 •

Copy link
Copy Markdown

PR Summary

Medium Risk
Changes sokol Metal preview rendering to redirect the main render pass into offscreen IOSurface-backed attachments and adds new lifecycle/state around per-frame target selection and teardown; mistakes could lead to blank previews or resource lifetime/assertion issues on macOS/iOS.

Overview
Enables sokol Metal Path-A Play-in-Editor preview to render directly into IOSurface-backed offscreen targets instead of always rendering to the swapchain.

Adds a small window-backend shim (setEditorRenderTarget/clearEditorRenderTarget) that makes beginPass choose either swapchain or caller-provided sg.Attachments, plus a gfx_types re-export so generated code can use sokol-gfx handle types without importing sokol.

Updates the sokol templates/codegen to introduce a new {{preview_pre_render}} hook that (re)builds per-slot sg.Image→sg.View→sg.Attachments for the IOSurface ring on resize and arms the render-target override before g.render(), then publishes the rendered slot and always clears the override post-render; adds a regression test asserting the ordering and cleanup invariants.

Reviewed by Cursor Bugbot for commit eb828af. 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 implements the render-into-IOSurface functionality for the Metal Path-A preview on macOS, enabling offscreen rendering for the editor. It introduces a setEditorRenderTarget shim in the Sokol window backend to redirect render passes and updates the codegen templates to include pre-render and post-render blocks for attachment management and frame publishing. Review feedback identifies a typo in the sg.makeView field name and suggests adding error checks for resource creation handles, as well as extending the rollback loop to prevent resource leaks during initialization failures.

Comment thread src/main_zig.zig
Comment on lines +2183 to +2185
\\ const _view = _sg.makeView(.{
\\ .color_attachment = .{ .image = _img },
\\ });

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 field name in sg.makeView likely contains a typo. Standard sokol-zig bindings use the plural color_attachments (matching the C API color_attachments[SG_MAX_COLOR_ATTACHMENTS]) rather than the singular color_attachment.

    \                        const _view = _sg.makeView(.{
    \                            .color_attachments = .{ .{ .image = _img } },
    \                        });

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.

False positive — sokol-zig 1.0's ViewDesc.color_attachment is singular. From the pinned source zig-pkg/sokol/src/sokol/gfx.zig:3815-3820:

pub const ViewDesc = extern struct {
    _start_canary: u32 = 0,
    texture: TextureViewDesc = .{},
    storage_buffer: BufferViewDesc = .{},
    storage_image: ImageViewDesc = .{},
    color_attachment: ImageViewDesc = .{},
    resolve_attachment: ImageViewDesc = .{},
    ...
};

Each view binds ONE attachment of one kind (texture / storage_buffer / storage_image / color_attachment / resolve_attachment / depth_stencil_attachment). The C API's color_attachments[] array lives on AttachmentsDesc, not ViewDesc. Attachments is the struct that aggregates multiple views; this view is one of those attachments.

Comment thread src/main_zig.zig Outdated
Comment on lines 2181 to 2195
\\ const _img = _sg.makeImage(_desc);
\\ _preview_mtl_sg_images[_slot] = _img;
\\ const _view = _sg.makeView(.{
\\ .color_attachment = .{ .image = _img },
\\ });
\\ _preview_mtl_views[_slot] = _view;
\\ var _att: _sg.Attachments = .{};
\\ _att.colors[0] = _view;
\\ _preview_mtl_attachments[_slot] = _att;
\\ }
\\ if (!_alloc_ok) {
\\ // Rollback any partially-allocated ring so
\\ // the next resize attempt starts clean.
\\ var _i: u32 = 0;
\\ while (_i < _slot) : (_i += 1) {

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 results of sg.makeImage and sg.makeView are not checked for failure. If the resource pools are exhausted, these functions return handles with an id of 0. Using such handles in subsequent calls (like makeView or setEditorRenderTarget) can lead to assertions or undefined behavior. Additionally, the rollback loop should be updated to include the current _slot to ensure that if makeImage succeeds but makeView fails, the image at the current slot is properly destroyed.

    \                        const _img = _sg.makeImage(_desc);
    \                        if (_img.id == 0) { _alloc_ok = false; break; }
    \                        _preview_mtl_sg_images[_slot] = _img;
    \                        const _view = _sg.makeView(.{
    \                            .color_attachments = .{ .{ .image = _img } },
    \                        });
    \                        if (_view.id == 0) { _alloc_ok = false; break; }
    \                        _preview_mtl_views[_slot] = _view;
    \                        var _att: _sg.Attachments = .{};
    \                        _att.colors[0] = _view;
    \                        _preview_mtl_attachments[_slot] = _att;
    \                    }
    \                    if (!_alloc_ok) {
    \                        // Rollback any partially-allocated ring so
    \                        // the next resize attempt starts clean.
    \                        var _i: u32 = 0;
    \                        while (_i <= _slot) : (_i += 1) {

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 in eb828af. Both makeImage and makeView now check .id == 0 and bail into the rollback block. Also extended the rollback loop bound to _i <= _slot so the just-failed slot's partial state (MTLTexture, possibly also the sokol image handle) gets released too — caught while writing the fix; without it a mid-init failure leaked the IOSurface texture + sokol image.

@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 5b07f12. Configure here.

Comment thread src/main_zig.zig
… typo

Addresses two bot findings:

**gemini high — `color_attachment` typo in `sg.makeView`** — FALSE
POSITIVE. sokol-zig 1.0's `ViewDesc.color_attachment` is singular
(verified against the pinned source at
`zig-pkg/sokol/src/sokol/gfx.zig:3820`: each view binds ONE
attachment of one kind — texture / storage_buffer / storage_image /
color_attachment / resolve_attachment / depth_stencil_attachment).
The C API's `color_attachments[]` array lives on `AttachmentsDesc`,
not `ViewDesc`. Posted a reply on the inline comment with the
evidence; no code change.

**gemini medium / cursor medium — `makeImage` / `makeView` results
not checked for failure** — VALID. Both fns return handles with
`.id = 0` when the sokol pool is exhausted. Now check explicitly:
if either returns a zero-id handle, mark `_alloc_ok = false` and
fall into the rollback block.

Extended the rollback loop bound `_i < _slot` → `_i <= _slot and
_i < _PreviewMtlRingMax` so the just-failed slot's partial state
(MTLTexture already in `_preview_mtl_textures[_slot]`, possibly
also `_preview_mtl_sg_images[_slot]`) gets released too — without
this, a mid-init failure leaked the IOSurface texture + sokol image
handle.

Codegen tests still pass: 306/310 (4 skipped baseline).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@apotema
apotema merged commit 27db30a into main May 16, 2026
4 checks passed
@apotema
apotema deleted the feat/133-sokol-metal-render-target branch May 16, 2026 19:08
apotema added a commit that referenced this pull request May 16, 2026
Minor release completing the **sokol-Metal Path A** stack:

- **#133 / #135** — route the game's `sg.beginPass` into the
  Path-A offscreen IOSurface render target. New gfx-layer shim
  in `backends/sokol/src/window.zig` (`setEditorRenderTarget` /
  `clearEditorRenderTarget`); new template slot
  `{{preview_pre_render}}` fires before the game render to arm
  the shim with the current ring slot's `sg.View` +
  `sg.Attachments`. The existing `{{preview_readback}}` slot
  disarms it and calls `signalSlotReady` after the render.
- **#134** — un-pinned `backends/sokol/build.zig.zon` from the
  labelle-toolkit/sokol-zig fork (`feat/expose-cached-metal-drawable`
  branch) back to upstream `floooh/sokol-zig@8b1a651e`. Path A
  doesn't need the fork's `sapp_metal_get_current_drawable`
  accessor.

Together with assembler v0.22.0's Path A scaffolding and engine
v1.37.4's `getIOSurfaceAt`/`signalSlotReady` APIs, the sokol-Metal
PIE producer is now **end-to-end functional**: the game renders
directly into IOSurface-backed `sg.Image`s, the editor samples them
zero-copy via `CGLTexImageIOSurface2D`, no post-frame blit, no
drawable lifetime concerns.

Plus bonus latent compile fix from #132's Path A bringup that
codegen tests didn't catch (#135 surfaced it during live build).

Review-fix from #135: null-check `makeImage`/`makeView` returns +
extend the rollback loop to cover the just-failed slot's partial
state (caught a leak path).

Out of scope: the sokol-zig fork repo itself (un-archive cleanup
is a separate task; harmless to leave it alive).

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 Metal Path A: route game render into offscreen IOSurface target

1 participant