SplatForge QAT-PLY v1
Bit-level specification for the on-disk PLY-with-quantized-fields
format produced by every SplatForge codec preset
(splatforge-qat-scaffold,
splatforge-qat-bundle,
splatforge-qat-3dgs). Any third-party
Gaussian-Splat renderer can decode a SplatForge-compressed PLY
bit-exactly by following this document — no runtime
dependency on SplatForge code is required.
1 · Overview
QAT-PLY v1 is a strict superset of the canonical Stanford PLY 1.0
binary little-endian format. Every QAT-PLY v1 file is a valid PLY
file: legacy readers that ignore comment lines see a
standard PLY with no surprises. The quantization layer is carried
entirely in:
- Header markers —
comment quantized_field ...lines that declare each quantized field's dtype, channel count, and (for per-channel scales) the base64-encoded scale array. - Body columns — per-vertex
char(signed 8-bit) columns named<field>_q_<i>that carry the quantized values directly, plus (for per-anchor scaled int4 fields) a per-vertexfloatcolumn named<field>_scale.
A complementary header marker
comment constant_field <name> <hex> carries
columns whose value is fp32-constant across all vertices, but
constant-field handling is orthogonal to this spec.
2 · Header markers
The grammar of one quantized-field declaration:
comment quantized_field <NAME> <DTYPE> channels=<C> [scale_b64=<BASE64>] [scale_kind=<KIND>] [packed_per_byte=<N>] -
<NAME>— the logical field name (e.g.f_anchor_feat,f_offset,f_rest). Must match[A-Za-z_][A-Za-z0-9_]*with length1..63. -
<DTYPE>— literal tokenint8orint4. v1 reservesint2,int16for future versions but decoders must reject them in v1. -
channels=<C>— decimal logical channel count. Forint8, the body carries exactlyCcharcolumns. Forint4, the body carriesceil(C/2)packedcharcolumns plus onefloatper-anchor scale column. -
scale_b64=<BASE64>— required when the scale is per-channel (the v1 default forint8).<BASE64>is the RFC 4648 standard-alphabet (+/, padding=) encoding of the raw little-endianfp32bytes of a length-Carray. -
scale_kind=per_channelorscale_kind=per_anchor. Defaults:int8→per_channel;int4→per_anchor. Whenper_anchor, the per-vertex scale lives in afloatcolumn named<NAME>_scaleandscale_b64MUST be absent. -
packed_per_byte=2— required forint4. v1 only supports two nibbles per byte; future versions may add other packings.
Decoders MUST tolerate unknown key=value tokens for
forward compatibility. Tokens MUST be separated by a single ASCII
space or tab. Line endings MAY be LF or CRLF; decoders strip a
trailing CR before parsing. The header is terminated by an
end_header line per PLY 1.0.
2.1 · Example markers
comment quantized_field f_anchor_feat int8 channels=32 scale_b64=zczMPc3MTD6amZk+... comment quantized_field f_offset int4 channels=30 packed_per_byte=2 scale_kind=per_anchor 3 · Body layout
The PLY body follows the
format binary_little_endian 1.0 declared in the
standard PLY header. Element order, property order, and row layout
are all standard PLY — quantized columns are simply additional
property char (signed 8-bit) and property float
declarations.
3.1 · int8 layout
For a field declared int8 channels=C, the body carries
exactly C consecutive property char <NAME>_q_<i>
columns for i = 0 .. C-1. Each value is a signed
int8 in the range [-128, 127]. The PLY type token MAY
be char or uchar: both refer to the same
1-byte storage and the decoder reinterprets the byte as signed.
3.2 · int4 layout
For a field declared int4 channels=C packed_per_byte=2:
-
The body carries
B = ceil(C/2)consecutiveproperty char <NAME>_q_<i>columns fori = 0 .. B-1. Each byte packs two unsigned-shifted 4-bit nibbles. -
For byte index
j: the low nibble (byte & 0x0F) represents channel2j; the high nibble ((byte >> 4) & 0x0F) represents channel2j+1. -
When
Cis odd, the final byte's high nibble is unused and writers MUST set it to0; readers MUST ignore it. -
A single
property float <NAME>_scalecolumn follows the packed columns and carries one fp32 per-anchor scale (little-endian).
4 · Quantization math
Dequantization is symmetric (zero-point = 0) and stateless. The decoder computes:
4.1 · int8 per-channel
signed_q = (int8) byte // [-128, 127]
output[row][c] = (float) signed_q * scale[c] 4.2 · int4 per-anchor
byte_u = (uint8) byte // [0, 255]
nibble = (c % 2 == 0) ? (byte_u & 0x0F) : ((byte_u >> 4) & 0x0F)
signed_q = (int) nibble - 8 // [-8, 7]
output[row][c] = (float) signed_q * scale[row]
All arithmetic is fp32. (float) signed_q is exact for
every value in the encoded range; the only rounding step is the
final fp32 multiply, which rounds-half-to-even per IEEE-754.
Decoders that perform the multiplication in fp64 and
round the result to fp32 at storage time produce the
same byte output and are conformant.
The scale values in the scale_b64 block are stored as
fp32, so a decoder that reads them into fp64
variables MUST first round them through fp32 before
multiplying, otherwise its byte output may differ from the
reference by 1 ULP. The reference C decoder reads scales as
float and performs all arithmetic in fp32, sidestepping
the issue.
5 · Scale-block encoding
For per-channel scales, the scale_b64 token's value is
base64-encoded according to RFC 4648 §4 (standard alphabet,
padding required). Decoded byte length MUST equal
4 * channels. The decoded bytes are interpreted as a
contiguous little-endian fp32 array of length
channels.
| Position | Byte (hex) | Meaning |
|---|---|---|
| 0..3 | xx xx xx xx | scale[0] as little-endian fp32 |
| 4..7 | xx xx xx xx | scale[1] |
| ... | ... | ... |
| 4(C-1)..4C-1 | xx xx xx xx | scale[C-1] |
6 · Worked example
A 1-anchor, 3-channel int8 field
f_anchor_feat. The scales are
[0.1, 0.25, 0.5]. The quantized values for the single
anchor are [-50, 10, 127].
6.1 · Header marker
The three scales as little-endian fp32 bytes:
cd cc cc 3d 00 00 80 3e 00 00 00 3f
(the first 4 bytes are 0x3DCCCCCD = fp32 0.1; next four are 0.25; last four 0.5).
Base64-encoding those 12 bytes yields
zczMPQAAgD4AAAA/. The full marker reads:
comment quantized_field f_anchor_feat int8 channels=3 scale_b64=zczMPQAAgD4AAAA/ 6.2 · Body bytes
With x, y, z all 0.0 and the three quant columns
declared as property char f_anchor_feat_q_0..2, the
single 15-byte vertex record is:
00 00 00 00 00 00 00 00 00 00 00 00 ce 0a 7f
[--- x ---] [--- y ---] [--- z ---] q0 q1 q2
where 0xce = -50 (signed int8),
0x0a = 10, and 0x7f = 127.
6.3 · Dequant
The reference computation is:
(-50) * fp32(0.1) = -5.0
( 10) * fp32(0.25) = 2.5
(127) * fp32(0.5) = 63.5 The resulting fp32 little-endian bytes are:
00 00 a0 c0 00 00 20 40 00 00 7e 42
[ -5.0 ] [ 2.5 ] [ 63.5 ]
A conforming decoder MUST produce exactly those 12 bytes when
reconstructing the three fp32 values. The conformance test
case01_int8_1x1.ply exercises a single-element variant
of this example; case02_int8_4x3.ply extends it to four
anchors.
7 · Conformance
The conformance suite at
apps/codec/conformance/ ships ten test fixtures
covering the full v1 surface:
| Case | What it tests |
|---|---|
case01_int8_1x1 | minimal int8: 1 anchor, 1 channel |
case02_int8_4x3 | typical int8: 4 anchors, 3 channels, mixed signs |
case03_int8_5x32 | realistic int8: 5 anchors, 32 channels (Scaffold-GS anchor-feat shape) |
case04_int4_1x4 | minimal int4: 1 anchor, 4 channels, full nibble range |
case05_int4_3x30 | typical int4: 3 anchors, 30 channels (Scaffold-GS f_offset shape) |
case06_int4_2x5_odd | int4 with odd channel count (5) — exercises the high-nibble-zero rule |
case07_mixed | both int8 and int4 fields in one PLY |
case08_int8_zero | all-zero quantized values (degenerate but legal) |
case09_int8_extreme_scales | scales spanning 1e-10 .. 1e10 |
case10_int8_with_constant | quantized_field plus a constant_field marker |
Each fixture ships with a JSON assertion (in conformance.json)
listing the expected dequantized fp32 output as a base64-encoded
byte string. A decoder is conformant when its dequant output
matches each fixture's expected_fp32_b64 byte-for-byte.
The verifier script
verify.py
cross-checks two independent reference decoders (Python +
C99) against every fixture; both currently agree byte-for-byte
across all 10 cases.
8 · Versioning
Version 1 is identified solely by the presence of
quantized_field comments and the
int8 / int4 dtype tokens. Future versions
will add an explicit version token (e.g.
quantized_field f_x int8 v=2 ...) so v1 decoders MUST
reject any quantized_field token whose dtype is not in
{int8, int4} and MAY skip tokens with unknown
key=value pairs (forward compatibility).
The next iteration (v2, planned) is expected to introduce: int2 / non-symmetric quant (asymmetric zero-point), per-block scale tables (instead of per-channel / per-anchor), and an optional CRC32 footer over the body for tamper detection.
9 · Reference implementations
- C99 reference decoder —
apps/codec/qat-ply-c/. Single header + single.cfile, MIT-licensed, nomalloc, no I/O, no platform dependencies beyond<stdint.h>/<stddef.h>/<string.h>. Vendor it directly. - Python reference decoder — embedded in
verify.py. Useful for tooling and CI checks. - WebAssembly (browser) —
apps/codec/qat-ply-wasm/. Emscripten build of the C decoder; ships as@splatforge/qat-ply-wasmwith theqat_ply_decode.{js,wasm}pair. Build:brew install emscripten && make build. - iOS / macOS Metal —
apps/ios/SplatForgeQATKernel/. SwiftPM module with a Metal compute pipeline (one thread per(anchor × channel)) and a pure-Swift header parser. Build:swift build && swift test. - Android Vulkan —
apps/android/splatforge-qat-vulkan/. Vulkan compute shader (SPIR-V) + JNI bridge + Kotlin facade. Build:./gradlew assembleDebug(Android SDK / NDK 26+).
All four runtime implementations (C / WASM / Metal / Vulkan) are
cross-checked against the Python reference and the same JSON
expectations under
conformance/cross-target/;
any deviation breaks CI. Bindings for additional languages and
runtimes are tracked at
label:qat-ply-v1.
← back to Try it · QAT-3DGS (Inria 3DGS single-PLY) · QAT-Bundle (Scaffold-GS retrain) · QAT-Scaffold