Search by

jovian / metal

projectsaturnstudios

macOS Metal Bindings for The PHP Metal Extension

0.8.x-dev 2026-09-17 17:32 UTC

This package is auto-updated.

Last update: 2026-09-17 17:32:18 UTC


README

Metal, typed in PHP.

ext-metal binds Apple's Metal 1:1 and deliberately withholds one thing: constants. This package is where they live, along with the types the extension cannot express — every object handle projected onto a class, every flattened struct onto a value object, every NS_ENUM onto a PHP enum mined from the macOS SDK.

It adds no behaviour. Every method is exactly one extension call.

ext-metal  →  jovian/metal  →  venusian-metal  →  Surface
  1:1           typed            composition       cross-platform

Install

composer require jovian/metal

Requires macOS, PHP ^8.4, and ext-metal ^0.8.0.

The shape of it

use Jovian\Bindings\Metal\Enums\MTLPixelFormat;
use Jovian\Bindings\Metal\Enums\MTLStorageMode;
use Jovian\Bindings\Metal\Enums\MTLTextureUsage;
use Jovian\Bindings\Metal\MTL\MTLDevice;
use Jovian\Bindings\Metal\MTL\MTLTextureDescriptor;

$device = MTLDevice::createSystemDefault();
echo $device->name();                      // "Apple M1 Pro"

$descriptor = MTLTextureDescriptor::texture2DDescriptorWithPixelFormatWidthHeightMipmapped(
    MTLPixelFormat::RGBA8_UNORM,           // 70, mined from MTLPixelFormat.h
    64,
    64,
    false,
);
$descriptor->setUsage(MTLTextureUsage::RENDER_TARGET->value);
$descriptor->setStorageMode(MTLStorageMode::SHARED);

$texture = $device->newTextureWithDescriptor($descriptor->handle);
$texture->width();                         // 64

Three conventions are worth knowing up front.

Object parameters are int handles; only returns are boxed. Pass $obj->handle. The extension takes handles, and a projection that quietly unwrapped objects would be doing work the extension did not ask for.

NS_OPTIONS values stay int. PHP enums cannot be OR'd, so MTLTextureUsage::RENDER_TARGET | MTLTextureUsage::SHADER_READ is a TypeError. The enum is still generated — write ->value and OR the ints.

PHP refcount owns the handle. A projected object retains on construction and releases when the last PHP reference goes. So this is a bug:

$handle = MTLTextureDescriptor::texture2D…(…)->handle;   // already released

Hold the object, then read ->handle.

Structs and the NSError** return

Flattened struct arguments are value objects:

use Jovian\Bindings\Metal\Values\MTLClearColor;
use Jovian\Bindings\Metal\Values\MTLViewport;

$attachment->setClearColor(new MTLClearColor(0.0, 0.0, 0.0, 1.0));
$encoder->setViewport(new MTLViewport(0.0, 0.0, 64.0, 64.0, 0.0, 1.0));

Shader compilation and pipeline creation use ext-metal's sanctioned {handle, error} return, typed here as HandleError:

$result = $device->newLibraryWithSourceOptionsError($source, 0);

if ($result->handle === 0) {                 // note: handle, not error
    throw new RuntimeException(
        $result->error > 0
            ? (Bridge::errorDescription($result->error) ?? 'unreadable NSError')
            : 'the call could not be made at all',
    );
}

$library = MTLLibrary::box($result->handle);

Both members are zero when the binding refused to send at all — a nil receiver, or an argument that did not resolve — so handle === 0 is the failure test, not error > 0.

Proof

examples/proof_triangle_typed.php compiles an MSL shader pair at runtime, builds a render pipeline, draws one triangle into a 64×64 offscreen texture and byte-checks the readback. No window, no AppKit.

$ php examples/proof_triangle_typed.php
device: Apple M1 Pro
centre: 64,128,191,255 corner: 0,0,0,255
PROOF_TRIANGLE_TYPED_OK

Those are the same bytes ext-metal's own proof_triangle asserts — the same GPU work, written against types instead of cited integer literals.

What is here

Classes 32 (src/MTL/, src/QuartzCore/)
Methods 157, each exactly one extension call
Enums 18, 267 cases, mined from the SDK
Value objects 9 (src/Values/)
Hand-written src/Runtime/Bridge, ObjCObject, Registry, Lifetime

src/MTL/**, src/QuartzCore/**, src/Enums/** and src/Runtime/ClassMap.php are generated by php scripts/generate.php and are never hand-edited.

Cross-extension pointer seam

Raw pointer bits are the only currency between extensions — never a registry handle from another one.

$layer = CAMetalLayer::init();
$pointer = Bridge::pointerOf($layer->handle);
// … hand $pointer to ext-appkit's own Bridge::adopt('CAMetalLayer', $pointer)

Each side owns one retain, and neither may skip its own on the assumption the other did it.

Development

See AGENTS.md for the rules and the gate list, and .okf/ for the knowledge bundle.

php scripts/generate.php --check
vendor/bin/pest

Licence

MIT.