Native macOS window management and Metal GPU acceleration for Ruby.
Features · Installation · Usage · API Reference
Metaco is a Ruby C extension for Cocoa window management, pixel presentation, input events, and Metal GPU compute.
- Native Cocoa window lifecycle and RGBA pixel presentation.
- Keyboard, mouse, scroll, focus, and resize event polling.
- Metal compute shaders, texture upload, and pixel readback.
Add this line to your application's Gemfile:
gem "metaco"And then execute:
bundle installOr install it yourself as:
gem install metaco- macOS
- Ruby 3.0 or newer
- Xcode Command Line Tools
require "metaco"
# Initialize Cocoa
Metaco.init
# Create a window
handle = Metaco.window_create(800, 600, "My Window", resizable: true, high_dpi: true)
# Main loop
until Metaco.should_close?(handle)
# Create pixel buffer (RGBA format)
width, height = 800, 600
buffer = "\xFF\x00\x00\xFF" * (width * height) # Red
# Update pixels and present
Metaco.set_pixels(handle, buffer, width, height)
Metaco.present(handle)
# Handle events
events = Metaco.poll_events(handle)
events.each do |event|
case event[:type]
when :key_press
puts "Key pressed: #{event[:key]}"
when :mouse_press
puts "Mouse clicked at: #{event[:x]}, #{event[:y]}"
end
end
end
# Cleanup
Metaco.window_destroy(handle)require "metaco"
Metaco.init
handle = Metaco.window_create(800, 600, "Compute Shader")
# Check Metal availability
if Metaco.metal_compute_available?(handle)
# Compile shader (MSL)
shader = <<~MSL
#include <metal_stdlib>
using namespace metal;
kernel void compute_shader(
texture2d<float, access::write> output [[texture(0)]],
constant float4 &uniforms [[buffer(0)]],
uint2 gid [[thread_position_in_grid]])
{
if (gid.x >= output.get_width() || gid.y >= output.get_height()) return;
float2 uv = float2(gid) / float2(output.get_width(), output.get_height());
output.write(float4(uv.x, uv.y, uniforms.x, 1.0), gid);
}
MSL
Metaco.compile_compute_shader(handle, shader)
until Metaco.should_close?(handle)
# Dispatch with uniforms
time = Time.now.to_f
uniforms = [Math.sin(time) * 0.5 + 0.5, 0.0, 0.0, 1.0].pack("f4")
Metaco.dispatch_compute(handle, uniforms)
Metaco.present_compute(handle)
Metaco.poll_events(handle)
end
end
Metaco.window_destroy(handle)| Method | Description |
|---|---|
init |
Initialize Cocoa application |
window_create(width, height, title, resizable: false, high_dpi: false) |
Create a new window, returns a Metaco::Window handle |
window_destroy(handle) |
Close and release the window; repeated calls are safe |
should_close?(handle) |
Check if window should close |
poll_events(handle) |
Poll and return pending events |
window_size(handle) |
Return the current logical [width, height] |
framebuffer_size(handle) |
Return the current drawable pixel [width, height] |
| Method | Description |
|---|---|
set_pixels(handle, buffer, width, height) |
Set pixel data (RGBA format) |
read_pixels(handle, source: :compute) |
Read top-down RGBA8 pixels from :compute or :set_pixels |
present(handle) |
Present the frame and wait for GPU completion |
| Method | Description |
|---|---|
metal_compute_available?(handle) |
Check if Metal compute is available |
compile_compute_shader(handle, msl_source) |
Compile MSL compute shader |
dispatch_compute(handle, uniforms) |
Execute compute shader and wait for GPU completion |
present_compute(handle) |
Present compute shader output and wait for GPU completion |
has_compute_shader?(handle) |
Check if shader is compiled |
texture_create(handle, width, height, bytes, filter: :linear, wrap: :clamp) |
Upload RGBA8 pixels as a Metal texture |
texture_update(texture, bytes) / texture_destroy(texture) |
Replace pixels or release a texture |
bind_compute_texture(handle, index, texture) |
Bind input texture at texture(index + 1) |
:key_press- Key pressed (:key,:char):key_release- Key released (:key):mouse_press- Mouse button pressed (:x,:y,:button):mouse_release- Mouse button released (:x,:y,:button):mouse_move- Mouse moved (:x,:y):scroll- Scroll distance in pixels (:dx,:dy,:modifiers). Wheel ticks are scaled by 10; positive:dyfollows Cocoa's upward convention.:focus/:blur- Window key focus changed:resize- Window size changed (:width,:height,:framebuffer_width,:framebuffer_height)
Key and mouse events include :modifiers, an array of :shift, :control, :option, and :command.
- Call
initbefore creating windows. All native APIs must run on the process's main thread; worker-thread calls raiseThreadError. GPU completion waits release Ruby's GVL. - Handles are opaque
Metaco::Windowobjects, replacing the integer pointers returned by 0.1.0. Pass them unchanged to Metaco methods. A closed handle raisesArgumentErroron operations other thanwindow_destroy; unrelated objects raiseTypeError. - Release windows in an
ensureblock. GC also schedules AppKit cleanup on the main thread when necessary and releases abandoned window resources;poll_eventsservices this queue. - Width and height must be between 1 and 16,384.
resizable:is false by default.window_sizereports logical points;framebuffer_sizereports drawable pixels, including the backing scale whenhigh_dpi: true.set_pixelsrequires the current framebuffer dimensions and at leastwidth * height * 4bytes of row-major RGBA data, with unpremultiplied alpha. Extra trailing bytes are ignored. Invalid dimensions or short buffers raiseArgumentError. - Titles and shader sources must contain valid UTF-8. Key event
:charstrings use UTF-8 and preserve embedded NUL characters. Mouse buttons are numbered 0 (left), 1 (right), and 2 (middle); dragging produces:mouse_moveevents. - Compute shaders use the entry point
compute_shader, output texture 0, and a uniform buffer at index 0. Uniforms may contain 0–256 bytes; remaining bytes are zeroed on every dispatch. Larger inputs raiseArgumentError. Shader uniform structures must fit within 256 bytes. - Failed compilation preserves the previous shader and its resources. Dispatch and compute presentation require a compiled shader. GPU command failures raise
RuntimeErrorafter native resources have been cleaned up. - Dispatch covers exactly the window's pixels. Threadgroup dimensions may vary by pipeline and image width; shaders must not assume a fixed group size. The example's bounds check also makes it safe with other dispatch implementations.
read_pixelscopies the selected texture into CPU-visible memory before returning. It requires an open window and a completed render. GPU readback has not been verified on a Metal device in headless CI.- Textures belong to the window that created them. Uploads require exactly
width * height * 4RGBA8 bytes. Input textures use linear sampling and clamp addressing; other options raiseArgumentError. Destroyed textures cannot be updated or bound. - Presentation is synchronous so pixel uploads cannot overwrite an in-flight frame. An occluded window may have no drawable, in which case presentation is skipped. Rendering resource allocation failures select the bitmap fallback; compute availability then returns
false. - On unsupported platforms both compute availability queries return
false, and other APIs raiseLoadError. On macOS, native extension loading errors retain their original diagnostics.
# Install dependencies
bundle install
# Compile native extension
bundle exec rake compile
# Run tests
bundle exec rake test
# Require actual GPU execution and validate Metal commands/shaders
METACO_REQUIRE_METAL=1 MTL_DEBUG_LAYER=1 MTL_SHADER_VALIDATION=1 bundle exec rake testThe test task includes a separate native test extension for GPU pixel readback, allocation and command failure injection, Unicode input, and GC/exception cleanup. These helpers are excluded from the gem. CI runs window and bitmap tests on macOS even when Metal is unavailable; only GPU-specific tests are omitted in that case. Set METACO_SKIP_GUI=1 explicitly for a session without WindowServer. Argument and fallback tests still run.
The optional run_metal workflow-dispatch input runs the same suite with mandatory GPU execution on a self-hosted runner labeled macOS and metal. It requires a logged-in GUI session and a Metal device.
Bug reports and pull requests are welcome at rbgfx/metaco.
The gem is available as open source under the terms of the MIT License.