50% OFF Launch sale, MechaPaint is half off.
Click to join in!Come hang out and talk about the future of art as we know it!

MechaPaint / Specs / .mpbr

FILE FORMAT SPECIFICATION

The .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.

AT A GLANCE

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.

Extension
.mpbr
Version
1.0, October 2026
Contents
Base color, normal, metallic, roughness, ambient occlusion, height, opacity, and emissive maps, a preview image, descriptive text, and any third-party data
Named pieces
A trim sheet or sprite sheet carries its own list of pieces, each with an id, a name, and a rect, shared by every map. How it works
Extending it
Text tags and codecs, reverse-domain names for third parties, sizes stored in the file, and a required flag. How it works
Byte order
Little-endian throughout
Image codecs
PNG for every data map, PNG or JPEG for color, JPEG for the preview
Normal maps
OpenGL convention, green up (+Y)
Roughness
Stored as roughness, where white is rough and black is glossy
In MechaPaint
Drag an .mpbr onto the window and it imports as a material, exactly like a material folder
LAYOUT

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.

HEADER64 bytes in 1.0
METAUTF-8 text
INDEX80 bytes per entry in 1.0
PAYLOADSimages and data, 16-byte aligned

The index and every payload start on a 16-byte boundary. The bytes between parts are zero.

PART 2

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.

KeyMeaningExample
nameDisplay name of the materialOld Crate
originThe URL or source the material came fromhttps://example.com/crate
authorWho made itJane Artist
licenseThe terms it is shared underCC0
createdThe date the pack was written, YYYY-MM-DD2026-10-05
toolThe program that wrote the packMechaPaint
com.acme.shaderA third-party key. Any key containing a dot belongs to whoever owns that domainvelvet-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.

PART 3

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.

OffsetSizeTypeFieldMeaning
032ASCIItagWhat this payload is, zero padded. See tags
328ASCIIcodecHow it is encoded, zero padded. png and jpeg in the core
401uint8colorSpace0 linear data, 1 sRGB color, 2 not an image
411uint8flagsBit 0 is REQUIRED. Other bits are zero in 1.0
422bytesreservedZero
444uint32widthImage width in pixels, 0 for data that is not an image
484uint32heightImage height in pixels, 0 for data that is not an image
524bytesreservedZero
568uint64offsetWhere the payload starts, measured from the start of the file
648uint64lengthHow many bytes the payload takes
728bytesreservedZero, 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.

TAGS

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.

TagColor spaceCodecWhat the pixels mean
previewsRGBjpegA thumbnail of the material, usually a lit sphere around 256 by 256
colorsRGBpng or jpegThe surface color with no lighting baked in
normallinearpngTangent-space normal, OpenGL convention, green points up
metallinearpngWhite is metal, black is non-metal, read from the red channel
roughlinearpngWhite is rough, black is glossy, read from the red channel
aolinearpngWhite is open, dark is tucked away, read from the red channel
heightlinearpngMiddle gray (128) is the surface, lighter is raised, darker is carved
opacitylinearpngWhite is solid, black is clear, read from the red channel
emissivesRGBpng or jpegLight the surface gives off, black where it gives off none
regionsnot an imagejsonThe 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.

NAMED PIECES

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]}
 ]}
KeyMeaning
width, heightThe reference canvas the rects are measured on, in pixels. Usually the size of the color map
idA 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
nameWhat an artist sees in a list. Any text
x, y, w, hThe rect in pixels, measured from the top-left corner, the way every image editor counts, and inside the canvas
tileOptional. x, y or xy for a trim strip that repeats seamlessly in that direction, none otherwise
mapsOptional. 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.anythingThird-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.

EXTENDING .MPBR

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 meetsEvery readerMechaPaint
A tag or codec it handlesUses itImports the map into its channel
A tag or codec it has never seen, REQUIRED clearSteps over the entry and loads everything elseImports every map it reads and lists the extra entries by name in the import report
A tag or codec it has never seen, REQUIRED setLeaves the pack closed and reports the tagShows "This pack needs com.acme.layers" and keeps your scene exactly as it was
Meta keys it has never seenKeeps themKeeps 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 knowsOpens it normally
Longer index entriesSteps by entrySize and reads the first 72 bytes of eachOpens it normally
A newer major version (2.0)Reports the version and leaves the pack closedShows "made by a newer program, update MechaPaint to open it"

Writer rules for extensions

  1. Third-party tags and meta keys contain a dot, and start with a domain you control, written backwards.
  2. Set REQUIRED sparingly. Only when the material is wrong without the entry. An optional entry reaches the most people.
  3. 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.
  4. Publish your tag. A short page describing what your tag and codec hold lets other tools add support for it.
IMAGE QUALITY

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.

READING A PACK

A complete reader in thirty lines

  1. Check the magic and the major version. The first four bytes are MPBR. Open any minor version of a major you support.
  2. Read the meta text from metaOffset for metaLength bytes, and split it into lines and pairs.
  3. Read the index from indexOffset, stepping entrySize bytes each time, entryCount times.
  4. Check every entry so that offset + length lies inside the file. A file that was cut short is then a clear message instead of a confusing gray map.
  5. 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.
  6. 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
WRITING A PACK

The rules a writer follows

  1. Write the header with major 1, minor 0, headerSize 64 and entrySize 80, and fill every reserved byte with zero.
  2. Write the meta text right after the header, then pad with zeros to the next multiple of 16. That position is indexOffset.
  3. Leave room for the index, 80 bytes per entry, and pad to the next multiple of 16.
  4. 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.
  5. 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.
  6. 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.

A REAL FILE

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
4d50 4252
Magic, the letters MPBR
0100 0000
Version 1.0
4000 0000
Header is 64 bytes
5000 0000
Each index entry is 80 bytes
0700 0000
Seven entries
4000 0000
Meta text starts at byte 64
6900 0000
Meta text is 105 bytes long
b000 0000
Index starts at byte 176, which is 64 + 105 rounded up to a multiple of 16

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  ................
com.acme.sheen
The tag, with a dot, so it belongs to acme.com
png
A PNG image, so any reader could decode it
00 00
Linear data, REQUIRED clear, so readers that skip it still load the material
8000 0000 x 2
128 by 128 pixels
f03c ...
The PNG starts at byte 15600 and is 151 bytes long
GROWING THE FORMAT

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.

Version 1.0
October 2026. 64-byte header with stored sizes, meta text, 80-byte index entries with text tags and codecs, the REQUIRED flag, nine core tags, PNG and JPEG images.