PoiBuilder 0.9.167
/ Retro / The PBM format specification

The PBM format specification

The .pbm v3 binary map format, normative and complete — file layout, chunks, the standard emitters lump, and the consumer rules. What the exporter writes and the reference PSP engine reads.

This page is the normative format document for the .pbm map, maintained in the repository as SPEC_RETRO_FORMAT.md. A writer is the editor's exporter (addons/poibuilder/export/pb_map_exporter.gd); the reference reader is the demo engine's loader (pbm.h + pbm_loader.c). Every byte below is cross-checked against both.

What v3 added on top of that: the standard lumps in §8 — payload layouts the specification defines, carried by the extensible metadata table (§7). They need no version change: a loader that does not know a lump's tag skips it. What v3 changed (breaking): the mesh header grew from 64 to 72 bytes by appending the animated-UV-scroll words uv_scroll_u / uv_scroll_v, and the texture header's has_alpha became a three-valued alpha_mode (NONE / CUTOUT / BLEND). Both changes are what makes scrolling and translucent textures a first-class part of the format instead of something an engine has to infer. v1 and v2 files still load (see §3.1) — their meshes are simply static and their alpha is a cutout.

1. Overview & Architectural Goals

The PoiBuilder Retro Map format (.pbm) is a compact, zero-overhead, memory-mapped binary 3D map format designed specifically for fixed-function and resource-constrained retro hardware (such as the Sony PlayStation Portable, Nintendo DS/3DS, Dreamcast, PlayStation 2) as well as custom low-overhead software and OpenGL ES 1.x/2.0 renderers.

Core Design Principles

  1. Direct DMA / Hardware Alignment: All vertex data is pre-interleaved into a single 24-byte structure matching native GPU registers (GU_TEXTURE_32BITF | GU_COLOR_8888 | GU_VERTEX_32BITF | GU_TRANSFORM_3D on Sony PSP GU). Vertices require zero runtime rearrangement or format conversion.
  2. Zero Runtime Decompression: Textures are stored in native uncompressed power-of-two formats (16-bit RGBA5551 or 32-bit RGBA8888) ready for immediate VRAM upload via DMA (sceGuTexImage).
  3. Texture Swizzling Support: 16-bit textures can be swizzled into 16-byte × 8-row cache-friendly Morton/tile blocks to eliminate memory bus thrashing.
  4. Tile-Based Texture Atlasing: Discrete baked tiles (splats, stamps, decals) are packed into 512x512 texture atlases whose slots map edge-to-edge (a tile samples right up to its slot border and the sampler clamps at the atlas edge; there is deliberately no half-texel inset, which would shift the pattern at every slot boundary).
  5. Baked Vertex Lighting & AO: Static scene illumination (directional sun, omni lights, shadows, and hemisphere ambient occlusion) is fully pre-baked into 32-bit vertex colors (0xAABBGGRR).
  6. Built-in Physical Collision: Dedicated bounding-box and triangle-mesh collision hulls are stored alongside visual meshes for native player collision and raycasting.
  7. Arbitrary Binary Metadata & Entity Scripting: Version 2 introduces an extensible metadata chunk table for embedding arbitrary binary payloads, level descriptors, waypoints, scripting logic, and entity definitions directly within the map file.

2. File Structure & Memory Layout

A .pbm file is composed of five sequential contiguous binary chunks in Little-Endian byte order:

+-------------------------------------------------------------+
| PbmHeader (64 bytes)                                        |
+-------------------------------------------------------------+
| Texture Chunk (num_textures entries)                        |
|   ├── PbmTextureHeader (44 bytes)                           |
|   └── Raw Pixel Buffer (data_size bytes, packed)            |
|   └── ...                                                   |
+-------------------------------------------------------------+
| Mesh Chunk (num_meshes entries)                             |
|   ├── PbmMeshHeader (72 bytes)                              |
|   └── Vertex Buffer (num_vertices * 24 bytes, packed)       |
|   └── ...                                                   |
+-------------------------------------------------------------+
| Collider Chunk (num_colliders entries)                      |
|   ├── PbmColliderHeader (64 bytes)                          |
|   └── Triangle Buffer (num_triangles * 36 bytes)            |
|   └── ...                                                   |
+-------------------------------------------------------------+
| Metadata Chunk (num_metadata entries) [NEW in v2.0]         |
|   ├── PbmMetadataHeader (40 bytes)                          |
|   └── Raw Payload (data_size bytes, 4-byte padded)          |
|   └── ...                                                   |
+-------------------------------------------------------------+

The file is packed. Every chunk follows the previous one back-to-back with no file-level alignment: texture pixel buffers and vertex buffers carry no padding (a consumer that wants aligned buffers for its own hardware aligns its COPY, as the reference PSP loader does when it swizzles textures into VRAM). The one exception is the metadata chunk, where each payload is padded to a multiple of 4 bytes so the next 40-byte header stays aligned.


3. Header Specification

The file begins with an exact 64-byte header:

PbmHeader (64 bytes, packed)

OffsetTypeField NameDescription
0x00uint32_tmagicMagic identifier. Must be 0x334D4250 ("PBM3" in ASCII Little-Endian). Version 2 used 0x324D4250 ("PBM2"), version 1 0x314D4250 ("PBM1").
0x04uint32_tversionFormat major version number. Must be 3 for PBM3.
0x08uint32_tnum_texturesTotal number of texture records in the Texture Chunk.
0x0Cuint32_tnum_meshesTotal number of visual mesh records in the Mesh Chunk.
0x10uint32_tnum_collidersTotal number of collision shapes in the Collider Chunk.
0x14uint32_tnum_metadataTotal number of metadata entries in the Metadata Chunk. (0 in v1).
0x18float[3]spawn_posDefault player camera/spawn position (X, Y, Z) in world meters.
0x24floatspawn_rotDefault player camera spawn yaw orientation in radians.
0x28float[3]bounds_minScene Axis-Aligned Bounding Box (AABB) minimum coordinates (X, Y, Z).
0x34float[3]bounds_maxScene Axis-Aligned Bounding Box (AABB) maximum coordinates (X, Y, Z).
0x40uint8_t[0](end)Total size = 64 bytes.

Versioning & Breaking Change Policy

  • Minor Version Increments: Backwards-compatible additions (e.g. new optional metadata tags or hints) MUST NOT increment the major version.
  • Breaking Format Changes: Any structural change to existing headers, vertex layouts, or chunk order MUST increment PBM_VERSION and update the magic string (e.g. PBM3).
  • Loader Compliance Rule: Loaders MUST inspect header.version. If header.version > PBM_SUPPORTED_VERSION, the loader MUST reject the file with an explicit diagnostic: [PBM] Error: Incompatible map version %u (supported: 1..%u). Breaking format change detected. Loaders supporting version 3 SHOULD maintain backwards-compatibility with version 1 and 2 files.

3.1 Version Differences a Loader Must Handle

v1v2v3
magicPBM1PBM2PBM3
PbmHeader size606464
Metadata chunkabsent (num_metadata = 0)presentpresent
Mesh header size646472
Texture has_alpha0 / 10 / 1alpha_mode 0 / 1 / 2

Practical rule: read the version first, then read PBM_MESH_HEADER_V2 (64) bytes of every mesh header when the version is below 3, and treat the missing uv_scroll_* words as 0,0 (static). A v1/v2 map therefore renders exactly as it did before the upgrade.


4. Texture Chunk Specification

The Texture Chunk contains header.num_textures sequential records. Each record consists of a 44-byte PbmTextureHeader followed immediately by data_size bytes of raw uncompressed pixel data.

PbmTextureHeader (44 bytes, packed)

OffsetTypeField NameDescription
0x00char[32]nameNull-terminated ASCII texture name / identifier (max 31 chars + \0).
0x20uint16_twidthTexture width in pixels. Must be a power of two (≥ 16, e.g. 128, 256, 512).
0x22uint16_theightTexture height in pixels. Must be a power of two (≥ 8, e.g. 128, 256, 512).
0x24uint16_tformatPixel storage format (PBM_TEX_FMT_*).
0x26uint16_talpha_modeHow the surface blends, PBM_ALPHA_* (see below). v1/v2 called this has_alpha and used only 0/1, which load as NONE/CUTOUT.
0x28uint32_tdata_sizeLength of pixel payload in bytes (= width × height × bytes_per_pixel).

Alpha Modes

ConstantValueMeaningEngine behaviour
PBM_ALPHA_NONE0Fully opaqueOpaque pass; mip chain built.
PBM_ALPHA_CUTOUT1Hard-edged transparency (foliage, decals, lace)Alpha-tested in the alpha pass (a threshold near 1/16 of full range); mip chain built with an alpha-preserving combine — ANY opaque texel of a 2x2 block keeps the level's texel opaque, so the silhouette dilates by half a texel per level. A plain box filter would instead make every level further transparent and eat the silhouette, which is why an implementation may be tempted to ship cutouts with no chain; doing so costs a texture-cache miss per fragment on art that is routinely 256x512 or larger.
PBM_ALPHA_BLEND2Soft, partial alpha (water, glass, smoke, wetness overlays)Blended in the alpha pass with a zero threshold (only fully transparent texels are discarded, which keeps early-Z working); mip chain built.

A BLEND texture MUST be stored as PBM_TEX_FMT_RGBA8888. The 16-bit formats carry a single alpha bit, which can only cut a texel out — a soft edge quantised to it becomes a hard one, which is precisely the difference between the two modes. Exporters derive the mode from the authoring tool (in Godot: transparency = Alpha → BLEND, Alpha Scissor/Alpha Hash → CUTOUT, everything else → NONE; in glTF: alphaMode BLEND / MASK / absent).

Pixel Storage Formats

ConstantValueBPPLayout Description
PBM_TEX_FMT_RGBA888803232-bit direct color: 8 bits Red, 8 bits Green, 8 bits Blue, 8 bits Alpha.
PBM_TEX_FMT_RGBA555111616-bit direct color: 5 bits Red, 5 bits Green, 5 bits Blue, 1 bit Alpha (R:0..4, G:5..9, B:10..14, A:15). Halves VRAM usage.
PBM_TEX_FMT_RGBA444421616-bit direct color: 4 bits per channel, including alpha. Legal in the format, not produced by the reference exporters.
PBM_TEX_FMT_RGB56531616-bit direct color: 5/6/5 with no alpha. Legal in the format, not produced by the reference exporters.

The reference exporters emit only RGBA5551 (opaque and CUTOUT: 2 bytes per texel, swizzleable, one alpha bit is enough to cut a hard edge) and RGBA8888 (BLEND: soft alpha needs 8 bits). Consumers that cannot sample 4444/565 MUST reject them explicitly rather than bind them as something else — the reference loader treats any other value as fatal, because a silently mis-sampled texture (a 4444 payload read as 8888) renders garbage that is far harder to diagnose than a refusal.

Memory Swizzling Specification

The FILE stores texels as plain linear, row-major, top-down pixel arrays — no swizzling is applied on the way out, and a loader reads them exactly as written. For 16-bit textures (RGBA5551) targeting Sony PSP GU hardware, a consumer SHOULD swizzle into 16-byte × 8-row tiles when copying the pixels into VRAM (the reference loader does exactly this at load time; the destination buffer is what needs the alignment):

void swizzle_texture_16(uint8_t* out, const uint8_t* in, unsigned int width, unsigned int height) {
    unsigned int block_address = 0;
    unsigned int row_blocks = (width * 2) / 16;
    for (unsigned int y = 0; y < height; ++y) {
        for (unsigned int x = 0; x < width * 2; x += 16) {
            unsigned int block_x = x / 16;
            unsigned int block_y = y / 8;
            unsigned int block_index = block_y * row_blocks + block_x;
            unsigned int block_offset = block_index * 128;
            unsigned int row_in_block = y % 8;
            memcpy(&out[block_offset + row_in_block * 16], &in[y * width * 2 + x], 16);
        }
    }
}

5. Mesh Chunk Specification

The Mesh Chunk contains header.num_meshes records. Each record consists of a 72-byte PbmMeshHeader followed immediately by num_vertices * sizeof(PbmVertex) bytes of interleaved vertex data.

PbmMeshHeader (72 bytes, packed)

OffsetTypeField NameDescription
0x00char[32]nameNull-terminated ASCII mesh name (e.g. "CourtyardFloor_0").
0x20int32_ttexture_idZero-based index into Texture Chunk, or -1 if untextured (vertex color only).
0x24uint32_tnum_verticesTotal vertices. Must be a multiple of 3 (triangles = num_vertices / 3).
0x28float[3]bounds_minMesh AABB minimum coordinates (X, Y, Z) in world space.
0x34float[3]bounds_maxMesh AABB maximum coordinates (X, Y, Z) in world space.
0x40floatuv_scroll_uAnimated UV scroll along U (§5.1). 0.0 = static.
0x44floatuv_scroll_vAnimated UV scroll along V (§5.1). 0.0 = static.

(The 64-byte v2 mesh header ended at 0x40; the two scroll words are what makes a v3 header 72 bytes.)

5.1 Standard Specification: Animated UV Scrolling

The PBM specification formalizes animated scrolling textures through a minimal, universal common denominator: a continuous 2D linear translation offset.

5.1.1 Scope and Guarantees

The specification guarantees only the simplest linear translation model:

uv(t) = uv(0) + t * (uv_scroll_u, uv_scroll_v)

Where:

  • t is scene elapsed time in seconds.
  • uv_scroll_u and uv_scroll_v are float values expressing velocity in texture repeats per second along the surface's local UV axes.
  • 0.0, 0.0 denotes a static mesh.

Intentional Minimalism: The format deliberately does not specify full animated UV node graphs, arbitrary rotation matrices, non-linear spline curves, or procedural UV distortion. Restricting the guarantee to linear 2D translation ensures universal compatibility across:

  1. Fixed-function retro hardware: Sony PlayStation Portable (PSP) Graphics Engine (GE) via hardware coordinate offset registers (sceGuTexOffset), with zero CPU vertex transformation, zero bus traffic, and zero allocation.
  2. Modern GPU pipelines: Direct material shader uniforms (StandardMaterial3D.uv1_offset) or vertex shader additions with near-zero overhead.
  3. Software and minimal rasterizers: Single addition per vertex or scanline iterator step.

5.1.2 Format Constraints

To guarantee correct rasterization across all targets:

  1. Standalone Texture Enforcement: Any mesh with non-zero scroll velocity MUST reference a dedicated, standalone texture. It must NEVER be packed into a shared tile atlas. Atlased textures use clamped sampling within sub-rectangle coordinate slots; applying an offset to an atlased mesh would drag texture coordinates across slot boundaries into neighboring tiles.
  2. Repeat Wrapping (GU_REPEAT / GL_REPEAT): The referenced texture must wrap seamlessly along the scrolling axis.
  3. Alpha Blending: Scrolling surfaces requiring soft transparency (e.g. water streams, mist, glass) use alpha_mode = PBM_ALPHA_BLEND (RGBA8888 with mip chain retained). Hard-edged cutouts use alpha_mode = PBM_ALPHA_CUTOUT.

5.1.3 Sign and Direction Convention

uv_scroll_u and uv_scroll_v describe the direction the pattern travels across the surface:

  • Walls: The surface V axis points vertically upward. A downward-falling waterfall moves toward -V; therefore, falling water has negative uv_scroll_v (e.g. -0.75).
  • Floors: The surface V axis points toward world +Z. A fluid churning away from a wall (toward +Z) travels along +V; under the coordinate offset relation (uv(t) = uv(0) + v · t), advancing the offset translates the pattern outward.
  • Billboards: Rising steam, smoke, or mist moving upward has positive uv_scroll_v (e.g. +0.35).

5.1.4 Authoring Workflow in PoiBuilder

The canonical workflow to author scrolling textures in PoiBuilder:

  1. Shape Creation (Plane): Select New Shape → Plane (or press B / toolbar shortcut).
  2. Surface Drag: Click and drag on any surface (wall, floor, or sloped ramp) to establish a base rectangle parallel to the surface.
  3. Standoff Offset: Release mouse button; move the cursor along the surface normal (clamped ≥ 0) to set the standoff elevation (keeping the sheet clear of wall/floor z-fighting), then click to confirm.
  4. Material & UV Dock: Select the face in Face Mode, navigate to Scrolling Texture (UV Animation) in the dock: - Enter Speed U and Speed V in repeats/second (the dock displays live equivalent m/s translation). - Click Apply Scroll. The material is automatically duplicated so other faces are not unintentionally animated, and metadata poi_uv_scroll is set.
  5. Live In-Editor Preview: The Animate in Viewport checkbox animates the scrolling texture live in the 3D editor viewport at 60 FPS while editing.
  6. Export: Exporting via Export → PoiRetro (.pbm) or GLB automatically writes uv_scroll_u / uv_scroll_v and keeps the texture standalone.

6. Collider Chunk Specification

The Collider Chunk contains header.num_colliders collision hulls. Each record has a 64-byte PbmColliderHeader followed by triangle vertex data if type == PBM_COL_TRIMESH.

PbmColliderHeader (64 bytes, packed)

OffsetTypeField NameDescription
0x00char[32]nameCollider name (e.g. "Collider_NorthWall").
0x20uint32_ttypeCollider type: 0 = BOX, 1 = TRIMESH, 2 = RAMP.
0x24float[3]bounds_minCollider AABB minimum (X, Y, Z).
0x30float[3]bounds_maxCollider AABB maximum (X, Y, Z).
0x3Cuint32_tnum_trianglesNumber of triangles in payload (0 for simple AABB BOX).

(PbmColliderHeader is 64 bytes as implemented and written; there is no padding field in the binary layout.)

  • If num_triangles > 0, exactly num_triangles * 9 * sizeof(float) bytes follow (3 vertices × 3 floats (x, y, z) per triangle, total 36 bytes per triangle).

7. Metadata Chunk Specification (New in v2.0)

The Metadata Chunk contains header.num_metadata entries. This chunk allows embedding arbitrary binary metadata, scripting tables, entity lists, audio triggers, and custom data blocks.

PbmMetadataHeader (40 bytes, packed)

OffsetTypeField NameDescription
0x00char[32]tagNull-terminated ASCII tag identifier (e.g. "map_name", "entities", "waypoints").
0x20uint32_ttypePayload type identifier (PBM_META_*).
0x24uint32_tdata_sizeLength of payload in bytes.

Metadata Type Constants

ConstantValueDescription
PBM_META_RAW0Raw unformatted binary blob.
PBM_META_STRING1UTF-8 / ASCII null-terminated text string.
PBM_META_JSON2Plain UTF-8 JSON object string for flexible high-level scripting.
PBM_META_ENTITY3Binary structured entity record list (see Section 8).

Following each PbmMetadataHeader, exactly data_size bytes of binary data are stored. If data_size is not a multiple of 4, the writer MUST pad with 0x00 bytes to maintain 4-byte alignment for subsequent headers.

Standard Tags Written by the Reference Exporter

The tag space is open — anything not listed here is the author's engine's to define (see §9 for the recipe tags: player_spawn, walkable_mesh, triggers, rigid_bodies, entities). Three tags are written by the reference exporter on every export and read by the reference demo engine:

TagTypePayload
map_namePBM_META_STRINGHuman-readable map title (NUL-terminated UTF-8). Defaults to the export file's base name.
env_presetPBM_META_STRINGEnvironment preset name (dawn / day / dusk / night), stamped from the editor's Time of Day menu; the demo engine rebuilds its sky/fog/ambient from it.
emitters—The standard particle-emitter lump, normative in §8.

8. Standard Lump: Particle Emitters (tag = "emitters")

A standard lump is a metadata entry (§7) whose payload layout this specification defines, rather than the author's engine. "emitters" is the first one: the metadata table is the transport, and the payload is normative. It is a compatible addition within v3 — the chunk is already extensible, and a loader that does not know the tag skips it — so the major version does not move.

8.1 The model: a looping, stateless particle stream

An emitter does not own a particle array. At any scene time t, particle i of an emitter is a pure function of t, i, and the emitter's seed:

age_i(t)  = frac(t / life_i + phase_i)        # 0 at birth, → 1 at death
tau       = age_i(t) * life_i                 # the particle's age in seconds
position  = origin + spawn_i + closed_form(tau)
size      = size_i * curve(tau / life_i)
colour    = colour_curve(tau / life_i)
rotation  = angle0_i + spin_i * tau
frame     = atlas cell at frac(age * anim_loops + anim_offset_i)

Every particle is therefore always alive and always at a different point of its own loop, which is what makes a fixed budget of them read as a continuous emission. Two consequences are worth stating, because they are the reason this model is in the format at all:

  • A runtime needs no per-particle state, no allocation, and no integration: the per-particle constants (life, speed, size, spin, angle0, dir, spawn, phase, anim_offset, wobble_phase) are derived once from the seed, and the per-frame cost is the closed form plus two triangles per particle.
  • The look is deterministic: an implementation that derives the constants as §8.3 specifies shows the same particle field as the reference, so a preview in an authoring tool and the device output can be compared directly.

The cost profile that shaped it, measured on PSP hardware (see §11): fill rate is the only real budget — a full screen of cache-resident textured fill is ≈0.3 ms, one draw call is ≈0.94 µs, and a few hundred transformed vertices are noise. The design goal is therefore "as few fragments as the effect needs", not "as few vertices". Particles may be large, but not many.

8.2 Lump layout (normative)

"emitters" payload =
  PbmEmitterLumpHeader        (16 bytes)
  PbmEmitter records[N]       (176 bytes each)

PbmEmitterLumpHeader

OffsetTypeFieldMeaning
0x00uint32magic0x54494D45 ("EMIT", little-endian)
0x04uint32version1. A loader MUST reject a higher version by skipping the lump
0x08uint32countNumber of PbmEmitter records
0x0Cuint32reservedMUST be 0

PbmEmitter (176 bytes, packed, little-endian)

OffsetTypeFieldMeaning
0x00char[24]nameLabel (debug only)
0x18float[3]posEmitter origin, world space
0x24float[3]dirEmission axis, unit length
0x30floatspreadCone half-angle, radians; PI = sphere
0x34floatspeed_minInitial speed along the particle's own direction, m/s
0x38floatspeed_max
0x3Cfloatlife_minParticle lifetime, seconds (> 0)
0x40floatlife_max
0x44float[3]gravityConstant acceleration, m/s²
0x50floatdampingExponential drag λ, per second; 0 = none
0x54floatsize_minQuad height at birth, metres
0x58floatsize_max
0x5Cfloatsize_midHeight multiplier at the knee (1 = unchanged)
0x60floatsize_endHeight multiplier at death
0x64floataspectWidth ÷ height of the quad (1 = square)
0x68floatangle_minInitial screen-plane rotation, radians
0x6Cfloatangle_max
0x70floatspin_minRotation speed over life, rad/s
0x74floatspin_max
0x78floatwobble_ampLateral sinusoidal displacement, metres (0 = none)
0x7Cfloatwobble_freqWobble frequency, Hz
0x80floatspawn_radiusSpawn sphere radius, metres (0 = point)
0x84floatkneeLife fraction of the mid key, 0.05…0.95 (0.5 typical)
0x88uint32color_start0xAABBGGRR at birth
0x8Cuint32color_mid…at the knee
0x90uint32color_end…at death
0x94int32texture_idTexture chunk index, or -1 for the built-in radial glow
0x98uint16countSimultaneous particles (≥ 1)
0x9Auint16flagsPBM_EMIT_*
0x9Cuint8atlas_colsFlipbook columns (≥ 1)
0x9Duint8atlas_rowsFlipbook rows (≥ 1)
0x9Euint8anim_loopsWhole animation loops per particle lifetime (≥ 1)
0x9Fuint8reserved0MUST be 0
0xA0uint32seedPer-emitter random seed
0xA4float[3]reservedMUST be 0 (minor extensions only)

Flags

ConstantValueMeaning
PBM_EMIT_ADDITIVE1Additive blending (dst = src·srcA + dst). Order-independent, so no sorting is needed — this is the cheap default
PBM_EMIT_Y_LOCKED2Cylinder billboard: the quad's up axis stays world up
PBM_EMIT_VEL_ALIGN4The quad's up axis follows the particle's own velocity (overrides Y_LOCKED)
PBM_EMIT_PHASE_ALIGN8Every particle shares phase 0: a burst repeating once per lifetime, instead of a continuous stream

8.3 Particle derivation (normative)

pbm_rand(seed, index, channel) is this 32-bit avalanche hash — it is part of the format, because it is what makes the particle field reproducible:

static inline uint32_t pbm_hash32(uint32_t x) {
    x ^= x >> 16; x *= 0x7feb352du;
    x ^= x >> 15; x *= 0x846ca68bu;
    x ^= x >> 16;
    return x;
}
static inline float pbm_rand(uint32_t seed, uint32_t idx, uint32_t chan) {
    return (float)(pbm_hash32(seed ^ (idx * 0x9E3779B9u) ^ (chan * 0x85EBCA6Bu)) >> 8)
           * (1.0f / 16777216.0f);          /* uniform in [0,1) */
}

With u_c = pbm_rand(seed, i, c) for particle i:

cQuantity
0life_i = lerp(life_min, life_max, u_0)
1phase jitter (phase_i, below)
2speed_i = lerp(speed_min, speed_max, u_2)
3size_i = lerp(size_min, size_max, u_3)
4spin_i = lerp(spin_min, spin_max, u_4)
12angle0_i = lerp(angle_min, angle_max, u_12)
5wobble_phase_i = u_5 · 2π
6anim_offset_i = u_6
7cone polar angle θ = spread · √u_7
8cone azimuth φ = u_8 · 2π
9, 10, 11spawn point in the sphere: radius spawn_radius · u_9^(1/3), cos θ_s = 2u_10 − 1, azimuth u_11 · 2π

Direction. With t1 any unit vector perpendicular to dir and t2 = dir × t1:

dir_i = dir·cos θ + (t1·cos φ + t2·sin φ)·sin θ

Phase. A continuous emitter spreads its particles evenly through the loop and jitters each one inside its own slot, so no two share a phase:

phase_i = frac(i / count + u_1 / count)        (PBM_EMIT_PHASE_ALIGN: phase_i = 0)

Position (closed form; λ = damping). With τ the particle's age:

λ = 0 :  p = pos + spawn_i + dir_i·speed_i·τ + ½·gravity·τ²
λ > 0 :  k1 = (1 − e^(−λτ)) / λ
         k2 = (τ − k1) / λ
         p  = pos + spawn_i + dir_i·speed_i·k1 + gravity·k2

The damped form is the analytic solution of v′ = −λv + g, so a damped emitter never needs integration either; as λ → 0 it converges to the ballistic form.

Wobble (wobble_amp > 0): with (w1, w2) a right-handed orthonormal basis perpendicular to dir:

p += wobble_amp · (w1·sin(2π·wobble_freq·τ + wobble_phase_i)
                 + w2·cos(2π·wobble_freq·τ + wobble_phase_i))

Size (two segments, meeting at age = knee):

age < knee :  size = size_i · lerp(1, size_mid, age / knee)
age ≥ knee :  size = size_i · lerp(size_mid, size_end, (age − knee) / (1 − knee))

Colour: the same two-segment interpolation over color_start → color_mid → color_end, per channel including alpha. Alpha is the fade: an emitter whose art spans its whole loop should start and end at α = 0, so the loop restart is invisible.

Rotation: angle = angle0_i + spin_i · τ, applied in the quad's plane.

Flipbook: frames = atlas_cols · atlas_rows;

frame = min(frames − 1, floor(frac(age · anim_loops + anim_offset_i) · frames))
cell  = (frame mod atlas_cols, frame / atlas_cols)          # row-major

The cell's UV rectangle is inset by half a texel on every side (0.5 / texture_width in U, 0.5 / texture_height in V) so a filter tap at the cell edge cannot reach the neighbouring frame.

8.4 Rendering (normative intent)

Each particle is two triangles in the billboard plane:

  • the billboard basis is the camera's right/up (Y_LOCKED: world up, and right perpendicular to it; VEL_ALIGN: the particle's velocity and a right vector perpendicular to both);
  • the quad spans size · aspect in width and size in height, rotated by angle in that plane;
  • the vertex colour is the particle's colour, and the texture is sampled with MODULATE (colour × texel) — so a texture's RGB is the particle's shading and its alpha (together with the colour's alpha) is the opacity;
  • emitters are unlit: no baked lighting, no shadows. A particle is its own light source; that is also why additive emitters look right on a dark scene;
  • depth test on, depth write off, backface culling off;
  • additive emitters use dst = src·srcA + dst and need no sorting; blended emitters use dst = src·srcA + dst·(1 − srcA) and MUST be drawn back-to-front. Within one emitter the particles sort by view depth; between emitters, scene order is the intended order.

A runtime MAY drop a particle whose projected edge is below ≈3 px: it covers a handful of fragments while its texture fetch is fully minified, which on the GE is the difference between a cache hit and a main-memory round trip (see §11).

8.5 Textures

A particle texture obeys the same rules as any other texture, plus three specific to emitters:

  1. Standalone. An emitter texture is never packed into a tile atlas: the cell addressing assumes the whole texture is the flipbook.
  2. Small. A texture that fits the hardware's texture cache is sampled at full speed even when minified. 64×64 is the sweet spot on the PSP (8 KB in 5551) and is enough for four 32×32 flipbook cells or one soft 64×64 puff.
  3. No mip chain for emitters. A mip level of a flipbook averages neighbouring frames together; the reference implementation samples level 0 only and relies on the size cull above for the fetch footprint.

For additive emitters, put the falloff in the texture's RGB, not in its alpha: one alpha bit (all the 16-bit formats have) can only cut a texel out, while additive blending multiplies by RGB anyway. An emitter with texture_id = -1 gets a runtime-generated radial glow (white centre → black edge, alpha 1) and is intended for additive use only.

Soft-edged particle art (smoke, mist) needs real alpha and therefore travels as RGBA8888 with alpha_mode = BLEND, exactly like a soft-alpha surface. Hard art (glows, sparks, flame cells) stays RGBA5551 + CUTOUT: half the memory, and it is the difference between fitting the cache and not.

Every flipbook cell SHOULD keep a fully transparent border ring, so a stray filter tap can only find more transparency.

8.6 Authoring in Godot

Emitters are authored as ordinary GPUParticles3D nodes — the editor preview and the device playback are then the same effect. The exporter maps:

PBM fieldGodot source
posnode global position
dirnode basis × ParticleProcessMaterial.direction, normalized
spreadspread (degrees → radians)
speed_min/maxinitial_velocity_min/max
life_min/maxlifetime × (1 − lifetime_randomness), lifetime
gravitygravity
dampingmean of damping_min/max
size_min/maxdraw-pass quad height × scale_min/max × scale_curve(0)
size_mid/endscale_curve(knee) / scale_curve(0), scale_curve(1) / scale_curve(0)
aspectdraw-pass quad width ÷ height
angle_min/maxangle_min/max (degrees → radians)
spin_min/maxangular_velocity_min/max (degrees/s → rad/s)
kneet of the peak of color_ramp's alpha (else of scale_curve, else 0.5)
color_start/mid/endcolor_ramp sampled at 0 / knee / 1, × color
countamount
atlas_cols/rowsmaterial particles_anim_h_frames/v_frames (in BILLBOARD_PARTICLES mode)
anim_loopsanim_speed (Godot counts complete cycles per lifetime too)
flagsBLEND_MODE_ADD → additive; one_shot → phase-aligned burst; particle_flag_align_y → velocity-aligned
seednode seed when use_fixed_seed, else derived from the node name
texture_idthe draw-pass material's albedo texture, registered as a standalone texture

Fields Godot has no concept for are reachable as explicit poi_* metadata on the node — an override list, not a second authoring path: poi_additive, poi_y_locked, poi_wobble_amp, poi_wobble_freq, poi_knee, poi_seed.

Authoring note (PoiBuilder's emitter UI): the flipbook grid is a promise about the texture's layout. A cols x rows grid over an image samples it as cells — art that IS a sheet must say so with the _sheet file-name marker (the shipped ones: particle_flame_2x2_sheet.png, particle_flame_sheet.png, particle_smoke_sheet.png; _<cols>x<rows>_sheet also arms the default grid). A grid over a single-frame image is allowed — it samples the image in slices, one fragment per particle — so PoiBuilder's readout briefly says when that is what the knobs will do.

8.7 Recipes

A looping animation on one quad (the cheapest emitter there is). Set count = 1, size_min = size_max = the quad's size, anim_loops = 1, an atlas whose cells are the animation frames, life_min = life_max = the loop period, color_start = color_end = transparent, color_mid opaque, and no motion at all (speed = 0, gravity = 0, spread = 0). One quad, one draw call, one texture: a torch flame, a magic portal, a fountain plume. size_mid/end can still expand the quad over the loop if the animation breathes.

A campfire. Three emitters at one point: a count = 1 flame quad as above; a small additive glimmer with spread ≈ 25°, speed ≈ 1…2 m/s, gravity ≈ +0.5, life ≈ 1 s for the sparks; and — if the scene allows transparency — a blended soft puff with damping for the smoke column.

Testability note: a looping emitter that is wrong looks exactly like one that is right, in a single frame. Verify motion with two captures at different scene times (see §10).

9. End-to-End Walkthrough: Godot 4 Authoring to Custom Engine Implementation

CRITICAL ARCHITECTURAL DISTINCTION — RECIPES VS. STANDARD: The specific entity tags and structures detailed below ("walkable_mesh", "triggers", "player_spawn", "rigid_bodies", "entities") are EXAMPLE IMPLEMENTATION RECIPES, NOT fixed schema constraints of the specification. Particle emitters are the exception: they graduated from a recipe to a standard lump with a normative payload, defined in §8. The PBMv2 specification defines only the general binary lump transport container (Section 7: 32-byte tag string, 32-bit type integer, 32-bit length integer, and raw payload bytes). The payload data can be anything you want: flat binary structs, UTF-8 JSON, byte-encoded bytecode, dialog trees, navmesh graphs, or audio cue tables. You are completely free to invent your own tags and payload formats for your custom game engine. The walkthrough below demonstrates tested, real-world patterns for authoring custom data in the Godot 3D editor, exporting via PoiBuilder, and consuming them in custom C / Raylib / PSP game engines.
  [ Godot 4 3D Editor ]           [ PoiBuilder Exporter ]            [ Custom Engine / Raylib / PSP ]
  ---------------------           -----------------------            --------------------------------
  1. Place Visual Geometry        PBMapExporter:                     pbm_load("map.pbm"):
  2. Place Marker3D (PlayerSpawn) -> Discovers scene nodes           -> Uploads GPU textures & meshes
  3. Place Area3D (Trigger)       -> Computes world transforms       -> Parses metadata lump table
  4. Place Mesh (Walkable)        -> Extracts AABB bounds / triangles-> Initializes Player at Spawn
  5. Attach Inspector Metadata    -> Encodes JSON / Binary lumps     -> Drops rays onto Walkable Mesh
  6. Click "Export Retro PBM"     -> Writes PBMv2 (64-byte header)   -> Checks Trigger containment
                                                                     -> Steps Physics Ball Pit & Particles

Step 1: Authoring in Godot 4 Editor

In the Godot 3D Viewport and Scene Dock, authoring entities uses standard, intuitive node patterns:

1. Player Spawn Point (tag = "player_spawn", JSON)

  1. Add a Marker3D or Node3D anywhere in your level.
  2. Name the node PlayerSpawn (or any name starting with Spawn).
  3. Rotate and position it where the player should begin.
  4. (Optional) In the Inspector, scroll to Metadata, click Add Metadata, and set camera_fov = 75.0.

2. Walkable Mesh Navigation Surface (tag = "walkable_mesh", Binary Triangles)

  1. When creating stepped terraced stairs, complex ruins, or decorative balustrades, computing exact physics collision against thousands of detailed visual triangles is slow and prone to snagging.
  2. Add an invisible MeshInstance3D named Walkable_Floor spanning the walkable area.
  3. Assign it a simple quad or low-poly ramp surface and set visible = false.
  4. The exporter extracts its triangles into "walkable_mesh" (num_triangles * 9 * sizeof(float)) and excludes it from opaque visual drawing so it does not render twice.

3. Event / Cutscene Trigger Area (tag = "triggers", JSON)

  1. Add an Area3D or simple box MeshInstance3D named Trigger_VaultDoor.
  2. Position and scale it over the doorway or entrance volume.
  3. In the Inspector, under Metadata, click Add Metadata: - event (String): "open_vault_cutscene" - dialogue_id (String): "vault_lore_01" - oneshot (bool): true

4. Particle Emitters (tag = "emitters", standard binary lump)

  1. Add a GPUParticles3D node named Emitter_* and style it with a ParticleProcessMaterial exactly as for a real-time effect; the editor preview is the effect the device will play.
  2. Give it a draw pass: a QuadMesh with a StandardMaterial3D. That material supplies the particle texture and the blend mode (choose Add for fire, sparks and glows; leave it on Mix for smoke and mist). For a flipbook, set the material's billboard mode to Particles and its particles_anim_h/v frames to the sheet's grid.
  3. Export. The exporter writes the standard "emitters" lump (§8) and registers the particle texture as a standalone texture entry.

The mapping table, the fields Godot cannot express, and the runtime semantics are all in §8; this recipe is the authoring workflow, §8 is the contract.

5. Physics Rigid Bodies / Ball Pit (tag = "rigid_bodies", JSON)

  1. Add a container Node3D named BallPit.
  2. In the Inspector, add metadata: - count (int): 24 - radius (float): 0.22 - restitution (float): 0.85 - mass (float): 1.2

6. Arbitrary Custom Gameplay Entities (NPCs, Loot, Audio)

On ANY node in your Godot scene, you can attach arbitrary custom metadata:

  1. Add metadata poi_metadata_tag = "dialogue_npc".
  2. Add your custom fields: npc_name = "Elder Olaru", quest_id = 101, greeting = "Welcome to the Sunken Vault.".
  3. The exporter will package all metadata on that node into a clean JSON lump under tag "dialogue_npc".

Step 2: Exporting from Godot via PoiBuilder

In the PoiBuilder Toolbar, click Export → select PoiRetro (.pbm) → click Export.

Under the hood, PBMapExporter:

  1. Iterates the authored Godot scene tree (root).
  2. Resolves world-space transforms (_get_world_transform).
  3. Dynamically extracts player_spawn, walkable_mesh, triggers, rigid_bodies and custom metadata lumps, and packs every GPUParticles3D into the standard emitters lump (§8).
  4. Subdivides large surfaces into ≤ 384-vertex spatial chunks and packs textures into 512 × 512 atlases.
  5. Writes the .pbm v3 binary file with the 64-byte header and lump table.

Step 3: Loading & Parsing in a Custom Engine (C / Raylib / PSP)

In your custom engine, loading metadata is simple and decoupled:

// 1. Read PBM Header
PbmHeader hdr;
fread(&hdr, sizeof(PbmHeader), 1, file);

// 2. Read GPU textures and visual mesh chunks
// ...

// 3. Read Extensible Metadata Lumps
for (uint32_t i = 0; i < hdr.num_metadata; ++i) {
    PbmMetadataHeader mhdr;
    fread(&mhdr, sizeof(PbmMetadataHeader), 1, file);
    
    uint8_t* payload = malloc(mhdr.data_size + 1);
    fread(payload, mhdr.data_size, 1, file);
    payload[mhdr.data_size] = '\0';
    
    // Skip 4-byte padding
    uint32_t pad = (4 - (mhdr.data_size % 4)) % 4;
    if (pad > 0) fseek(file, pad, SEEK_CUR);

    // Route by tag:
    if (strcmp(mhdr.tag, "walkable_mesh") == 0) {
        load_walkable_triangles((float*)payload, mhdr.data_size / sizeof(float));
    } else if (strcmp(mhdr.tag, "triggers") == 0) {
        parse_triggers_json((const char*)payload);
    } else if (strcmp(mhdr.tag, "player_spawn") == 0) {
        parse_spawn_json((const char*)payload);
    } else if (strcmp(mhdr.tag, "emitters") == 0) {
        parse_emitters_lump(payload, mhdr.data_size);   /* standard lump, §8 */
    } else if (strcmp(mhdr.tag, "rigid_bodies") == 0) {
        init_ball_pit_from_json((const char*)payload);
    } else if (strcmp(mhdr.tag, "dialogue_npc") == 0) {
        spawn_npc_from_json((const char*)payload);
    }
    
    free(payload);
}

Step 4: Real-time Runtime Execution in Custom Engine

In your frame loop:

  1. Ground Snapping: When the player moves, sample ground elevation from the walkable triangles via 2D barycentric raycast: player.y = get_walkable_ground_y(player.x, player.z, 0.0f) + player_eye_height;
  2. Trigger Evaluation: Check if the player position is contained in any trigger's AABB: if (is_in_bounds(player.pos, trigger.min, trigger.max)) fire_event(trigger.event);
  3. Physics Simulation (Ball Pit): Integrate gravity, resolve floor bounces (vy = -vy × restitution), boundary walls, and elastic sphere-sphere collisions: ``c Vector3 diff = Vector3Subtract(b2->pos, b1->pos); float dist = Vector3Length(diff); float min_dist = b1->radius + b2->radius; if (dist < min_dist && dist > 0.0001f) { Vector3 normal = Vector3Scale(diff, 1.0f / dist); float overlap = 0.5f * (min_dist - dist); b1->pos = Vector3Subtract(b1->pos, Vector3Scale(normal, overlap)); b2->pos = Vector3Add(b2->pos, Vector3Scale(normal, overlap)); float k = Vector3DotProduct(Vector3Subtract(b1->vel, b2->vel), normal); if (k > 0.0f) { float impulse = (1.0f + b1->restitution) * k / (b1->mass + b2->mass); b1->vel = Vector3Subtract(b1->vel, Vector3Scale(normal, impulse * b2->mass)); b2->vel = Vector3Add(b2->vel, Vector3Scale(normal, impulse * b1->mass)); } } ``
  4. Particles: Evaluate the emitter lump (§8) — particles are stateless: particle i's position at scene time t is the closed form p_i(t) = p_0 + d_i·v_i·τ + ½·g·τ² (with the analytic damped form when the emitter drags, §8.3). There is nothing to integrate between frames and nothing to allocate; two implementations that follow §8 show the same particle field.

Step 5: Interactive Scratch Project & Re-Export Loop

To safely edit the map, poke around in Godot, and re-export to test on the target:

# 1. Open the isolated scratch project with the showcase map:
./scratch.sh                     # (or ./test.sh scratch)
# 2. Edit geometry, move entities, adjust triggers or ball pit parameters in Godot.
# 3. Export from the editor:  Export -> PoiRetro (.pbm)  (or Modern glTF for other targets)
# 4. Run the map:
./run_viewer.sh                  # Godot retro viewer (visuals: baked / vertex colour / textures / wireframe / colliders + play mode)
./run_psp_hw.sh --app            # the real thing: PSP over USB (performance lives here)

10. Recipe: A Scrolling Texture (Waterfall), Godot → Retro Engine

A worked example of the pattern above, end to end. It is the recipe behind the courtyard waterfall in the showcase map (project/test_scenes/test_map_showcase_builder.gd), and every value in it is read straight out of the Godot material — there is no special-casing anywhere in the pipeline.

Step 1 — Author the surface

  1. New Shape → Plane. The plane is the surface-decoration shape: drag it out parallel to the surface you are decorating (it starts coplanar with it), then move the mouse to offset it clear of that surface and click to confirm. The stand-off is the plane's third dimension — it is not a size — so a waterfall sheet can hang a few centimetres in front of a wall without z-fighting it. Plane values are width/depth only; the offset is placement, so it never reaches the params modal.
  2. Assign the water texture to the face (Material & UV dock) and set its Tiling to fix how many metres one repeat covers. That matters for the speed you pick next: metres per second = speed × (metres per repeat). The dock prints the metres/second figure under the speed fields, so the number can be judged in world terms rather than in texture terms.

Step 2 — Give the material a scroll speed

In the Material & UV dock, Scrolling Texture:

fieldmeaning
Speed U / Speed Vtexture repeats per second, along the face's own U / V axes
Apply Scrollwrites the speed onto the selected faces' material
Clearremoves it (the surface becomes static again)

The sign is a direction, and with the reference implementation (§5.1) the rule that matters for this scene is simple — negative V travels down a wall and away from a wall on the floor:

  • a sheet falling down a wall: Speed V negative (e.g. -0.75),
  • a second, faster sheet in front of it: -1.15 (the parallax reads as depth),
  • churn spreading away from the base of the fall, and a pool drifting with it: also negative (e.g. -0.30),
  • rising mist on a billboard: positive, because a sprite's V runs down its own face — the one place the sign flips.

Applying a speed duplicates the material when other faces share it, because the animation is a property of the material — that is the unit the exporters split meshes by.

Step 3 — Choose the transparency

Water almost never wants to be opaque. Set the material's Transparency and the exporter carries it through:

GodotglTFPBM alpha_modeResult in the engine
Disabled(absent)NONEopaque pass, mip chain
Alpha Scissor / HashMASKCUTOUTalpha-tested, no mip chain
AlphaBLENDBLENDblended, RGBA8888 + mip chain

Alpha on the waterfall sheet is what lets the stone read through the thin parts of the water. Foliage and other hard-edged cutouts stay on Alpha Scissor: their silhouette is 1-bit, and a mip chain would erode it.

Step 4 — Export

Toolbar Export → PoiRetro (.pbm). For each scrolling face the exporter writes the mesh's uv_scroll_u/uv_scroll_v, keeps its texture out of the tile atlases (a scrolling atlas tile would drag across its slot), and stores a blended texture as RGBA8888. Nothing else changes: lighting still bakes into vertex colours, tiles still atlas, colliders still export.

The same data also rides a GLB export (Modern mode), because the speed is mirrored into the material's glTF extras as {"poi_uv_scroll": [u, v]} for any glTF consumer.

Step 5 — Consume it in the engine

/* Per frame, before the draw calls: advance each animated mesh's texture
 * coordinates. On the GE this is one register write per axis, and only for
 * meshes whose offset actually changed — a static scene emits none. */
static void apply_uv_scroll(PbmMap* map, PbmMesh* mesh, float t,
                            float* cur_u, float* cur_v) {
    float su = mesh->uv_scroll_u, sv = mesh->uv_scroll_v;
    if (mesh->texture_id < 0 ||
        strstr(map->textures[mesh->texture_id].name, "TileAtlas")) {
        su = sv = 0.0f;              /* atlases must never be offset (see 5.1) */
    }
    /* The register holds one repeat: wrap, so a long-running clock keeps full
     * fixed-point precision. */
    float u = t * su, v = t * sv;
    u -= floorf(u);
    v -= floorf(v);
    if (u != *cur_u || v != *cur_v) {
        sceGuTexOffset(u, v);        /* pattern velocity; the GE inverts it */
        *cur_u = u; *cur_v = v;
    }
}

Draw scrolling meshes in the same passes as everything else: BLEND meshes belong to the alpha pass (after the opaques), and two translucent layers must be emitted back to front. Scene-tree order is preserved into the file, so list the far sheet before the near one.

Step 6 — Verify it actually moves

A scrolling texture that is wrong looks exactly like one that is frozen. Check the rendered result, not the numbers:

cd retro_engine/psp
./run_psp_headless.sh     # builds, runs the benchmark, writes two PNGs

The benchmark captures twice — at frame 60 and 20 frames later, with the camera frozen — into screenshot_psp.png and screenshot_psp_scroll.png. Diff them, or correlate the scrolling mesh's pixels. To isolate the animation from everything else, zero every uv_scroll field in a copy of the map, capture the same frame from both, and diff: the pixels that change are exactly the ones the scroll owns. (Remember the capture's alpha byte is meaningless — the framebuffer is 5551 — so drop it before viewing, or the image looks blank.)

If the pattern moves the wrong way, the sign flipped somewhere: the file's meaning is "where the pattern travels", and hardware offset registers are commonly the opposite of it (see the implementation note in §5.1).

11. Getting the Most Out of the Format (Performance & Fidelity Rules)

A .pbm says nothing about how fast it will run: the same file renders at 200 fps or 35 fps depending on decisions the consuming engine makes and the exporter baked. Every number below was measured on a real PSP; the raw tables and the method are in psp/HARDWARE-TESTING.md, the engine-side rationale is in psp/OPTIMIZATION.md, and the authoring-side companion is ../RETRO-AUTHORING.md.

11.1 Textures decide everything

The GE's texture cache is ~8 KB. A fragment whose sampled mip level fits it costs ~2 ns; one that misses costs ~37 ns — measured as 480 vs 27 Mfrag/s, a 19x per-fragment penalty, and the single most important number in this document.

A conforming consumer MUST:

  1. Build a mip chain for every texture at load (one buffer per level, down to 16x16). Without a chain, every minified surface pays the 19x; with one, the fetch footprint collapses to roughly one texel per pixel.
  2. Sample the chain with a mipmap minification filter (GU_LINEAR_MIPMAP_NEAREST on PSP). The plain filters ignore the chain entirely — this is the mistake that makes a "fixed" build measure no faster.
  3. Keep magnification linear, and drop trilinear unless it is paid for: it doubles the fetch set (two levels per fragment) for a level-crossing smoothness that per-primitive LOD steps through anyway. Measured at the worst view: 25.21 ms (trilinear) vs 14.61 ms (single level) at equal bias.
  4. Swizzle 16-bit levels, keep 32-bit levels linear. The 16-bit swizzle layout does not apply to RGBA8888; each level needs its own base/size register either way.
  5. Downsample alpha-aware: opaque and BLEND textures by alpha-weighted box filter; CUTOUT textures with an ANY-opaque-wins combine, because averaging a 1-bit alpha erodes the silhouette away by the fourth level (and shipping cutouts without a chain costs the full 19x on 256x512 foliage: measured 6.09 → 0.46 ms when the chain arrived).
  6. Clamp atlas sampling, repeat standalone textures. An offset applied to an atlas slot drags the tile across its border; a scrolling texture must therefore never be atlased (§5.1.2).

Recommended LOD policy, and the cliff it sits on:

  • Bias the level one step coarser than "sharpest" (+1). The hardware's automatic level is the sharpest that still averages ~1 texel per pixel, and at that level the sampled footprint is the surface's on-screen area — so a forward bias is what keeps a close-up surface inside the cache. A negative bias samples past the boundary and costs 10-100x at close range: at the showcase's worst view, 25.21 ms (bias -1.0) → 11.20 (0.0) → 4.32 (+0.5) → 2.79 (+1.0) → 1.02 (+2.0).
  • Override the bias per mesh where detail matters. The GE picks one level per primitive, so a floor crossing several levels in a few metres steps in sharpness at every tile boundary — invisible at level 0-1, a visible band once the level is coarse. Pinning the baked tile/splat meshes to ONE constant level removes the step entirely: measured 0.57 ms gpu for the painted floor at a grazing view (326 fps) against 1.68 ms pinned at level 0 and 26.2 ms with no chains at all.
  • All of it is tunable at runtime in the reference engine (poi_render.txt: bias=, filter=, level_mode=, detail_mesh=, detail_bias=, detail_const=, cutout_mips=, mips=), so a policy can be A/B'd on the device without a rebuild.

11.2 Fill: budget in screens per frame, not in objects

A 480x272 screen is 130 560 fragments = ~0.27 ms of untextured fill, measured at 487-490 Mfrag/s. A 60 fps budget is 16.67 ms. Consequences:

  • Count screens, not models: the showcase's worst view spends ~10 screens of fill (4.98 ms/frame) and its most common views ~3-5.
  • Every blended surface re-shades the pixels underneath it, so stacked alpha (water sheet + core + spray + pool + foam) is the most expensive kind of content per visible pixel. It is affordable at showcase sizes; a screen-filling stack of it is not.
  • Close-up hero surfaces cost the most (their sampled level is the biggest, and they cover the most screen). That is where to look first when a view is slow.
  • Backface culling is worth ~3 ms at the showcase's worst view. The format's winding convention (triangles wound CCW seen from the front, i.e. the outward normal) exists so consumers can cull; do not disable it.

11.3 Geometry & submission

  1. Vertex layout: 24-byte interleaved float u, v; uint32_t color; float x, y, z; — GU_TEXTURE_32BITF | GU_COLOR_8888 | GU_VERTEX_32BITF | GU_TRANSFORM_3D. Consumers submit the buffer directly; there is nothing to convert.
  2. Draw calls and triangles are cheap (~0.94 µs per draw call, ~2.2 M tris/s). At the showcase's 25-30 draws and ~1 500 triangles they are not the bottleneck; two orders of magnitude of fill is.
  3. Spatial chunking (exporters emit ≤ 384-vertex chunks) exists so that a mesh's bounds are local: one draw call per neighbourhood, bounded per-mesh cost, and tight AABBs for any consumer that wants them. It is not a culling mechanism — the reference runtime draws every chunk every frame, and measured that culling was never the win.
  4. Near plane: keep it tight (0.05-0.10 m; the reference uses 0.08). A 0.5 m near plane puts the floor within arm's reach inside the clip volume and forces the hardware clipper to split large treads and floor quads.
  5. Clipping is effectively free on this hardware: a guardband-crossing quad measures 0.26 ms with clip planes on and off. Keep GU_CLIP_PLANES on for correctness and the guardband (sceGuViewport(2048, 2048, 480, 272) + sceGuOffset(2048 - 240, 2048 - 136)) for the virtual coordinate space, but do not expect either to be the reason a frame is fast or slow.

11.4 Depth: tested, not written

The reference runtime tests depth (GU_LEQUAL) but never writes it — GU_DEPTH_MASK stays off in every pass. That makes draw order the visibility order, which is exactly what the exporters arrange: opaque meshes are emitted so the surviving surface is drawn last, and transparent surfaces are emitted in blend order (a scrolling sheet is emitted before the core it must blend onto). Writing depth is measured free on this content (0.114 vs 0.114 ms; 2.749 vs 2.735; 0.688 vs 0.683) and buys real occlusion, but it exposes z-fighting between the coplanar floor layers (base material quad and baked tile quads at the same height) that the current arrangement hides — so consumers choosing to write depth should verify those layers. The reference knob is depth_write=1.

11.5 Particles

  • The emitter lump (§8) is stateless by design: particle state is a pure function of (t, index, seed), so a consumer evaluates a few flops per particle per frame with no state, no allocation and one draw call per emitter.
  • 256 particles per map is the format's budget; measured, the whole budget costs 0.72 ms gpu / 1.14 ms cpu — about a fifth of a frame. Particle count is cheap; particle screen area is not.
  • Additive emitters need no sorting (order-independent) and should be the default near the camera. Blended emitters must be drawn back-to-front and pay their fill like any surface; keep them small or distant.
  • An emitter texture must be small and power-of-two, and a multi-cell flipbook must not be sampled with a mip chain (a mip level would average neighbouring frames together) — single-cell emitter textures should use the chain, which is what keeps the mist from costing 19x.

11.6 Colliders, metadata and entities

  • Colliders are a separate chunk and cost nothing to render; BOX is an AABB, TRIMESH carries triangles, RAMP is the smooth stair wedge. Pick the cheapest type that is walkable.
  • The metadata table (§7) is the extension point: put level descriptors, waypoints, spawn logic, audio triggers and arbitrary payloads in it rather than inventing new chunks. Unknown tags MUST be skipped, so metadata is the compatible way to add anything.
  • Scripted entities are just data: the reference runtime animates a patrol sphere along waypoints from the metadata lump, and adds one small draw call.

11.7 The shortest version

If a consumer implements only this list, it will be within ~10% of the reference runtime: swizzled 16-bit + linear 32-bit textures, mip chains for everything with the right alpha combine, a mipmap minification filter with a +1 level bias and per-mesh pinning for painted detail, one interleaved vertex buffer per mesh with no conversion, backface culling, alpha-test + blend for CUTOUT/BLEND and nothing else, and additive-before-blended emitters with no sorting required for the additive half.


12. Compliance Verification

A compliant PBM exporter and loader MUST pass the following tests:

  1. magic == 0x334D4250 and version == 3.
  2. Reject files where version > 3 with explicit error logging, and load v1/v2 files (64-byte mesh headers, has_alpha 0/1) with their meshes static.
  3. Successfully load textures with power-of-two dimensions and 16-byte memory alignment.
  4. Successfully parse arbitrary metadata entries by tag and type.
  5. Store a BLEND texture as RGBA8888 and never leave it without its mip chain; build a CUTOUT texture's chain with the alpha-preserving combine (never without a chain, and never with a plain box filter, which erodes the silhouette).
  6. Never apply a UV scroll to a tile-atlas texture, and keep every scrolling mesh's texture standalone.
  7. Load the standard emitters lump (§8): skip a lump whose own version is newer than the loader supports, clamp an over-budget count instead of failing (stating the clamp in the log), and derive the per-particle constants with the specified pbm_rand so two implementations show the same particle field.
  8. Sample a SINGLE-CELL emitter texture through its mip chain and keep a multi-cell flipbook on level 0 (a mip level would average neighbouring frames together); never let an emitter reference a tile-atlas texture.
  9. Draw additive emitters without sorting them and blended emitters back-to-front; leave the emitters unlit.
  10. The .pbm writer's byte layout is locked by project/tests/test_pb_pbm_export.gd (it builds its own fixture scene and walks the file: header, texture table, chunked meshes, colliders, metadata, the emitter lump).