MechaPaint / Specs / .mpbr
FILE FORMAT SPECIFICATIONThe .mpbr material pack
One file, one complete PBR material. Every map, a preview image, a note of where the material came from, and room for anything else you want to carry, packed together so a material travels as a single thing you can drag, share, and archive.
Simple on purpose, open by design
I designed .mpbr so anyone can read it in about twenty lines of code, and anyone can add to it without asking me first. A small header, a block of text, an index, and then ordinary PNG and JPEG files laid end to end. Pull any image out with a plain file copy and it opens in every image viewer you own.
Four parts, in order
The header points at the text and the index. The index points at each payload. A reader works straight through from the top, using the sizes and offsets the file itself states.
The index and every payload start on a 16-byte boundary. The bytes between parts are zero.
The header
The first 32 bytes carry every field version 1.0 needs. The header states its own size and the size of each index entry, so later versions can grow both and every 1.0 reader keeps working.
| Offset | Size | Type | Field | Meaning |
|---|---|---|---|---|
| 0 | 4 | bytes | magic | The four ASCII characters MPBR |
| 4 | 2 | uint16 | major | 1. Changes only when an existing field changes meaning |
| 6 | 2 | uint16 | minor | 0. Goes up when something is added |
| 8 | 4 | uint32 | headerSize | 64 in 1.0 |
| 12 | 4 | uint32 | entrySize | 80 in 1.0. Readers step through the index by this value |
| 16 | 4 | uint32 | entryCount | How many entries the index lists, preview included |
| 20 | 4 | uint32 | metaOffset | Where the meta text starts, measured from the start of the file |
| 24 | 4 | uint32 | metaLength | Length of the meta text in bytes |
| 28 | 4 | uint32 | indexOffset | Where the index starts, on a 16-byte boundary |
| 32 | 32 | bytes | reserved | Zero, kept for future minor versions |
The meta text
Plain UTF-8, one key=value pair per line. It answers the question every material library eventually asks, which is "where did this one come from?" The origin travels inside the file, so the answer stays attached forever.
| Key | Meaning | Example |
|---|---|---|
name | Display name of the material | Old Crate |
origin | The URL or source the material came from | https://example.com/crate |
author | Who made it | Jane Artist |
license | The terms it is shared under | CC0 |
created | The date the pack was written, YYYY-MM-DD | 2026-10-05 |
tool | The program that wrote the pack | MechaPaint |
com.acme.shader | A third-party key. Any key containing a dot belongs to whoever owns that domain | velvet-v2 |
Every key is optional. Split each line at its first = and trim the spaces from both halves. Core keys have no dot. Readers keep every key they find, including ones they have never seen.
The index
One entry per payload, entrySize bytes apart, starting at indexOffset. Each entry names what the payload is, how it is encoded, and exactly where its bytes live.
| Offset | Size | Type | Field | Meaning |
|---|---|---|---|---|
| 0 | 32 | ASCII | tag | What this payload is, zero padded. See tags |
| 32 | 8 | ASCII | codec | How it is encoded, zero padded. png and jpeg in the core |
| 40 | 1 | uint8 | colorSpace | 0 linear data, 1 sRGB color, 2 not an image |
| 41 | 1 | uint8 | flags | Bit 0 is REQUIRED. Other bits are zero in 1.0 |
| 42 | 2 | bytes | reserved | Zero |
| 44 | 4 | uint32 | width | Image width in pixels, 0 for data that is not an image |
| 48 | 4 | uint32 | height | Image height in pixels, 0 for data that is not an image |
| 52 | 4 | bytes | reserved | Zero |
| 56 | 8 | uint64 | offset | Where the payload starts, measured from the start of the file |
| 64 | 8 | uint64 | length | How many bytes the payload takes |
| 72 | 8 | bytes | reserved | Zero, kept for future minor versions |
Width and height repeat what the image file itself says, so a library can show sizes and plan memory without decoding a single image. Offsets are 64-bit, so a pack of 8K maps has all the room it needs.
What each payload holds
Tags are short lowercase text, up to 32 characters of a-z, 0-9, dot, underscore and hyphen. Core tags have no dot. Include the maps your material has and leave the rest out. A pack with only a color map is a perfectly good pack.
| Tag | Color space | Codec | What the pixels mean |
|---|---|---|---|
preview | sRGB | jpeg | A thumbnail of the material, usually a lit sphere around 256 by 256 |
color | sRGB | png or jpeg | The surface color with no lighting baked in |
normal | linear | png | Tangent-space normal, OpenGL convention, green points up |
metal | linear | png | White is metal, black is non-metal, read from the red channel |
rough | linear | png | White is rough, black is glossy, read from the red channel |
ao | linear | png | White is open, dark is tucked away, read from the red channel |
height | linear | png | Middle gray (128) is the surface, lighter is raised, darker is carved |
opacity | linear | png | White is solid, black is clear, read from the red channel |
emissive | sRGB | png or jpeg | Light the surface gives off, black where it gives off none |
regions | not an image | json | The named pieces of a trim sheet or sprite sheet. See named pieces |
Each core tag appears at most once in a 1.0 pack. New core tags arrive only through this spec, in a new minor version.
A sheet that knows its own pieces
A trim sheet or a sprite sheet is a dozen useful things laid out on one canvas. The regions entry writes down where each one is, with an id and a name, inside the pack itself. The layout travels with the art, so the sheet stays useful in every program and every project it lands in. Keep one sheet in memory, and pull out exactly the piece you want as a stamp or a decal, with every map cut to match.
{"width": 767, "height": 771,
"regions": [
{"id": 1, "name": "Plate Plain", "x": 39, "y": 19, "w": 124, "h": 156},
{"id": 5, "name": "Corrugated Horizontal", "x": 15, "y": 198, "w": 359, "h": 173, "tile": "x"},
{"id": 11, "name": "Fan Housing", "x": 16, "y": 587, "w": 170, "h": 144},
{"id": 13, "name": "Hex Door", "x": 409, "y": 570, "w": 132, "h": 177,
"com.acme.pivot": [0.5, 0.5]}
]}
| Key | Meaning |
|---|---|
width, height | The reference canvas the rects are measured on, in pixels. Usually the size of the color map |
id | A whole number from 1 up, unique in the pack. Keep it the same forever, so a project that refers to piece 11 still gets the fan housing after the sheet is repainted |
name | What an artist sees in a list. Any text |
x, y, w, h | The rect in pixels, measured from the top-left corner, the way every image editor counts, and inside the canvas |
tile | Optional. x, y or xy for a trim strip that repeats seamlessly in that direction, none otherwise |
maps | Optional. A different rect for one map, keyed by its tag, for a sheet whose normal map (say) is laid out on its own |
com.acme.anything | Third-party keys, with a dot, on a region or at the top level. Readers keep them |
One list serves every map. Each map scales the rects by its own size divided by the canvas size, so a 4K normal map and a 1K color map cut the same piece. The whole sheet is always usable as one material as well.
Add anything, break nothing
Extensibility was the first requirement, ahead of everything else on this page. You can put your own maps, settings, or data into a pack today, with no registration and no waiting on me, and every reader that came before you keeps working. The practical result is the one that matters: a pack from your tool and a pack from mine open in the same places.
Name it with your domain
A third-party tag is reverse-domain and always contains a dot, like com.acme.sheen or org.blender.cycles.params. If you own the domain, you own the namespace. Core tags never contain a dot, so a third-party tag and a core tag can never collide, and two companies can never collide with each other.
Carry any kind of data
The codec is text too. Use png or jpeg for images every reader decodes, or name your own: exr for HDR maps, ktx2 for GPU-ready textures, json for settings like tiling, IOR, or shader parameters. Set colorSpace to 2 for data that is not an image.
Say how much it matters
Leave the REQUIRED flag clear for anything that adds to the material, such as a sheen map. A reader that knows the tag uses it, and every other reader still loads the material. Set REQUIRED when the material is wrong without your entry, such as layered data that replaces the color map. A reader that handles it loads the pack, and every other reader reports which entry it needs.
Add your own meta keys
The same dot rule covers the meta text. com.acme.shader=velvet-v2 sits beside name and origin, and every reader keeps it.
What a reader does with an extended pack
| The reader meets | Every reader | MechaPaint |
|---|---|---|
| A tag or codec it handles | Uses it | Imports the map into its channel |
| A tag or codec it has never seen, REQUIRED clear | Steps over the entry and loads everything else | Imports every map it reads and lists the extra entries by name in the import report |
| A tag or codec it has never seen, REQUIRED set | Leaves the pack closed and reports the tag | Shows "This pack needs com.acme.layers" and keeps your scene exactly as it was |
| Meta keys it has never seen | Keeps them | Keeps them with the material |
| A newer minor version (1.4, say) | Opens it, using the stored header and entry sizes, and reads the fields it knows | Opens it normally |
| Longer index entries | Steps by entrySize and reads the first 72 bytes of each | Opens it normally |
| A newer major version (2.0) | Reports the version and leaves the pack closed | Shows "made by a newer program, update MechaPaint to open it" |
Writer rules for extensions
- Third-party tags and meta keys contain a dot, and start with a domain you control, written backwards.
- Set REQUIRED sparingly. Only when the material is wrong without the entry. An optional entry reaches the most people.
- Re-saving a pack keeps what you did not write. Copy entries you do not understand byte for byte, unless you changed the core map they were made from.
- Publish your tag. A short page describing what your tag and codec hold lets other tools add support for it.
Lossless where it counts
Every core data map is stored as PNG, so each normal, roughness, and height value comes back out of the pack exactly as it went in.
Data maps are PNG
Normal, metallic, roughness, occlusion, height, and opacity maps are numbers that a renderer does math with. JPEG compresses in 8 by 8 pixel blocks, and in a normal map those blocks show up as tiny dents in the lighting. PNG keeps every value exact, and grayscale data maps compress to a small size anyway.
Color can be JPEG
Base color and emissive are the one place JPEG shines. At quality 100 with full color resolution (4:4:4, no chroma subsampling) the difference is invisible on a painted surface, and the file gets much smaller. Writers choose PNG by default and offer JPEG as an option.
Images stay whole
Each image in the pack is a complete, standard PNG or JPEG file, byte for byte. Nothing is re-encoded on the way in or out, so a pack keeps every bit of quality you put in, and any image library on any platform decodes the maps directly.
A complete reader in thirty lines
- Check the magic and the major version. The first four bytes are
MPBR. Open any minor version of a major you support. - Read the meta text from
metaOffsetformetaLengthbytes, and split it into lines and pairs. - Read the index from
indexOffset, steppingentrySizebytes each time,entryCounttimes. - Check every entry so that
offset + lengthlies inside the file. A file that was cut short is then a clear message instead of a confusing gray map. - Sort each entry by tag and codec into the ones you use and the ones you step over, and close the pack if any REQUIRED entry is in the second group.
- Hand each image's bytes to your PNG or JPEG decoder, as color or linear data according to
colorSpace.
The whole reader, in Python:
import struct
CORE_TAGS = {"preview", "color", "normal", "metal", "rough",
"ao", "height", "opacity", "emissive"}
CODECS = {"png", "jpeg"}
def read_mpbr(path):
blob = open(path, "rb").read()
(magic, major, minor, header_size, entry_size, count,
meta_off, meta_len, index_off) = struct.unpack_from("<4sHHIIIIII", blob, 0)
if magic != b"MPBR" or major > 1:
raise ValueError("unsupported file")
meta = {}
for line in blob[meta_off:meta_off + meta_len].decode("utf-8").splitlines():
if "=" in line:
key, value = line.split("=", 1)
meta[key.strip()] = value.strip()
used, skipped = [], []
for i in range(count):
tag, codec, space, flags, _, w, h, _, offset, length = struct.unpack_from(
"<32s8sBBHIIIQQ", blob, index_off + i * entry_size)
tag = tag.rstrip(b"\0").decode("ascii")
codec = codec.rstrip(b"\0").decode("ascii")
if offset + length > len(blob):
raise ValueError("file is cut short")
entry = {"tag": tag, "codec": codec, "srgb": space == 1,
"data": blob[offset:offset + length]}
if tag in CORE_TAGS and codec in CODECS:
used.append(entry)
elif flags & 1:
raise ValueError("pack needs " + tag)
else:
skipped.append(entry)
return meta, used, skipped
The rules a writer follows
- Write the header with major 1, minor 0,
headerSize64 andentrySize80, and fill every reserved byte with zero. - Write the meta text right after the header, then pad with zeros to the next multiple of 16. That position is
indexOffset. - Leave room for the index, 80 bytes per entry, and pad to the next multiple of 16.
- Write each payload in turn, padding with zeros to the next multiple of 16 after each one, and fill in its index entry as you go.
- Convert on the way in. Store normals in the OpenGL convention, flipping the green channel of a DirectX normal map. Store roughness, inverting a gloss or smoothness map. Store height with middle gray as the surface.
- Build the file in memory and write it once. A pack that fails halfway through then leaves the previous good file untouched.
The order of entries in the file is free. I put the preview first, so a library that only wants a thumbnail finds it in the first few kilobytes.
The bytes of an extended pack
A pack with a preview, four core maps, and two third-party entries: a sheen map and a block of JSON settings. Seven entries in all.
00000000: 4d50 4252 0100 0000 4000 0000 5000 0000 [email protected]... 00000010: 0700 0000 4000 0000 6900 0000 b000 0000 [email protected]....... 00000020: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 00000030: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 00000040: 6e61 6d65 3d4f 6c64 2043 7261 7465 0a6f name=Old Crate.o
And the sixth index entry, the third-party sheen map:
00000240: 636f 6d2e 6163 6d65 2e73 6865 656e 0000 com.acme.sheen.. 00000250: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 00000260: 706e 6700 0000 0000 0000 0000 8000 0000 png............. 00000270: 8000 0000 0000 0000 f03c 0000 0000 0000 .........<...... 00000280: 9700 0000 0000 0000 0000 0000 0000 0000 ................
Built to last
The minor version goes up when something is added: a new core tag, a new meta key, or a new field in a reserved byte or at the end of a longer entry. Every reader of the same major opens it. The major version goes up only when an existing field changes meaning, which I intend to keep as rare as possible.