Every display takes its pixels in its own byte layout. A typical I2C OLED eats pixel data shaped by the binary bit, packed into columns rows each sized at 1 byte. A common TFT panel eat pixels that are 2 bytes in-size. A Linux framebuffer wants 32-bit pixels, that's 4 bytes per pixel. A colored e-paper display likes it's pixels shaped similarly to an OLED but in multiple channels. Drawing for any of them means keeping the picture in that exact layout, changing pixels in it, and sending all of it, or only the part that changed, down the wire.
The easiest way to abstract this in PHP is with a string. While that is the most developer-friendly way to learn how framebuffers works, a string is a heavier primitive type in PHP, and requires extra work to turn in into a blob of bytes. Sometimes, it takes more work than allowed to keep up with even 30FPS. For most framebuffers, ext-gd wouldn't be helpful either, you would have to transcode its output which we want to avoid for performance.
The solution? Use an extension that manages the framebuffer in C land. Now its C-shaped blob that can be manipulated in PHP-land, that's extra performant. That's where ext-fb comes in. It holds the pixels in C. Describe the pixel format you want to work with, and draw pixels to it. When you're ready, flush the buffer and get a blog of pixels ready to be fed into an I2C/SPI Panel, A kernel-level framebuffer or a graphics API.
Most ways you will interface with ext-fb are in two parts -
Describe the layout with an FbFormat, make an FbBuffer in it, and draw. A pixel value is a word in the layout's own terms, here an RGB565 word; mapRgba8() turns 0..255 channels into one.
// An ST7789 panel: 240 × 135 pixels of RGB565, high byte first on the wire.
$format = new FbFormat(FB_LAYOUT_RGB565);
$screen = new FbBuffer($format, 240, 135);
$black = $format->mapRgba8(0, 0, 0);
$red = $format->mapRgba8(255, 0, 0);
$screen->fill($black);
$screen->rect(20, 20, 100, 50, $red);
$screen->set(0, 0, $format->mapRgba8(255, 128, 0));Take the bytes out. bytes() is the whole store, top row first, exactly as the panel takes it. region() is one rect of it, for a partial update. Hand either to whatever talks to the device.
$frame = $screen->bytes();
$box = $screen->region(20, 20, 100, 50);
printf("frame %d bytes, box %d bytes\n", strlen($frame), strlen($box));
printf("pixel (20, 20) = 0x%04X, first two bytes %s\n", $screen->get(20, 20), bin2hex(substr($box, 0, 2)));
printf("pixel (0, 0) as RGBA8 = 0x%08X\n", $format->unmapRgba8($screen->get(0, 0)));$ php panel.php
frame 64800 bytes, box 10000 bytes
pixel (20, 20) = 0xF800, first two bytes f800
pixel (0, 0) as RGBA8 = 0xFF8200FFThe orange went in as (255, 128, 0) and comes back as 0xFF8200: RGB565 keeps six bits of green, and the word is what the panel will show.
The SSD1306 stores its 128 × 64 pixels as eight pages of 128 column bytes, the top row of each page in bit 0. setCoordinates() answers the box it touched, and granularity() says the smallest block whose bytes stand alone, so a partial update can be rounded out to whole pages.
$oled = new FbBuffer(new FbFormat(FB_LAYOUT_MONO_PAGES, bitOrder: FB_BIT_ORDER_LSB), 128, 64);
$dirty = $oled->setCoordinates([[3, 1], [4, 1], [5, 9]], 1);
echo json_encode($dirty), ' ', json_encode($oled->granularity()), "\n";
echo strlen($oled->bytes()), " bytes; page 0, column 3 = 0x", bin2hex($oled->bytes()[3]), "\n";
echo strlen($oled->region(0, 0, 128, 16)), " bytes for pages 0 and 1\n";[3,1,3,9] [128,8]
1024 bytes; page 0, column 3 = 0x02
256 bytes for pages 0 and 1paintSpans() takes 7-byte spans, y, x and length as uint16 and coverage as uint8, little-endian: the bytes ext-rasterize answers for paths, ellipses, rings and lines. RGB and grey layouts blend the colour source-over by alpha × coverage; mono, palette and planar layouts write it where that reaches half.
$disc = (new RasterScanner(0, 0, 64, 64, true))->ellipse(32, 32, 20, 20);
$buffer = new FbBuffer(new FbFormat(FB_LAYOUT_RGBA8888, channelOrder: FB_CHANNELS_RGBA), 64, 64);
$buffer->fill(0x000000FF);
echo json_encode($buffer->paintSpans($disc, 0xFF8000FF)), "\n";
printf("centre 0x%08X, rim 0x%08X, outside 0x%08X\n", $buffer->get(32, 32), $buffer->get(12, 30), $buffer->get(0, 0));[12,12,40,40]
centre 0xFF8000FF, rim 0xF07800FF, outside 0x000000FFpaintRgba8() places an RGBA8 image through an inverse placement: six numbers [a, b, c, d, e, f] that map the centre of each target pixel (x, y) back to the image point (a·x + c·y + e, b·x + d·y + f). Scaling by four is [0.25, 0, 0, 0.25, 0, 0]; a rotation or a shear is the inverse of its matrix. Pass $smooth to sample the four source pixels around each point instead of the nearest one.
// A 2 × 2 image: red, green / blue, white.
$image = pack('N*', 0xFF0000FF, 0x00FF00FF, 0x0000FFFF, 0xFFFFFFFF);
$buffer = new FbBuffer(new FbFormat(FB_LAYOUT_RGB888), 8, 8);
$buffer->paintRgba8($image, 2, 2, [0.25, 0, 0, 0.25, 0, 0], 0, 0, 8, 8);
printf("%06X %06X %06X %06X\n", $buffer->get(0, 0), $buffer->get(7, 0), $buffer->get(0, 7), $buffer->get(7, 7));FF0000 00FF00 0000FF FFFFFFregion() with a second format maps every pixel of the rect through RGBA8 into it. One buffer can feed a panel in its own layout and, from the same pixels, a texture, a PNG encoder, or a 32-bit framebuffer that stores blue first. $screen is the RGB565 panel buffer from Basic usage.
$bgra = $screen->region(0, 0, 240, 135, new FbFormat(FB_LAYOUT_RGBA8888, channelOrder: FB_CHANNELS_BGRA));
$rgba = $screen->rgba8(); // the same pixels as RGBA8, top-left first| OS | Linux or macOS. composer.json lists those two families. |
| PHP | 8.4 or newer, NTS or ZTS, 64-bit or 32-bit. On a 32-bit PHP a buffer's sides stop where its bytes would no longer fit a zend_long: about 23,166 square. |
| Libraries | None. The extension is plain C on the Zend API. |
To build it you need a C compiler and the development files of the PHP you intend to load it into:
# Debian, Ubuntu, Raspberry Pi OS
sudo apt install build-essential php8.4-dev
# macOS: Xcode Command Line Tools, and Homebrew's PHP ships phpize and php-config
xcode-select --install
brew install php@8.4PIE is the PHP Foundation's installer for extensions. It downloads the tagged source from Packagist, builds it against the PHP on your PATH, installs the module and enables it. sudo where your PHP's extension directory is root-owned, as on Debian; not on a Homebrew Mac:
pie install php-io-extensions/fbThe repository ships two installers, one per platform. Each one builds for each PHP it targets, installs fb.so into that PHP's extension_dir, writes 30-fb.ini into its conf.d scan directory, and checks the module loads. The macOS one builds in a temporary copy of the checkout and ad-hoc signs the module so the system loads it on first use; the Debian one builds in the checkout, writes the ini into the CLI, FPM and Apache conf.d of that PHP, and cleans the build products out afterwards.
git clone https://github.com/php-io-extensions/fb.git
cd fb
./install-debian-trixie.sh # Debian Trixie, Ubuntu 24.04+, Raspberry Pi OS: the php on PATH
./install-macos.sh # Homebrew php@8.4 and php@8.4-zts
./install-macos.sh /path/to/bin/php # the macOS script takes specific PHP binariesgit clone https://github.com/php-io-extensions/fb.git
cd fb
phpize
./configure --enable-fb
make -j"$(nproc 2>/dev/null || sysctl -n hw.ncpu)"
sudo make installThen enable it: add extension=fb.so to your php.ini, or drop that line into a file in the scan directory that php --ini reports.
$ php --ri fb
fb
fb support => enabled
Version => 0.10.xTwo final classes and 23 constants, declared in stubs/. Coordinates count from the top-left pixel. A coordinate outside the buffer, a rect that is empty or not inside it, or a malformed argument throws ValueError, and a list is checked whole before anything is written, so a refused call leaves the buffer as it was. Neither class can be cloned or serialized, and calling __construct() a second time throws Error.
| Layout | Bytes | Pixel word |
|---|---|---|
FB_LAYOUT_MONO_ROWS |
1 bit per pixel, rows padded to a byte | 0 or 1, where 1 is lit, white |
FB_LAYOUT_MONO_PAGES |
one byte per column per 8-row page, as an SSD1306 stores it | 0 or 1 |
FB_LAYOUT_INDEX2, FB_LAYOUT_INDEX4, FB_LAYOUT_INDEX8 |
2, 4 or 8 bits per pixel, rows padded | a grey level, or a palette code |
FB_LAYOUT_RGB444 |
two pixels in three bytes | 0xRGB |
FB_LAYOUT_RGB565 |
two bytes | RGB565 |
FB_LAYOUT_RGB666 |
three bytes, six bits left-aligned in each | 0xRRGGBB, the low two bits of each channel zero |
FB_LAYOUT_RGB888 |
three bytes | 0xRRGGBB |
FB_LAYOUT_RGBA8888 |
four bytes | 0xRRGGBBAA |
FB_LAYOUT_PLANAR |
one 1-bit plane per ink | a mask of inks; 0 is paper |
| Order | Applies to | Values |
|---|---|---|
| Bit order | mono, index and planar layouts | FB_BIT_ORDER_MSB (the default: the leftmost or topmost pixel in bit 7), FB_BIT_ORDER_LSB |
| Byte order | FB_LAYOUT_RGB565 |
FB_BYTE_ORDER_MSB (the default), FB_BYTE_ORDER_LSB |
| Channel order | RGB layouts | FB_CHANNELS_RGB (the default), FB_CHANNELS_BGR for 12 to 24 bits; FB_CHANNELS_RGBA, FB_CHANNELS_BGRA, FB_CHANNELS_ARGB, FB_CHANNELS_ABGR for FB_LAYOUT_RGBA8888, which needs one of them |
| Scan | the row order region() emits |
FB_SCAN_TOP_DOWN (the default), FB_SCAN_BOTTOM_UP |
An order changes the bytes, never the word: an FB_LAYOUT_RGBA8888 pixel is 0xRRGGBBAA to get() and set() whatever its channel order, and only bytes() shows the difference.
__construct(int $layout, int $bitOrder = FB_BIT_ORDER_MSB, int $byteOrder = FB_BYTE_ORDER_MSB, int $channelOrder = FB_CHANNELS_RGB, int $scan = FB_SCAN_TOP_DOWN, array $palette = [])
Describes a byte layout. Immutable.
$layout— one of theFB_LAYOUT_*constants.$bitOrder,$byteOrder,$channelOrder,$scan— the orders above. A bit or byte order the layout doesn't use is accepted and has no effect. A channel order on a layout without channels, or a three-channel order onFB_LAYOUT_RGBA8888and a four-channel one on the others, throwsValueError.$palette— one[int $rgb, bool $inverted, int $code]per ink, at most 16: the colour as0xRRGGBB, whether a planar plane stores the ink as 0, and the word an index layout stores for it.FB_LAYOUT_PLANARneeds one; an index layout without one holds grey levels; any other layout refuses one.
Colours map with integer arithmetic on 0..255 channels: Rec. 709 luma for mono, lit at half, and for grey; the nearest palette entry for index and planar layouts, the earlier entry winning a tie; bit packing for RGB.
Example — a black, white and red e-paper panel
// Paper is white; ink 1 is black, ink 2 is red.
$epaper = new FbFormat(FB_LAYOUT_PLANAR, palette: [[0x000000, false, 1], [0xFF0000, false, 2]]);
echo $epaper->mapRgba8(20, 20, 20), ' ', $epaper->mapRgba8(250, 10, 10), ' ', $epaper->mapRgba8(255, 255, 255); // 1 2 0What the format was made with.
The bytes a $width × $height surface takes in this format. Sides run 1..65535.
Four 0..255 channels to this format's pixel word.
A pixel word back to 0xRRGGBBAA, showing what the layout keeps of a colour.
Holds one $width × $height surface in $format, sides 1..65535, allocated through PHP's allocator so it counts against memory_limit. It starts blank: every byte zero, and a planar plane whose ink is inverted at 1.
The format it was made with, its sides, and the bytes it holds.
[$unitWidth, $unitHeight]: the smallest block of pixels whose bytes stand alone. [$width, 8] for FB_LAYOUT_MONO_PAGES, whose bytes are whole columns of a page; [1, 1] for every other layout.
Reads or writes one pixel word. set() masks the value to what the layout stores.
Writes a list: one [$x, $y, $value] per pixel, or one [$x, $y] per pixel all in $value. Answers [$x, $y, $width, $height], the box the list touched, or null for an empty list. A list with any pixel outside the buffer is refused whole.
Example
$box = $screen->setPixels([[1, 1, $red], [3, 2, $red]]); // [1, 1, 3, 2]Fills a rect, which must lie inside the buffer, or the whole buffer. Padding bits at the end of a row are never written.
The raw store, top row first, exactly as the layout lays it out.
One plane of a planar buffer, one per palette entry in palette order. Layer 0 of any other layout is the whole store.
A rect's bytes, its rows in the format's scan order. With no format the words are copied as stored, in this buffer's own format; with one, every pixel is mapped through RGBA8 into it.
The whole buffer as RGBA8 bytes, top-left first.
One bit per pixel, rows padded to a byte, x 0 in bit 7: set where the pixel's word equals $value. A mask for a panel that takes one colour at a time.
blitRgba8(string $rgba8, int $width, int $height, int $x, int $y, int $clipX = 0, int $clipY = 0, ?int $clipWidth = null, ?int $clipHeight = null): ?array
Maps a $width × $height RGBA8 image into the buffer with its top-left at ($x, $y), words as the format maps them, no blending. Unlike every other call it clips, to the buffer and to the clip rect: a null clip side runs to the buffer's edge. Answers the rect written, or null when nothing landed. $rgba8 must be exactly $width × $height × 4 bytes.
Copies a rect from a buffer of the same size and storage, words as stored.
Paints coverage spans in one colour, 0xRRGGBBAA. $spans holds 7-byte records, little-endian: y, x and length as uint16, coverage as uint8. The effective alpha is alpha × coverage. RGB and grey layouts blend source-over; mono, palette and planar layouts write the colour where the effective alpha reaches 128. Every span is checked before any is painted. Answers the spans' bounding box, or null for none.
paintRgba8(string $rgba8, int $width, int $height, array $inverse, int $x, int $y, int $targetWidth, int $targetHeight, int $opacity = 255, bool $smooth = false, int $row = 0): void
Paints a straight-alpha RGBA8 image through an inverse placement.
$inverse—[a, b, c, d, e, f], six finite numbers. The centre of each target pixel(x, y)maps to the image point(a·x + c·y + e, b·x + d·y + f).$x,$y,$targetWidth,$targetHeight— the rect of the buffer to visit, which must lie inside it. A pixel whose point falls outside the image is left alone.$opacity— 0..255; each pixel blends like a span at source alpha × opacity.$smooth— sample the four source pixels around the point, colours weighed by alpha and edges held, instead of the one under it.$row— for a buffer that is a window onto a taller surface: the surface row its own row 0 stands for, 0..65535.ycounts from there, so each window samples exactly as the whole surface would.
The address of the buffer's bytes, valid while the object lives. Native code can read the pixels in place: pack it into a struct for ioctl(), or hand it to FFI. Keep the object alive for as long as anything holds the address.
- ext-rasterize: paths, ellipses, rings and one-pixel lines into the exact span bytes
paintSpans()takes. - ext-imgdec: PNG, JPEG and TIFF into the straight RGBA8 that
blitRgba8()andpaintRgba8()take. - ext-posi: the wire.
posix_write()putsbytes()orregion()on an SPI or I2C device node, and anSPI_IOC_MESSAGEtransfer can point its buffer straight atpointer().
This codebase was built with LLM assistance under human direction. Prompts, reviews, and architectural decisions were owned by a professional software engineer — the model accelerates drafting and exploration; it does not replace judgment on APIs, dependency direction, or what ships.
This repository carries an Open Knowledge Format bundle at .okf/: Markdown with YAML frontmatter, readable by you and loadable by a coding agent. It records what the README doesn't: how the module is layered, what each call of both classes refuses, which calls move byte runs instead of pixels and the case each one covers, why the build turns off fused multiply-add, the scratch build and both installers, and a log of every change that altered behaviour.
Start at .okf/index.md. AGENTS.md is the short rule sheet an agent reads before touching the C, the stubs, or packaging; each rule points into the bundle for the reason behind it.
Point your agent at the repo and it has the same context the maintainer has. Point yourself at it when the README's answer is "it does X" and you want "because".
src/core.h: every layout's word format and byte packing, documented next to the code that implements it.- PIE: the PHP Foundation's extension installer.
gen_stub.php: the php-src stub format.stubs/holds the API declarations; each*_arginfo.his generated from its stub.- Open Knowledge Format: the spec the
.okf/bundle follows.
The suite is Pest v4 and needs no hardware or image files: every layout packed the way its panel takes it, the colour mapping, refusals, spans, placed images, and every byte-run path held to the per-pixel result over 18 formats. It runs on Linux and macOS. Build the module in the clone, then point php at it:
git clone https://github.com/php-io-extensions/fb.git
cd fb
phpize && ./configure --enable-fb && make -j"$(nproc 2>/dev/null || sysctl -n hw.ncpu)"
composer install
php -n -d extension="$PWD/modules/fb.so" vendor/bin/pestTests: 1 skipped, 975 passed (2784 assertions)
Duration: 0.67sThe skipped test is the one that only runs on a 32-bit PHP. -n skips your php.ini, so the suite tests the clone and not an installed fb.so; without it, a PHP that already loads fb warns Module "fb" is already loaded and keeps the installed one. A module only loads in the PHP whose phpize built it: for Homebrew's ZTS build, run phpize and pass --with-php-config from /opt/homebrew/opt/php@8.4-zts/bin.
Open an issue first for anything beyond a typo. Pull requests change a stub in stubs/ and the C under src/ together, regenerate the *_arginfo.h files with gen_stub, and add a Pest test for the behaviour. A new byte-run path in src/core.c must answer what the per-pixel path answers, which tests/BulkTest.php checks over every format. CI must be green on every cell of the matrix. Read AGENTS.md for the rules the code follows.
ext-fb writes only inside memory it allocated, and hands out that memory's address through pointer(). See SECURITY.md for what it guards against and how to report a vulnerability.
MIT. See LICENSE.