The SOG Format
SOG (Spatially Ordered Gaussians) is a compact container for 3D Gaussian Splat data. It achieves high compression via quantization (lossy by design), typically yielding files ~15–20× smaller than an equivalent PLY.
You can create SOG files with SplatTransform and preview them in the PlayCanvas Viewer.
This document is the format specification. It describes version 3. The previous version is specified in SOG version 2, and §8 summarizes the changes.
1. File set
A SOG dataset is a set of images plus a metadata file:
| File | Purpose | Channels (8-bit) |
|---|---|---|
meta.json | Scene metadata and filenames | — |
means_l.webp | Positions – lower 8 bits (RGB) + rotation byte 0 (A) | R,G,B,A |
means_u.webp | Positions – upper 8 bits (RGB) + rotation byte 1 (A) | R,G,B,A |
scales.webp | Per-axis sizes via codebook (RGB) + rotation byte 2 (A) | R,G,B,A |
sh0.webp | Base color (DC) + opacity | R,G,B,A |
shN_centroids.webp | SH palette coefficients (optional) | R,G,B |
shN_labels.webp | Indices into SH palette (optional) | R,G,B |
blind_<i>.webp | Per-gaussian blind data, one file per blind texture (optional) | 1–4 of R,G,B,A |
- By default, images should be lossless WebP to preserve quantized values exactly.
- Each property in
meta.jsonnames its file, so other 8-bit RGBA-capable formats may be used. - Do not use lossy encodings for these assets as lossy compression will corrupt values and can produce visible/structural artifacts.
- Many lossless WebP encoders discard the RGB values of fully transparent pixels (alpha = 0) unless told to keep them (libwebp's
exactoption). Rotation bytes are never 0 (§3.2), but a blind texture whose fields use the A byte (§3.7) can contain alpha 0, so it must be encoded with RGB preserved under zero alpha.
1.1 Image dimensions & indexing
All per-Gaussian properties are co-located: the same pixel (x, y) across all property images (except shN_centroids) belongs to the same Gaussian.
- Pixels are laid out row-major, origin at the top-left.
- For image width
Wand heightH, the number of addressable Gaussians isW*H. meta.countmust be<= W*H. Any trailing pixels are ignored.
Indexing math (zero-based):
- From index to pixel:
x = i % W,y = floor(i / W) - From pixel to index:
i = x + y * W
1.2 Coordinate system
SOG stores gaussians in the same coordinate frame as 3DGS PLY files, so positions, rotations and SH coefficients are stored exactly as they were trained.
To place a scene in a right-handed, +y-up world (such as PlayCanvas, where −z is forward), rotate it 180° about z, i.e. (x, y, z) → (−x, −y, z). Apply the rotation to the scene as a whole (for example as the transform of the node holding it): rotations and SH coefficients are defined in the file's frame (§3.5.1).
1.3 Bundled variant
A bundled SOG is a ZIP of the files above. Readers should accept either layout:
- Multi-file directory (recommended during authoring)
- Single archive (e.g.,
scene.sog) containing the same files at the archive root
Readers must unzip and then resolve files using meta.json exactly as for the multi-file version.
2. meta.json
interface Meta {
version: 3; // File format version (integer)
asset?: { // Optional tool/version metadata
generator?: string; // e.g. "splat-transform v1.2.3"
};
count: number; // Number of gaussians (<= W*H of the images)
model?: "antialiased" | "2dgs"; // Optional. How the scene was trained (§3.8); omitted for ordinary gaussians
means: {
// Ranges for decoding *log-transformed* positions (see §3.1).
mins: [number, number, number]; // min of nx,ny,nz (log-domain)
maxs: [number, number, number]; // max of nx,ny,nz (log-domain)
files: ["means_l.webp", "means_u.webp"]; // alpha channels: rotation bytes 0 and 1 (§3.2)
};
scales: {
codebook: number[]; // 256 floats (log-domain); see §3.3
files: ["scales.webp"]; // alpha channel: rotation byte 2 (§3.2)
};
sh0: {
codebook: number[]; // 256 floats; DC coefficients for gamma-space color (§3.4)
files: ["sh0.webp"]; // alpha channel: opacity byte (§3.4)
};
// Present only if some gaussian's opacity exceeds 1 (§3.6):
opacity?: {
max: number; // The largest stored opacity, in (1, 1.5625]
};
// Present only if higher-order SH exist:
shN?: {
count: number; // Palette size (up to 16777216, §3.5)
bands: number; // Number of SH bands (1..4). DC (=band 0) lives in sh0.
codebook: number[]; // 256 floats; shared for all AC coefficients (§3.5)
files: [
"shN_centroids.webp",// Palette of AC coefficients as pixels (§3.5)
"shN_labels.webp" // Per-gaussian palette index (0..count-1)
];
};
// Present only if the scene carries blind data: one entry per blind texture (§3.7)
blind?: {
files: [string]; // e.g. "blind_0.webp"
fields: { // Packed in order into the texel's bytes (R, G, B, A)
name: string; // Application-defined name, e.g. the source PLY column
type: "u8" | "u16" | "u24" | "u32" | "f16" | "f32" | "unorm8" | "unorm16";
range?: [number, number]; // unorm types only: the values code 0 and the top code map to (default [0, 1])
}[];
}[];
extras?: any; // Application-defined JSON (§4)
comments?: string[]; // Free-form producer comments (§5)
}
- The scales codebook contains log-space values that must be passed through
exp()to recover linear sizes (§3.3). The sh0 codebook contains gamma-space DC coefficients. The shN codebook contains gamma-space AC coefficients. - Image data must be treated as raw 8-bit integers (no gamma conversion).
- Unless otherwise stated, channels not mentioned are ignored.
- Filenames in
filesarrays are arbitrary, but the order is significant.
3. Property encodings
3.1 Positions
means_l.webp,means_u.webp(RGB, 16-bit per axis)
Each axis is quantized to 16 bits across two images:
// 16-bit normalized value per axis (0..65535)
const qx = (means_u.r << 8) | means_l.r;
const qy = (means_u.g << 8) | means_l.g;
const qz = (means_u.b << 8) | means_l.b;
// Dequantize into *log-domain* nx,ny,nz using per-axis ranges from meta:
const nx = lerp(meta.means.mins[0], meta.means.maxs[0], qx / 65535);
const ny = lerp(meta.means.mins[1], meta.means.maxs[1], qy / 65535);
const nz = lerp(meta.means.mins[2], meta.means.maxs[2], qz / 65535);
// Undo the symmetric log transform used at encode time:
const unlog = (n: number) => Math.sign(n) * (Math.exp(Math.abs(n)) - 1);
const p = {
x: unlog(nx),
y: unlog(ny),
z: unlog(nz),
};
The alpha channels of both images carry rotation bytes (§3.2).
3.2 Orientation
means_l.a,means_u.a,scales.a(24-bit exponential map)
Quaternion components are ordered (w, x, y, z) throughout.
A rotation is stored as a point p in the unit ball, the quaternion's exponential map scaled so that a half-turn (180°) lies on the unit sphere, with one byte per component:
means_l.astoresp.x,means_u.astoresp.yandscales.astoresp.z.- Each byte
bmaps to(b − 128) / 127. This lattice has a level at exactly 0, so the identity rotation (bytes 128, 128, 128) is exact.
const px = (means_l.a - 128) / 127;
const py = (means_u.a - 128) / 127;
const pz = (scales.a - 128) / 127;
const r = Math.hypot(px, py, pz); // 0..1
const theta = (Math.PI / 2) * r; // half the rotation angle
const k = r > 0 ? Math.sin(theta) / r : 0;
const q = [Math.cos(theta), px * k, py * k, pz * k]; // [w, x, y, z], unit length
Encoding (informative): normalize q, negate it if w < 0 (q and −q are the same rotation), then
const v = Math.hypot(x, y, z);
const k = v > 0 ? Math.atan2(v, w) / v / (Math.PI / 2) : 2 / Math.PI;
const toByte = (c: number) => Math.max(0, Math.min(255, Math.round(c * k * 127 + 128)));
// means_l.a = toByte(x), means_u.a = toByte(y), scales.a = toByte(z)
Because |p| <= 1, every encoded byte is at least 1, so these alpha channels are never fully transparent. Decoding always yields a unit quaternion, so byte combinations with |p| > 1 (which a conforming encoder never produces) still decode to a valid rotation.
Over uniformly random rotations this encoding has a maximum error of about 1.2° and an RMS error of about 0.6°.
3.3 Scales
scales.webp(RGB via codebook)
Per-axis sizes are codebook indices. The codebook stores log-domain values, so decoding requires an exponential:
const sx = Math.exp(meta.scales.codebook[scales.r]); // r,g,b are 0..255
const sy = Math.exp(meta.scales.codebook[scales.g]);
const sz = Math.exp(meta.scales.codebook[scales.b]);
Interpretation (e.g., principal axis standard deviations vs. full extents) follows the source training setup; decoded (post-exp) values are in scene units.
The alpha channel carries a rotation byte (§3.2).
3.4 Base color + opacity (DC)
sh0.webp(RGBA)
sh0 holds the DC (l=0) SH coefficient per color channel and opacity:
- R,G,B are 0..255 indices into
sh0.codebook. - A is the opacity byte. Opacity values above 1 are allowed (§3.6).
To convert the DC coefficient to gamma-space RGB color:
// SH_C0 = Y_0^0 = 1 / (2 * sqrt(pi))
const SH_C0 = 0.28209479177387814;
const r = 0.5 + meta.sh0.codebook[sh0.r] * SH_C0;
const g = 0.5 + meta.sh0.codebook[sh0.g] * SH_C0;
const b = 0.5 + meta.sh0.codebook[sh0.b] * SH_C0;
const a = (sh0.a / 204) ** 2; // linear opacity, 0..1.5625
The opacity byte stores the square root of the opacity, A = (b / 204)². The square root gives fine steps at the low opacities that overlapping translucent layers are built from. Byte 204 is exactly A = 1, where trained scenes keep their saturated gaussians, and bytes 205–255 hold opacities above 1, up to (255 / 204)² = 1.5625.
Encoding (informative): b = min(255, round(204 * sqrt(A))), with A <= 0 stored as 0. Opacities above 1.5625 are clamped.
3.4.1 Color space
Decoded colors (DC plus higher-order SH) are sRGB: the sRGB transfer function with Rec.709 primaries, which is what 3DGS training produces. This corresponds to srgb_rec709_display in glTF's KHR_gaussian_splatting.
Colors are display-referred: scene lighting, exposure and tone mapping don't apply. Blending happens in sRGB, and any conversion to the output's color space happens after blending.
3.5 Higher-order SH (optional)
shN_centroids.webp,shN_labels.webp
If present, higher-order (AC) SH coefficients are stored via a palette:
shN.count∈ [1,2^24] number of entries.shN.bands∈ [1,4] number of bands per entry.
The palette image has ceil(count / 64) rows, so a reader is limited by its maximum texture size. Writers should keep count ≤ 262144 (4096 rows, the size every WebGL2 device supports).
Centroids (palette)
shN_centroids.webpis an RGB image storing the SH coefficient palette.- There are always 64 entries per row; entries are packed row-major with origin top-left.
The texture width is dependent on the number of bands:
| Bands | Coefficients | Texture width (pixels) |
|---|---|---|
| 1 | 3 | 64 * 3 = 192 |
| 2 | 8 | 64 * 8 = 512 |
| 3 | 15 | 64 * 15 = 960 |
| 4 | 24 | 64 * 24 = 1536 |
Calculating the pixel location for spherical harmonic entry n and coefficient c:
const coeffs = [3, 8, 15, 24];
const u = (n % 64) * coeffs[bands - 1] + c;
const v = Math.floor(n / 64);
Labels
shN_labels.webpstores a 24-bit index per gaussian with range (0..count-1).
const index = shN_labels.r + (shN_labels.g << 8) + (shN_labels.b << 16);
Decoding
Each centroid pixel's R,G,B channels are codebook indices (0..255) into the shared shN.codebook. The three channels correspond to the three color channels of the SH coefficient.
To decode the full set of AC SH coefficients for a single gaussian:
const coeffs = [3, 8, 15, 24];
const shCoeffs = coeffs[meta.shN.bands - 1]; // coefficients per color channel
const codebook = meta.shN.codebook; // 256 shared float entries
// 1. Look up this gaussian's palette entry
const label = shN_labels.r + (shN_labels.g << 8) + (shN_labels.b << 16);
// 2. For each coefficient, read RGB from the centroids pixel and map through the codebook
for (let c = 0; c < shCoeffs; c++) {
const u = (label % 64) * shCoeffs + c;
const v = Math.floor(label / 64);
// pixel (u, v) in shN_centroids.webp
const r = centroidsPixel(u, v).r;
const g = centroidsPixel(u, v).g;
const b = centroidsPixel(u, v).b;
sh_channel0[c] = codebook[r]; // SH AC coefficient for color channel 0
sh_channel1[c] = codebook[g]; // SH AC coefficient for color channel 1
sh_channel2[c] = codebook[b]; // SH AC coefficient for color channel 2
}
3.5.1 Basis and evaluation
Within each color channel, coefficients are ordered by band: band 1's 3 coefficients, then band 2's 5, band 3's 7 and band 4's 9.
The coefficients use the real SH basis with the Condon–Shortley phase, as used by the 3DGS reference implementation and gsplat. Bands 1–3 match KHR_gaussian_splatting (Appendix A); band 4 is gsplat's. Colors are evaluated with d, the unit vector from the camera to the gaussian center, expressed in the file's coordinate frame (§1.2):
const [x, y, z] = d; // normalize(center - cameraPosition)
const xx = x * x, yy = y * y, zz = z * z;
const xy = x * y, yz = y * z, xz = x * z;
const basis = [
// band 1
-0.4886025119029199 * y,
0.4886025119029199 * z,
-0.4886025119029199 * x,
// band 2
1.0925484305920792 * xy,
-1.0925484305920792 * yz,
0.31539156525252005 * (2 * zz - xx - yy),
-1.0925484305920792 * xz,
0.5462742152960396 * (xx - yy),
// band 3
-0.5900435899266435 * y * (3 * xx - yy),
2.890611442640554 * xy * z,
-0.4570457994644658 * y * (4 * zz - xx - yy),
0.3731763325901154 * z * (2 * zz - 3 * xx - 3 * yy),
-0.4570457994644658 * x * (4 * zz - xx - yy),
1.445305721320277 * z * (xx - yy),
-0.5900435899266435 * x * (xx - 3 * yy),
// band 4
2.5033429417967046 * xy * (xx - yy),
-1.7701307697799304 * yz * (3 * xx - yy),
0.9461746957575601 * xy * (7 * zz - 1),
-0.6690465435572892 * yz * (7 * zz - 3),
0.10578554691520431 * (zz * (35 * zz - 30) + 3),
-0.6690465435572892 * xz * (7 * zz - 3),
0.47308734787878004 * (xx - yy) * (7 * zz - 1),
-1.7701307697799304 * xz * (xx - 3 * yy),
0.6258357354491761 * (xx * (xx - 3 * yy) - yy * (3 * xx - yy)),
];
// per color channel c, with dc from §3.4 and sh_channel from above
let color = 0.5 + 0.28209479177387814 * dc[c];
for (let k = 0; k < shCoeffs; k++) color += basis[k] * sh_channel[c][k];
color = Math.max(0, color);
A renderer may evaluate fewer bands than the file stores, trading accuracy for speed. When a scene is rotated, either rotate d into the file's frame before evaluating (as above) or rotate the coefficients band by band.
3.6 Opacity above 1
When a decimator merges several gaussians into one, the merged gaussian often needs to carry more total opacity ("mass") than a standard gaussian with opacity 1 can. SOG stores this as an opacity A > 1, up to 1.5625 (§3.4). meta.opacity.max is present, and above 1, exactly when some gaussian's opacity exceeds 1, so a reader can tell without scanning the image.
The format defines A only as the gaussian's total mass relative to a standard gaussian of opacity 1. How that mass is drawn is a rendering choice: nothing about the profile is stored in the file, so renderers can change or tune it without affecting existing files.
A gaussian with A <= 1 renders as a standard gaussian. For A > 1, the recommended profile (used by PlayCanvas and SplatTransform) keeps the gaussian's footprint and peak alpha 1, and adds an opaque core: the inner fraction u0 of the footprint is fully opaque, and the gaussian's usual falloff is stretched over the rest. u0 is solved so that the profile draws exactly A times the mass of a standard gaussian. For a renderer that cuts gaussians off at c σ and subtracts f = exp(−c² / 2) so that alpha reaches 0 at the cutoff:
// Per renderer: constants for its cutoff c (in σ)
const f = Math.exp(-c * c / 2);
const g0 = Math.sqrt(Math.PI / 2) / c * erf(c / Math.SQRT2) - f; // erf: the error function
const g1 = (1 - f) / (c * c) - f / 2;
const qa = (1 - f) / 2 - g0 + g1;
const r0 = (g0 - 2 * g1) / (2 * qa);
const q = g1 / qa;
// Per gaussian: the core radius, as a fraction of the cutoff (0 for A <= 1)
const t = Math.min(Math.max(A - 1, 0), 0.5625);
const u0 = Math.sqrt(r0 * r0 + q * t) - r0;
const peak = Math.min(A, 1);
// Per fragment, r = the Mahalanobis distance from the center, in σ
const s = Math.max(0, r - u0 * c) / (1 - u0);
const alpha = peak * Math.max(0, Math.exp(-0.5 * s * s) - f);
- The footprint, culls and cutoff are the standard gaussian's, so no renderer bounds change. At
A = 1,u0 = 0and the profile is the standard gaussian. - A renderer that also rescales alpha by
1 / (1 − f)uses the same constants. The PlayCanvas engine cuts off atc = 2√2, givingr0 = 0.53839315andq = 0.62504992(u0 <= 0.2625). SplatTransform's renderer cuts off atc = 3(u0 <= 0.24).
A renderer that does not support opacity above 1 must clamp A to 1. The scene then renders as it would have before over-unity opacity was stored.
When converting to PLY, a tool should write opacity = logit(min(A, 1)) and the remainder opacity_excess = max(0, A − 1) as an extra float property, so that tools that ignore opacity_excess still read a valid PLY.
3.7 Blind data (optional)
blind_<i>.webp(one file per blind texture, 1–4 bytes per gaussian each)
Blind data is application-defined data per gaussian (for example an object ID, a segment label or a weight). Renderers do not interpret it, but may expose it to shaders.
Each entry of meta.blind describes one RGBA8 texture. Its fields are packed in order into the texture's 4 bytes per gaussian (R, G, B, A): the first field starts at byte 0 (R), and each following field starts where the previous one ended. Each field takes the number of bytes its type needs and is stored little-endian (the lowest byte first).
type | Bytes | Value |
|---|---|---|
u8, u16, u24, u32 | 1, 2, 3, 4 | Unsigned integer |
f16 | 2 | IEEE 754 half-precision float bits |
f32 | 4 | IEEE 754 single-precision float bits |
unorm8 | 1 | min + (code / 255) * (max - min), with [min, max] = range |
unorm16 | 2 | min + (code / 65535) * (max - min), with [min, max] = range |
- A texture's fields must fit in its 4 bytes.
rangeapplies only to the unorm types and defaults to[0, 1]. A writer quantizesvaluetoround((value - min) / (max - min) * 255)(or* 65535). Values outside the range cannot be stored.- Field names must be unique across all blind textures, and must stay unique and non-empty after conversion to a shader identifier: characters other than
A–Z,a–z,0–9and_become_, runs of_collapse to one, and a leading or trailing_is dropped. (The PlayCanvas engine exposes fieldnameto shaders asgetBlind_<identifier>(), definingSOG_BLIND_<identifier>.) - When converting to PLY, a tool should use each name as the property name: a
uintproperty for the integer types and afloatproperty for the others. - Bytes after the last field are unused. Writers should set an unused R, G or B to 0 and an unused A to 255 (so the image stays opaque), and readers must ignore them.
- Each blind texture is one more texture a renderer samples. Writers should keep to 10 blind textures or fewer, which every WebGL2 device can bind, packing narrow fields into shared textures.
// field j of texture t, for the gaussian at texel (u, v)
const bytesOf = { u8: 1, u16: 2, u24: 3, u32: 4, f16: 2, f32: 4, unorm8: 1, unorm16: 2 };
const fields = meta.blind[t].fields;
let offset = 0;
for (let k = 0; k < j; k++) offset += bytesOf[fields[k].type];
const px = blind[t].pixel(u, v); // [r, g, b, a]
let code = 0;
for (let k = 0; k < bytesOf[fields[j].type]; k++) code |= px[offset + k] << (8 * k);
code >>>= 0; // unsigned 32-bit
As noted in §1, a blind texture whose fields use the A byte can contain pixels with alpha 0 and must be encoded so that RGB is preserved under zero alpha.
In PlayCanvas, shaders read blind fields while splats are copied to the work buffer. See Reading SOG Blind Data.
3.8 Splat model
model records how the scene was trained, which decides how it must be rendered. It is omitted for ordinary 3D gaussians.
"antialiased": trained with a screen-space anti-aliasing filter (Mip-Splatting's 2D filter). A renderer should filter each projected gaussian with that filter instead of the usual 0.3 px² dilation, adding it to the diagonal of the 2D covariance Σ, and scale its opacity bysqrt(det Σ / det(Σ + filter))so that it draws the unfiltered gaussian's mass. Apply this scale before the over-unity profile (§3.6). The filter size is not stored: 0.1 px² (Mip-Splatting's, used by Brush, LichtFeld Studio and Spirula) is the default in PlayCanvas and SplatTransform, and both let it be overridden. See Anti-Aliasing for how PlayCanvas detects and renders these scenes."2dgs": 2D Gaussian Splatting surfels. The third scale is the zero-thickness axis.
4. Extras
extras is an optional top-level property of meta.json holding application-defined JSON, in the spirit of glTF's extras. It may be any JSON value; an object is recommended.
- Readers must not require
extrasand should expose it to the application unchanged. - Tools that convert or process a SOG should carry
extrasthrough to their output. Tools that combine several scenes should keepextrasonly when every input carries identical extras.
5. Comments
comments is an optional top-level array of free-form producer comments, such as the header comment lines of the PLY a scene was converted from (for example "Postshot v1.1.69" or "postshot.color.eotf=sRGB").
- Comments are informational. Readers must not require them or derive rendering behavior from them; anything that changes how a scene renders has its own property (for example
model). - Tools should carry
commentsthrough conversion unchanged, and write them back as PLYcommentlines when converting to PLY (a line break inside a comment becomes a space). They need not repeat comments that restate properties the file already records, such asmodel, or a tool's own generator line. - Comments are not interpreted, so ones that describe coordinates (an origin offset, an up axis) are not updated when a tool transforms the scene.
- Tools that combine several scenes should keep
commentsonly when every input carries identical comments.
6. Example meta.json
{
"version": 3,
"asset": { "generator": "splat-transform v1.2.3" },
"count": 187543,
"model": "antialiased",
"means": {
"mins": [-2.10, -1.75, -2.40],
"maxs": [ 2.05, 2.25, 1.90],
"files": ["means_l.webp", "means_u.webp"]
},
"scales": {
"codebook": [/* 256 floats */],
"files": ["scales.webp"]
},
"sh0": {
"codebook": [/* 256 floats */],
"files": ["sh0.webp"]
},
"opacity": {
"max": 1.5625
},
"shN": {
"count": 65536,
"bands": 3,
"codebook": [/* 256 floats */],
"files": ["shN_centroids.webp", "shN_labels.webp"]
},
"blind": [
{
"files": ["blind_0.webp"],
"fields": [
{ "name": "segment", "type": "u16" },
{ "name": "weight", "type": "unorm8" },
{ "name": "depth", "type": "unorm8", "range": [0.5, 40] }
]
}
],
"extras": { "capturedBy": "rig-04" },
"comments": ["Postshot v1.1.69", "postshot.color.eotf=sRGB"]
}
7. Versioning & compatibility
- Readers must check
version. This document describes version 3; version 2 is specified separately. Readers should reject versions they do not support. - Additional optional properties may appear in future versions; readers should ignore unrecognized fields.
- A version 3 file cannot be stored as version 2 without loss. When writing version 2, a tool should clamp opacity to 1, drop blind data, reduce SH to 3 bands and the SH palette to 65536 entries, and warn about each.
8. Changes from version 2
| Area | Version 2 | Version 3 |
|---|---|---|
| Rotation | quats.webp: 26-bit smallest-three | 24-bit exponential map in the alpha channels of means_l, means_u, scales (§3.2); quats.webp and meta.quats removed |
| Opacity | sh0.a / 255, at most 1 | (sh0.a / 204)², up to 1.5625; meta.opacity.max when above 1 (§3.4, §3.6) |
| SH bands | 1–3 | 1–4 (§3.5) |
| SH palette | up to 65536 entries, 16-bit labels | up to 2^24 entries, 24-bit labels (§3.5) |
| Blind data | — | optional blind textures blind_<i>.webp described by meta.blind (§3.7) |
| Extras | — | optional extras: application-defined JSON (§4) |
| Comments | — | optional comments: free-form producer comments (§5) |
Everything else (positions, scales, DC color, the SH palette layout, indexing and bundling) is unchanged.