Skip to content

Instantly share code, notes, and snippets.

@zeux
Last active December 22, 2025 01:34
Show Gist options
  • Select an option

  • Save zeux/d26340c53dd70d19ae18045e79d065df to your computer and use it in GitHub Desktop.

Select an option

Save zeux/d26340c53dd70d19ae18045e79d065df to your computer and use it in GitHub Desktop.
Script data to generate test assets for KHR_meshopt_compression extension. This was mostly generated using OpenAI Codex CLI + GPT 5.2 and tested by adjusting the injected extension loading code in viewer.html to break individual aspects of the decoding.
import fs from 'node:fs';
import path from 'node:path';
import { MeshoptDecoder, MeshoptEncoder } from 'meshoptimizer';
function alignTo(value, alignment) {
return Math.ceil(value / alignment) * alignment;
}
function bufferFromTypedArray(array) {
return Buffer.from(array.buffer, array.byteOffset, array.byteLength);
}
function createGltf({ meshoptExtension, saveFallbackBuffer }) {
const gltf = {
asset: { version: '2.0', generator: 'khrmeshopt-gen' },
extensionsUsed: ['KHR_mesh_quantization', meshoptExtension],
extensionsRequired: saveFallbackBuffer
? ['KHR_mesh_quantization']
: ['KHR_mesh_quantization', meshoptExtension],
scene: 0,
scenes: [{ nodes: [] }],
nodes: [],
meshes: [],
samplers: [],
images: [],
textures: [],
materials: [],
buffers: [
{ uri: 'MeshoptCubeTest.bin', byteLength: 0 },
{
uri: saveFallbackBuffer ? 'MeshoptCubeTestFallback.bin' : undefined,
byteLength: 0,
extensions: { [meshoptExtension]: { fallback: true } },
},
],
bufferViews: [],
accessors: [],
};
return {
gltf,
bin: [
{ chunks: [], byteLength: 0 }, // buffer 0: compressed
{ chunks: [], byteLength: 0 }, // buffer 1: fallback/uncompressed
],
meshoptExtension,
saveFallbackBuffer,
};
}
function ensureExtensionUsed(builder, name) {
const gltf = builder.gltf;
gltf.extensionsUsed ??= [];
if (!gltf.extensionsUsed.includes(name)) gltf.extensionsUsed.push(name);
}
function appendBinary(builder, bufferIndex, data, { alignment = 4 } = {}) {
const bin = builder.bin[bufferIndex];
const alignedOffset = alignTo(bin.byteLength, alignment);
const pad = alignedOffset - bin.byteLength;
if (pad) bin.chunks.push(Buffer.alloc(pad));
bin.chunks.push(data);
bin.byteLength = alignedOffset + data.byteLength;
builder.gltf.buffers[bufferIndex].byteLength = bin.byteLength;
return { byteOffset: alignedOffset, byteLength: data.byteLength };
}
function appendBufferView(builder, data, { buffer = 1, target, byteStride } = {}) {
const gltf = builder.gltf;
const { byteOffset, byteLength } = appendBinary(builder, buffer, data, { alignment: 4 });
const bufferViewIndex = gltf.bufferViews.length;
const bufferView = {
buffer,
byteOffset,
byteLength,
};
if (byteStride) bufferView.byteStride = byteStride;
if (target) bufferView.target = target;
gltf.bufferViews.push(bufferView);
return bufferViewIndex;
}
function appendCompressedData(builder, source, { count, byteStride, mode, filter = 'NONE', version }) {
const encoded = encodeMeshoptGltfBuffer(builder, source, { count, byteStride, mode, version });
return appendCompressedDataEncoded(builder, encoded, { count, byteStride, mode, filter });
}
function appendCompressedDataEncoded(builder, encoded, { count, byteStride, mode, filter = 'NONE' }) {
const { byteOffset, byteLength } = appendBinary(builder, 0, Buffer.from(encoded), { alignment: 4 });
const extension = {
buffer: 0,
byteOffset,
byteLength,
byteStride,
count,
mode,
};
if (filter && filter !== 'NONE') extension.filter = filter;
return extension;
}
function encodeMeshoptGltfBuffer(builder, source, { count, byteStride, mode, version }) {
if (mode === 'ATTRIBUTES' && typeof version === 'number') {
return MeshoptEncoder.encodeGltfBuffer(source, count, byteStride, mode, version);
}
return MeshoptEncoder.encodeGltfBuffer(source, count, byteStride, mode);
}
function decodeMeshoptGltfBuffer(builder, { count, byteStride, mode, filter, encoded }) {
const decoded = new Uint8Array(count * byteStride);
MeshoptDecoder.decodeGltfBuffer(decoded, count, byteStride, encoded, mode, filter);
return decoded;
}
function setCompressionExtension(builder, bufferViewIndex, extension) {
const bufferView = builder.gltf.bufferViews[bufferViewIndex];
bufferView.extensions ??= {};
bufferView.extensions[builder.meshoptExtension] = extension;
}
function getMeshoptCompressionExtension(bufferView) {
const extensions = bufferView.extensions;
if (!extensions) return null;
return extensions.EXT_meshopt_compression ?? extensions.KHR_meshopt_compression ?? null;
}
function computeBufferViewByteStats(gltf) {
let uncompressed = 0;
let compressed = 0;
let decompressed = 0;
for (const bufferView of gltf.bufferViews ?? []) {
const extension = getMeshoptCompressionExtension(bufferView);
if (extension) {
decompressed += bufferView.byteLength ?? 0;
compressed += extension.byteLength ?? 0;
} else {
uncompressed += bufferView.byteLength ?? 0;
}
}
return { uncompressed, compressed, decompressed };
}
function appendAccessor(builder, bufferView, accessor) {
const gltf = builder.gltf;
const accessorIndex = gltf.accessors.length;
gltf.accessors.push({ bufferView, byteOffset: 0, ...accessor });
return accessorIndex;
}
function computeMinMaxVec3(values) {
let minX = Infinity,
minY = Infinity,
minZ = Infinity;
let maxX = -Infinity,
maxY = -Infinity,
maxZ = -Infinity;
for (let i = 0; i < values.length; i += 3) {
const x = values[i + 0];
const y = values[i + 1];
const z = values[i + 2];
minX = Math.min(minX, x);
minY = Math.min(minY, y);
minZ = Math.min(minZ, z);
maxX = Math.max(maxX, x);
maxY = Math.max(maxY, y);
maxZ = Math.max(maxZ, z);
}
return { min: [minX, minY, minZ], max: [maxX, maxY, maxZ] };
}
function quantizeSnorm(value, bits) {
const max = (1 << (bits - 1)) - 1;
const clamped = Math.max(-1, Math.min(1, value));
return Math.round(clamped * max);
}
function quantizeUnorm(value, bits) {
const max = (1 << bits) - 1;
const clamped = Math.max(0, Math.min(1, value));
return Math.round(clamped * max);
}
function quantizeSnorm16(value) {
const clamped = Math.max(-1, Math.min(1, value));
return Math.round(clamped * 32767);
}
function getOrCreateCombinedRotationAnimation(builder, name) {
const gltf = builder.gltf;
if (builder._combinedRotationAnimationIndex !== undefined) {
return gltf.animations[builder._combinedRotationAnimationIndex];
}
gltf.animations ??= [];
const animationIndex = gltf.animations.length;
gltf.animations.push({
name,
samplers: [],
channels: [],
});
builder._combinedRotationAnimationIndex = animationIndex;
return gltf.animations[animationIndex];
}
function appendRotationAnimation(builder, nodeIndex, { compression } = {}) {
const gltf = builder.gltf;
const compressionEnabled = compression === true || (compression && compression.enabled === true);
const compressionFilters = compressionEnabled && compression && compression.filters === true;
const compressionVersion =
compressionEnabled &&
builder.meshoptExtension === 'KHR_meshopt_compression' &&
compression &&
typeof compression.version === 'number'
? compression.version
: undefined;
const times = new Float32Array([0, 1, 2]);
const keyCount = times.length;
const quatF32 = new Float32Array([
0, 0, 0, 1,
0, Math.sin(Math.PI / 4), 0, Math.cos(Math.PI / 4),
0, 0, 0, 1,
]);
const timesView = appendBufferView(builder, bufferFromTypedArray(times), { buffer: 0 });
const timesAccessor = appendAccessor(builder, timesView, {
componentType: 5126,
count: keyCount,
type: 'SCALAR',
min: [times[0]],
max: [times[times.length - 1]],
});
const outStride = 8; // snorm16 vec4
let rotationBytes;
let rotationEncoded = null;
let rotationFilter = 'NONE';
if (compressionEnabled && compressionFilters) {
const filtered = MeshoptEncoder.encodeFilterQuat(quatF32, keyCount, outStride, 16);
rotationEncoded = encodeMeshoptGltfBuffer(builder, filtered, {
count: keyCount,
byteStride: outStride,
mode: 'ATTRIBUTES',
version: compressionVersion,
});
rotationFilter = 'QUATERNION';
const decoded = decodeMeshoptGltfBuffer(builder, {
count: keyCount,
byteStride: outStride,
mode: 'ATTRIBUTES',
filter: rotationFilter,
encoded: rotationEncoded,
});
rotationBytes = Buffer.from(decoded.buffer, decoded.byteOffset, decoded.byteLength);
} else {
const out = new Int16Array(keyCount * 4);
for (let i = 0; i < keyCount; i++) {
out[i * 4 + 0] = quantizeSnorm16(quatF32[i * 4 + 0]);
out[i * 4 + 1] = quantizeSnorm16(quatF32[i * 4 + 1]);
out[i * 4 + 2] = quantizeSnorm16(quatF32[i * 4 + 2]);
out[i * 4 + 3] = quantizeSnorm16(quatF32[i * 4 + 3]);
}
rotationBytes = bufferFromTypedArray(out);
}
const rotationView = appendBufferView(builder, rotationBytes, {
buffer: compressionEnabled ? 1 : 0,
});
if (compressionEnabled) {
setCompressionExtension(
builder,
rotationView,
rotationEncoded !== null
? appendCompressedDataEncoded(builder, rotationEncoded, {
mode: 'ATTRIBUTES',
filter: rotationFilter,
count: keyCount,
byteStride: outStride,
})
: appendCompressedData(builder, rotationBytes, {
mode: 'ATTRIBUTES',
filter: 'NONE',
count: keyCount,
byteStride: outStride,
version: compressionVersion,
})
);
}
const rotationAccessor = appendAccessor(builder, rotationView, {
componentType: 5122,
normalized: true,
count: keyCount,
type: 'VEC4',
});
const animation = getOrCreateCombinedRotationAnimation(builder, 'RotateCubes');
const samplerIndex = animation.samplers.length;
animation.samplers.push({ input: timesAccessor, output: rotationAccessor, interpolation: 'LINEAR' });
animation.channels.push({ sampler: samplerIndex, target: { node: nodeIndex, path: 'rotation' } });
}
function appendAnimatedCube(builder, { translation, name, animationCompression } = {}) {
const gltf = builder.gltf;
const h = 0.5;
const faces = [
{ n: [1, 0, 0], v: [[h, -h, -h], [h, -h, h], [h, h, h], [h, h, -h]] },
{ n: [-1, 0, 0], v: [[-h, -h, h], [-h, -h, -h], [-h, h, -h], [-h, h, h]] },
{ n: [0, 1, 0], v: [[-h, h, -h], [h, h, -h], [h, h, h], [-h, h, h]] },
{ n: [0, -1, 0], v: [[-h, -h, h], [h, -h, h], [h, -h, -h], [-h, -h, -h]] },
{ n: [0, 0, 1], v: [[h, -h, h], [-h, -h, h], [-h, h, h], [h, h, h]] },
{ n: [0, 0, -1], v: [[-h, -h, -h], [h, -h, -h], [h, h, -h], [-h, h, -h]] },
];
const positions = new Float32Array(24 * 3);
const normals = new Float32Array(24 * 3);
const indices = new Uint16Array(12 * 3);
for (let f = 0; f < faces.length; f++) {
const face = faces[f];
const baseVertex = f * 4;
for (let i = 0; i < 4; i++) {
positions[(baseVertex + i) * 3 + 0] = face.v[i][0];
positions[(baseVertex + i) * 3 + 1] = face.v[i][1];
positions[(baseVertex + i) * 3 + 2] = face.v[i][2];
normals[(baseVertex + i) * 3 + 0] = face.n[0];
normals[(baseVertex + i) * 3 + 1] = face.n[1];
normals[(baseVertex + i) * 3 + 2] = face.n[2];
}
const baseIndex = f * 6;
indices[baseIndex + 0] = baseVertex + 0;
indices[baseIndex + 1] = baseVertex + 2;
indices[baseIndex + 2] = baseVertex + 1;
indices[baseIndex + 3] = baseVertex + 0;
indices[baseIndex + 4] = baseVertex + 3;
indices[baseIndex + 5] = baseVertex + 2;
}
const positionView = appendBufferView(builder, bufferFromTypedArray(positions), {
buffer: 0,
target: 34962,
});
const normalView = appendBufferView(builder, bufferFromTypedArray(normals), {
buffer: 0,
target: 34962,
});
const indexView = appendBufferView(builder, bufferFromTypedArray(indices), {
buffer: 0,
target: 34963,
});
const posMinMax = computeMinMaxVec3(positions);
const positionAccessor = appendAccessor(builder, positionView, {
componentType: 5126,
count: 24,
type: 'VEC3',
min: posMinMax.min,
max: posMinMax.max,
});
const normalAccessor = appendAccessor(builder, normalView, {
componentType: 5126,
count: 24,
type: 'VEC3',
});
const indexAccessor = appendAccessor(builder, indexView, {
componentType: 5123,
count: indices.length,
type: 'SCALAR',
});
const meshIndex = gltf.meshes.length;
gltf.meshes.push({
name: name ?? `AnimatedCube_${meshIndex}`,
primitives: [{ attributes: { POSITION: positionAccessor, NORMAL: normalAccessor }, indices: indexAccessor }],
});
const nodeIndex = gltf.nodes.length;
gltf.nodes.push({
name: name ?? `AnimatedCubeNode_${nodeIndex}`,
mesh: meshIndex,
translation,
});
gltf.scenes[gltf.scene].nodes.push(nodeIndex);
appendRotationAnimation(builder, nodeIndex, { compression: animationCompression });
}
function appendCube(
builder,
{
translation = [0, 0, 0],
size = 1,
name,
normals = 'i8',
colors = 'u8',
indices = 'u16',
layout = 'separate',
compression = false,
buffer = 1,
} = {}
) {
const gltf = builder.gltf;
const compressionEnabled = compression === true || (compression && compression.enabled === true);
const compressionTriangles = compressionEnabled && (compression === true || compression.triangles !== false);
const compressionFilters = compressionEnabled && compression && compression.filters === true;
const compressionVersion =
compressionEnabled &&
builder.meshoptExtension === 'KHR_meshopt_compression' &&
compression &&
typeof compression.version === 'number'
? compression.version
: undefined;
const uncompressedBuffer = compressionEnabled ? 1 : buffer;
const h = size * 0.5;
const cx = 0,
cy = 0,
cz = 0;
const faces = [
{ n: [1, 0, 0], v: [[h, -h, -h], [h, -h, h], [h, h, h], [h, h, -h]] }, // +X
{ n: [-1, 0, 0], v: [[-h, -h, h], [-h, -h, -h], [-h, h, -h], [-h, h, h]] }, // -X
{ n: [0, 1, 0], v: [[-h, h, -h], [h, h, -h], [h, h, h], [-h, h, h]] }, // +Y
{ n: [0, -1, 0], v: [[-h, -h, h], [h, -h, h], [h, -h, -h], [-h, -h, -h]] }, // -Y
{ n: [0, 0, 1], v: [[h, -h, h], [-h, -h, h], [-h, h, h], [h, h, h]] }, // +Z
{ n: [0, 0, -1], v: [[-h, -h, -h], [h, -h, -h], [h, h, -h], [-h, h, -h]] }, // -Z
];
const positions = new Float32Array(24 * 3);
const normalsComponentType = normals === 'i16' ? 5122 : 5120; // SHORT or BYTE
const normalsStride = normals === 'i16' ? 8 : 4;
const normalsArray = normals === 'i16' ? new Int16Array(24 * 4) : new Int8Array(24 * 4);
const normalsFloat4 = new Float32Array(24 * 4);
const colorsComponentType = colors === 'u16' ? 5123 : 5121; // UNSIGNED_SHORT or UNSIGNED_BYTE
const colorsArray = colors === 'u16' ? new Uint16Array(24 * 4) : new Uint8Array(24 * 4);
const colorsFloat4 = new Float32Array(24 * 4);
const indicesComponentType = indices === 'u32' ? 5125 : 5123; // UNSIGNED_INT or UNSIGNED_SHORT
const indicesArray = indices === 'u32' ? new Uint32Array(12 * 3) : new Uint16Array(12 * 3);
const faceColors = [
[1, 0.5, 0.5, 1], // X
[1, 1, 1, 1], // -X
[0.5, 1, 0.5, 1], // Y
[1, 1, 1, 1], // -Y
[0.5, 0.5, 1, 1], // Z
[1, 1, 1, 1], // -Z
];
for (let f = 0; f < faces.length; f++) {
const face = faces[f];
const baseVertex = f * 4;
const nx = face.n[0];
const ny = face.n[1];
const nz = face.n[2];
const qx = normals === 'i16' ? quantizeSnorm(nx, 16) : quantizeSnorm(nx, 8);
const qy = normals === 'i16' ? quantizeSnorm(ny, 16) : quantizeSnorm(ny, 8);
const qz = normals === 'i16' ? quantizeSnorm(nz, 16) : quantizeSnorm(nz, 8);
const fc = faceColors[f];
const cr = colors === 'u16' ? quantizeUnorm(fc[0], 16) : quantizeUnorm(fc[0], 8);
const cg = colors === 'u16' ? quantizeUnorm(fc[1], 16) : quantizeUnorm(fc[1], 8);
const cb = colors === 'u16' ? quantizeUnorm(fc[2], 16) : quantizeUnorm(fc[2], 8);
const ca = colors === 'u16' ? quantizeUnorm(fc[3], 16) : quantizeUnorm(fc[3], 8);
for (let i = 0; i < 4; i++) {
const px = face.v[i][0] + cx;
const py = face.v[i][1] + cy;
const pz = face.v[i][2] + cz;
positions[(baseVertex + i) * 3 + 0] = px;
positions[(baseVertex + i) * 3 + 1] = py;
positions[(baseVertex + i) * 3 + 2] = pz;
normalsArray[(baseVertex + i) * 4 + 0] = qx;
normalsArray[(baseVertex + i) * 4 + 1] = qy;
normalsArray[(baseVertex + i) * 4 + 2] = qz;
normalsArray[(baseVertex + i) * 4 + 3] = 0;
normalsFloat4[(baseVertex + i) * 4 + 0] = nx;
normalsFloat4[(baseVertex + i) * 4 + 1] = ny;
normalsFloat4[(baseVertex + i) * 4 + 2] = nz;
normalsFloat4[(baseVertex + i) * 4 + 3] = 0;
colorsArray[(baseVertex + i) * 4 + 0] = cr;
colorsArray[(baseVertex + i) * 4 + 1] = cg;
colorsArray[(baseVertex + i) * 4 + 2] = cb;
colorsArray[(baseVertex + i) * 4 + 3] = ca;
colorsFloat4[(baseVertex + i) * 4 + 0] = fc[0];
colorsFloat4[(baseVertex + i) * 4 + 1] = fc[1];
colorsFloat4[(baseVertex + i) * 4 + 2] = fc[2];
colorsFloat4[(baseVertex + i) * 4 + 3] = fc[3];
}
const baseIndex = f * 6;
// Counter-clockwise winding when viewed from outside.
indicesArray[baseIndex + 0] = baseVertex + 0;
indicesArray[baseIndex + 1] = baseVertex + 2;
indicesArray[baseIndex + 2] = baseVertex + 1;
indicesArray[baseIndex + 3] = baseVertex + 0;
indicesArray[baseIndex + 4] = baseVertex + 3;
indicesArray[baseIndex + 5] = baseVertex + 2;
}
let positionAccessor;
let normalAccessor;
let colorAccessor;
let indexAccessor;
if (layout === 'interleaved') {
if (normals !== 'i8' || colors !== 'u8') {
throw new Error('Interleaved layout currently supports normals=i8 and colors=u8 only.');
}
if (compressionFilters) {
// Filters require non-interleaved storage (strict stride requirements).
// Keep compression enabled but disable filters for this mesh.
}
const vertexStride = 20; // 12 pos + 4 normal (VEC3 + pad) + 4 color (VEC4)
const interleaved = new ArrayBuffer(24 * vertexStride);
const view = new DataView(interleaved);
const bytes = new Uint8Array(interleaved);
for (let v = 0; v < 24; v++) {
const base = v * vertexStride;
view.setFloat32(base + 0, positions[v * 3 + 0], true);
view.setFloat32(base + 4, positions[v * 3 + 1], true);
view.setFloat32(base + 8, positions[v * 3 + 2], true);
bytes[base + 12] = normalsArray[v * 4 + 0] & 0xff;
bytes[base + 13] = normalsArray[v * 4 + 1] & 0xff;
bytes[base + 14] = normalsArray[v * 4 + 2] & 0xff;
bytes[base + 15] = 0;
bytes[base + 16] = colorsArray[v * 4 + 0];
bytes[base + 17] = colorsArray[v * 4 + 1];
bytes[base + 18] = colorsArray[v * 4 + 2];
bytes[base + 19] = colorsArray[v * 4 + 3];
}
const vertexBytes = Buffer.from(interleaved);
const vertexView = appendBufferView(builder, vertexBytes, {
buffer: uncompressedBuffer,
target: 34962,
byteStride: vertexStride,
});
if (compressionEnabled) {
setCompressionExtension(
builder,
vertexView,
appendCompressedDataEncoded(
builder,
encodeMeshoptGltfBuffer(builder, vertexBytes, {
mode: 'ATTRIBUTES',
count: 24,
byteStride: vertexStride,
version: compressionVersion,
}),
{
mode: 'ATTRIBUTES',
filter: 'NONE',
count: 24,
byteStride: vertexStride,
}
)
);
}
const posMinMax = computeMinMaxVec3(positions);
positionAccessor = appendAccessor(builder, vertexView, {
componentType: 5126,
count: 24,
type: 'VEC3',
min: posMinMax.min,
max: posMinMax.max,
});
normalAccessor = appendAccessor(builder, vertexView, {
byteOffset: 12,
componentType: normalsComponentType,
count: 24,
type: 'VEC3',
normalized: true,
});
colorAccessor = appendAccessor(builder, vertexView, {
byteOffset: 16,
componentType: colorsComponentType,
count: 24,
type: 'VEC4',
normalized: true,
});
} else {
let positionBytes = bufferFromTypedArray(positions);
let positionEncoded = null;
let positionFilter = 'NONE';
if (compressionEnabled && compressionFilters) {
const filtered = MeshoptEncoder.encodeFilterExp(positions, 24, 12, 16);
positionEncoded = encodeMeshoptGltfBuffer(builder, filtered, {
count: 24,
byteStride: 12,
mode: 'ATTRIBUTES',
version: compressionVersion,
});
const decoded = decodeMeshoptGltfBuffer(builder, {
count: 24,
byteStride: 12,
mode: 'ATTRIBUTES',
filter: 'EXPONENTIAL',
encoded: positionEncoded,
});
positionBytes = Buffer.from(decoded.buffer, decoded.byteOffset, decoded.byteLength);
positionFilter = 'EXPONENTIAL';
}
const positionView = appendBufferView(builder, positionBytes, {
buffer: uncompressedBuffer,
target: 34962,
});
let normalsBytes = bufferFromTypedArray(normalsArray);
let normalsEncoded = null;
let normalsFilter = 'NONE';
if (compressionEnabled && compressionFilters) {
const bits = normals === 'i16' ? 16 : 8;
const filtered = MeshoptEncoder.encodeFilterOct(normalsFloat4, 24, normalsStride, bits);
normalsEncoded = encodeMeshoptGltfBuffer(builder, filtered, {
count: 24,
byteStride: normalsStride,
mode: 'ATTRIBUTES',
version: compressionVersion,
});
const decoded = decodeMeshoptGltfBuffer(builder, {
count: 24,
byteStride: normalsStride,
mode: 'ATTRIBUTES',
filter: 'OCTAHEDRAL',
encoded: normalsEncoded,
});
normalsBytes = Buffer.from(decoded.buffer, decoded.byteOffset, decoded.byteLength);
normalsFilter = 'OCTAHEDRAL';
}
const normalView = appendBufferView(builder, normalsBytes, {
buffer: uncompressedBuffer,
target: 34962,
byteStride: normalsStride,
});
let colorsBytes = bufferFromTypedArray(colorsArray);
let colorsEncoded = null;
let colorsFilter = 'NONE';
const colorsStride = colors === 'u16' ? 8 : 4;
const useColorFilter =
compressionEnabled &&
compressionFilters &&
builder.meshoptExtension === 'KHR_meshopt_compression';
if (useColorFilter) {
const bits = colors === 'u16' ? 16 : 8;
const filtered = MeshoptEncoder.encodeFilterColor(colorsFloat4, 24, colorsStride, bits);
colorsEncoded = encodeMeshoptGltfBuffer(builder, filtered, {
count: 24,
byteStride: colorsStride,
mode: 'ATTRIBUTES',
version: compressionVersion,
});
const decoded = decodeMeshoptGltfBuffer(builder, {
count: 24,
byteStride: colorsStride,
mode: 'ATTRIBUTES',
filter: 'COLOR',
encoded: colorsEncoded,
});
colorsBytes = Buffer.from(decoded.buffer, decoded.byteOffset, decoded.byteLength);
colorsFilter = 'COLOR';
}
const colorView = appendBufferView(builder, colorsBytes, {
buffer: uncompressedBuffer,
target: 34962,
});
if (compressionEnabled) {
setCompressionExtension(
builder,
positionView,
positionEncoded !== null
? appendCompressedDataEncoded(builder, positionEncoded, {
mode: 'ATTRIBUTES',
filter: positionFilter,
count: 24,
byteStride: 12,
})
: appendCompressedData(builder, positionBytes, {
mode: 'ATTRIBUTES',
filter: 'NONE',
count: 24,
byteStride: 12,
version: compressionVersion,
})
);
setCompressionExtension(
builder,
normalView,
normalsEncoded !== null
? appendCompressedDataEncoded(builder, normalsEncoded, {
mode: 'ATTRIBUTES',
filter: normalsFilter,
count: 24,
byteStride: normalsStride,
})
: appendCompressedData(builder, normalsBytes, {
mode: 'ATTRIBUTES',
filter: 'NONE',
count: 24,
byteStride: normalsStride,
version: compressionVersion,
})
);
setCompressionExtension(
builder,
colorView,
colorsEncoded !== null
? appendCompressedDataEncoded(builder, colorsEncoded, {
mode: 'ATTRIBUTES',
filter: colorsFilter,
count: 24,
byteStride: colorsStride,
})
: appendCompressedDataEncoded(
builder,
encodeMeshoptGltfBuffer(builder, colorsBytes, {
mode: 'ATTRIBUTES',
count: 24,
byteStride: colorsStride,
version: compressionVersion,
}),
{
mode: 'ATTRIBUTES',
filter: 'NONE',
count: 24,
byteStride: colorsStride,
}
)
);
}
const posMinMax =
positionFilter === 'EXPONENTIAL'
? computeMinMaxVec3(new Float32Array(positionBytes.buffer, positionBytes.byteOffset, positionBytes.byteLength / 4))
: computeMinMaxVec3(positions);
positionAccessor = appendAccessor(builder, positionView, {
componentType: 5126,
count: 24,
type: 'VEC3',
min: posMinMax.min,
max: posMinMax.max,
});
normalAccessor = appendAccessor(builder, normalView, {
componentType: normalsComponentType,
count: 24,
type: 'VEC3',
normalized: true,
});
colorAccessor = appendAccessor(builder, colorView, {
componentType: colorsComponentType,
count: 24,
type: 'VEC4',
normalized: true,
});
}
const indicesBytes = bufferFromTypedArray(indicesArray);
const indexView = appendBufferView(builder, indicesBytes, {
buffer: uncompressedBuffer,
target: 34963,
});
if (compressionEnabled) {
const indexMode = compressionTriangles ? 'TRIANGLES' : 'INDICES';
setCompressionExtension(
builder,
indexView,
appendCompressedDataEncoded(
builder,
encodeMeshoptGltfBuffer(builder, indicesBytes, {
mode: indexMode,
count: indicesArray.length,
byteStride: indices === 'u32' ? 4 : 2,
}),
{
mode: indexMode,
filter: 'NONE',
count: indicesArray.length,
byteStride: indices === 'u32' ? 4 : 2,
}
)
);
}
indexAccessor = appendAccessor(builder, indexView, {
componentType: indicesComponentType,
count: indicesArray.length,
type: 'SCALAR',
});
const meshIndex = gltf.meshes.length;
gltf.meshes.push({
name: name ?? `Cube_${meshIndex}`,
primitives: [
{
attributes: {
POSITION: positionAccessor,
NORMAL: normalAccessor,
COLOR_0: colorAccessor,
},
indices: indexAccessor,
mode: 4,
},
],
});
const nodeIndex = gltf.nodes.length;
gltf.nodes.push({
name: name ?? `CubeNode_${nodeIndex}`,
mesh: meshIndex,
translation,
});
gltf.scenes[gltf.scene].nodes.push(nodeIndex);
return { meshIndex, nodeIndex };
}
function appendImageTexture(builder, { uri }) {
const gltf = builder.gltf;
const samplerIndex = gltf.samplers.length;
gltf.samplers.push({
magFilter: 9729,
minFilter: 9729,
wrapS: 33071,
wrapT: 33071,
});
const imageIndex = gltf.images.length;
gltf.images.push({ uri });
const textureIndex = gltf.textures.length;
gltf.textures.push({ sampler: samplerIndex, source: imageIndex });
return textureIndex;
}
function appendLabelMaterial(builder, { textureIndex, name }) {
const gltf = builder.gltf;
const materialIndex = gltf.materials.length;
gltf.materials.push({
name,
pbrMetallicRoughness: {
baseColorTexture: { index: textureIndex },
metallicFactor: 0,
roughnessFactor: 1,
},
alphaMode: 'MASK',
alphaCutoff: 0.001,
doubleSided: true,
});
return materialIndex;
}
function getOrCreateQuadGeometry(builder) {
const gltf = builder.gltf;
if (builder._quadGeometry) return builder._quadGeometry;
// Unit quad in XY plane, facing +Z, CCW winding.
const positions = new Float32Array([
-0.5, -0.5, 0,
0.5, -0.5, 0,
0.5, 0.5, 0,
-0.5, 0.5, 0,
]);
const normals = new Float32Array([
0, 0, -1,
0, 0, -1,
0, 0, -1,
0, 0, -1,
]);
const uvs = new Float32Array([
0, 1,
1, 1,
1, 0,
0, 0,
]);
const indices = new Uint16Array([0, 2, 1, 0, 3, 2]);
const posView = appendBufferView(builder, bufferFromTypedArray(positions), { buffer: 0, target: 34962 });
const normalView = appendBufferView(builder, bufferFromTypedArray(normals), { buffer: 0, target: 34962 });
const uvView = appendBufferView(builder, bufferFromTypedArray(uvs), { buffer: 0, target: 34962 });
const idxView = appendBufferView(builder, bufferFromTypedArray(indices), { buffer: 0, target: 34963 });
const posAccessor = appendAccessor(builder, posView, {
componentType: 5126,
count: 4,
type: 'VEC3',
min: [-0.5, -0.5, 0],
max: [0.5, 0.5, 0],
});
const normalAccessor = appendAccessor(builder, normalView, {
componentType: 5126,
count: 4,
type: 'VEC3',
});
const uvAccessor = appendAccessor(builder, uvView, {
componentType: 5126,
count: 4,
type: 'VEC2',
});
const idxAccessor = appendAccessor(builder, idxView, {
componentType: 5123,
count: 6,
type: 'SCALAR',
});
builder._quadGeometry = { posAccessor, normalAccessor, uvAccessor, idxAccessor };
return builder._quadGeometry;
}
function appendLabelQuad(builder, { uri, name, translation, scale }) {
const gltf = builder.gltf;
const { posAccessor, normalAccessor, uvAccessor, idxAccessor } = getOrCreateQuadGeometry(builder);
const textureIndex = appendImageTexture(builder, { uri });
const materialIndex = appendLabelMaterial(builder, { textureIndex, name: `${name}_Mat` });
const meshIndex = gltf.meshes.length;
gltf.meshes.push({
name: `${name}_Mesh`,
primitives: [
{
attributes: { POSITION: posAccessor, NORMAL: normalAccessor, TEXCOORD_0: uvAccessor },
indices: idxAccessor,
material: materialIndex,
mode: 4,
},
],
});
const nodeIndex = gltf.nodes.length;
gltf.nodes.push({
name,
mesh: meshIndex,
translation,
scale,
});
gltf.scenes[gltf.scene].nodes.push(nodeIndex);
return { meshIndex, nodeIndex };
}
async function main() {
await MeshoptEncoder.ready;
await MeshoptDecoder.ready;
const [saveFallbackArg, meshoptExtensionArg] = process.argv.slice(2);
const saveFallbackBuffer =
saveFallbackArg === undefined
? false
: !['0', 'false', 'no', 'off'].includes(String(saveFallbackArg).toLowerCase());
const meshoptExtension = meshoptExtensionArg ?? 'KHR_meshopt_compression';
if (meshoptExtension !== 'EXT_meshopt_compression' && meshoptExtension !== 'KHR_meshopt_compression') {
throw new Error(
`Invalid extension name "${meshoptExtension}". Expected "EXT_meshopt_compression" or "KHR_meshopt_compression".`
);
}
const builder = createGltf({ meshoptExtension, saveFallbackBuffer });
if (!saveFallbackBuffer) delete builder.gltf.buffers[1].uri;
const size = 1;
const spacing = 4;
const rowStrideY = 4;
const xAxis = [
{
label: 'interleaved_u8norm_u8color_u16index',
layout: 'interleaved',
normals: 'i8',
colors: 'u8',
indices: 'u16',
},
{
label: 'deinterleaved_u8norm_u8color_u16index',
layout: 'separate',
normals: 'i8',
colors: 'u8',
indices: 'u16',
},
{
label: 'deinterleaved_u16norm_u16color_u16index',
layout: 'separate',
normals: 'i16',
colors: 'u16',
indices: 'u16',
},
{
label: 'deinterleaved_u8norm_u8color_u32index',
layout: 'separate',
normals: 'i8',
colors: 'u8',
indices: 'u32',
},
{
label: 'animated_rotation',
animated: true,
},
];
const rows = [
{ buffer: 0, compression: false, nameSuffix: '' },
{ buffer: 1, compression: { enabled: true, triangles: false }, nameSuffix: '_compressed_indices' },
{ buffer: 1, compression: { enabled: true, triangles: true }, nameSuffix: '_compressed_triangles' },
{
buffer: 1,
compression: { enabled: true, triangles: true, filters: true },
nameSuffix: '_compressed_filtered',
},
{
buffer: 1,
compression: { enabled: true, triangles: true, filters: true, version: 1 },
nameSuffix: '_compressed_filtered_v1',
},
];
// Row/column labels: quads with unique textures.
const labelZ = 0;
const labelScale = [3.2, 1.6, 1];
const rowLabelX = -4.5;
const topRowY = (rows.length - 1) * rowStrideY;
const colLabelY = topRowY + 3;
for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {
const rowY = (rows.length - 1 - rowIndex) * rowStrideY;
appendLabelQuad(builder, {
uri: `row${rowIndex}.png`,
name: `RowLabel_${rowIndex}`,
translation: [rowLabelX, rowY, labelZ],
scale: labelScale,
});
}
for (let colIndex = 0; colIndex < xAxis.length; colIndex++) {
appendLabelQuad(builder, {
uri: `col${colIndex}.png`,
name: `ColLabel_${colIndex}`,
translation: [colIndex * spacing, colLabelY, labelZ],
scale: labelScale,
});
}
for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {
const row = rows[rowIndex];
const rowY = (rows.length - 1 - rowIndex) * rowStrideY;
for (let cubeIndex = 0; cubeIndex < xAxis.length; cubeIndex++) {
const cube = xAxis[cubeIndex];
const globalCubeIndex = rowIndex * xAxis.length + cubeIndex;
if (cube.animated) {
appendAnimatedCube(builder, {
name: `Cube_${globalCubeIndex}_${cube.label}${row.nameSuffix}`,
translation: [cubeIndex * spacing, rowY, 0],
animationCompression: row.compression,
});
continue;
}
appendCube(builder, {
name: `Cube_${globalCubeIndex}_${cube.label}${row.nameSuffix}`,
size,
translation: [cubeIndex * spacing, rowY, 0],
normals: cube.normals,
colors: cube.colors,
indices: cube.indices,
layout: cube.layout,
compression: row.compression,
buffer: row.buffer,
});
}
}
const outGltf = path.resolve('MeshoptCubeTest.gltf');
const outBin = path.resolve('MeshoptCubeTest.bin');
const outFallback = path.resolve('MeshoptCubeTestFallback.bin');
fs.writeFileSync(outBin, Buffer.concat(builder.bin[0].chunks, builder.bin[0].byteLength));
if (builder.saveFallbackBuffer) {
fs.writeFileSync(outFallback, Buffer.concat(builder.bin[1].chunks, builder.bin[1].byteLength));
}
fs.writeFileSync(outGltf, JSON.stringify(builder.gltf, null, 2) + '\n', 'utf8');
console.log(`Wrote ${path.basename(outGltf)} + ${path.basename(outBin)}`);
if (builder.saveFallbackBuffer) console.log(`Wrote ${path.basename(outFallback)}`);
const stats = computeBufferViewByteStats(builder.gltf);
console.log(
`BufferView bytes: uncompressed ${stats.uncompressed}, compressed ${stats.compressed}, decompressed ${stats.decompressed}`
);
}
await main();

Layout

This repository generates a test scene (scene.gltf + scene.bin, optionally scene-fallback.bin) containing multiple cubes arranged in a grid:

  • Columns vary vertex/index formats and layout (X axis).
  • Rows vary compression settings (Y axis).

Columns (X axis)

  • Column 0: interleaved vertex, POSITION=float32x3, NORMAL=snorm8x3 (padded to 4 bytes), COLOR_0=unorm8x4, INDICES=u16
  • Column 1: deinterleaved streams, POSITION=float32x3, NORMAL=snorm8x3 (padded to 4 bytes), COLOR_0=unorm8x4, INDICES=u16
  • Column 2: deinterleaved streams, POSITION=float32x3, NORMAL=snorm16x3 (padded to 8 bytes), COLOR_0=unorm16x4, INDICES=u16
  • Column 3: deinterleaved streams, POSITION=float32x3, NORMAL=snorm8x3 (padded to 4 bytes), COLOR_0=unorm8x4, INDICES=u32
  • Column 4: animated cube, geometry uncompressed (POSITION=float32x3, NORMAL=float32x3, INDICES=u16), plus a rotation animation (rotation=snorm16x4)

Rows (Y axis)

  • Row 0 (top): uncompressed
  • Row 1: compressed, indices use mode="INDICES" (compression.triangles=false)
  • Row 2: compressed, indices use mode="TRIANGLES" (compression.triangles=true)
  • Row 3: compressed + filters + mode="TRIANGLES"
    • Interleaved column: filters disabled (stride constraints)
    • Deinterleaved columns:
      • POSITION: filter="EXPONENTIAL"
      • NORMAL: filter="OCTAHEDRAL"
      • COLOR_0: filter="COLOR" when using KHR_meshopt_compression, otherwise unfiltered
    • Animated column:
      • rotation: filter="QUATERNION"
  • Row 4 (bottom): compressed + filters + mode="TRIANGLES" + vertex codec v1 (compression.version=1)
    • Same filters as Row 3
{
"name": "khrmeshopt-gen",
"private": true,
"type": "module",
"scripts": {
"generate": "node generate.js"
},
"dependencies": {
"meshoptimizer": "^1.0.1"
}
}
#!/usr/bin/env bash
set -euo pipefail
out_dir="${1:-.}"
width="${WIDTH:-256}"
height="${HEIGHT:-128}"
bg="${BG:-#ffffff}"
fg="${FG:-#111111}"
rows_pointsize="${ROWS_POINTSIZE:-32}"
cols_pointsize="${COLS_POINTSIZE:-22}"
mkdir -p "$out_dir"
# Edit these to change label contents.
row0="uncompressed"
row1="compressed\nv0 ATTRIBUTES + INDICES"
row2="compressed\nv0 ATTRIBUTES + TRIANGLES"
row3="compressed\nv0 ATTRIBUTES + filters"
row4="compressed\nv1 ATTRIBUTES + filters"
col0="interleaved\nattr8 index16"
col1="separate\nattr8 index16"
col2="separate\nattr16 index16"
col3="separate\nattr8 index32"
col4="animated rotation"
convert -size "${width}x${height}" "xc:${bg}" \
\( -background none -fill "${fg}" -font "DejaVu-Sans" -pointsize "${rows_pointsize}" -gravity center -size "${width}x${height}" "caption:${row0}" \) \
-gravity center -composite \
"${out_dir}/row0.png"
convert -size "${width}x${height}" "xc:${bg}" \
\( -background none -fill "${fg}" -font "DejaVu-Sans" -pointsize "${rows_pointsize}" -gravity center -size "${width}x${height}" "caption:${row1}" \) \
-gravity center -composite \
"${out_dir}/row1.png"
convert -size "${width}x${height}" "xc:${bg}" \
\( -background none -fill "${fg}" -font "DejaVu-Sans" -pointsize "${rows_pointsize}" -gravity center -size "${width}x${height}" "caption:${row2}" \) \
-gravity center -composite \
"${out_dir}/row2.png"
convert -size "${width}x${height}" "xc:${bg}" \
\( -background none -fill "${fg}" -font "DejaVu-Sans" -pointsize "${rows_pointsize}" -gravity center -size "${width}x${height}" "caption:${row3}" \) \
-gravity center -composite \
"${out_dir}/row3.png"
convert -size "${width}x${height}" "xc:${bg}" \
\( -background none -fill "${fg}" -font "DejaVu-Sans" -pointsize "${rows_pointsize}" -gravity center -size "${width}x${height}" "caption:${row4}" \) \
-gravity center -composite \
"${out_dir}/row4.png"
convert -size "${width}x${height}" "xc:${bg}" \
\( -background none -fill "${fg}" -font "DejaVu-Sans" -pointsize "${cols_pointsize}" -gravity center -size "${width}x${height}" "caption:${col0}" \) \
-gravity center -composite \
"${out_dir}/col0.png"
convert -size "${width}x${height}" "xc:${bg}" \
\( -background none -fill "${fg}" -font "DejaVu-Sans" -pointsize "${cols_pointsize}" -gravity center -size "${width}x${height}" "caption:${col1}" \) \
-gravity center -composite \
"${out_dir}/col1.png"
convert -size "${width}x${height}" "xc:${bg}" \
\( -background none -fill "${fg}" -font "DejaVu-Sans" -pointsize "${cols_pointsize}" -gravity center -size "${width}x${height}" "caption:${col2}" \) \
-gravity center -composite \
"${out_dir}/col2.png"
convert -size "${width}x${height}" "xc:${bg}" \
\( -background none -fill "${fg}" -font "DejaVu-Sans" -pointsize "${cols_pointsize}" -gravity center -size "${width}x${height}" "caption:${col3}" \) \
-gravity center -composite \
"${out_dir}/col3.png"
convert -size "${width}x${height}" "xc:${bg}" \
\( -background none -fill "${fg}" -font "DejaVu-Sans" -pointsize "${cols_pointsize}" -gravity center -size "${width}x${height}" "caption:${col4}" \) \
-gravity center -composite \
"${out_dir}/col4.png"
echo "Wrote ${out_dir}/row{0..4}.png"
echo "Wrote ${out_dir}/col{0..4}.png"
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>khrmeshopt-gen viewer</title>
<style>
html,
body {
height: 100%;
margin: 0;
overflow: hidden;
background: #0b0c10;
}
#overlay {
position: fixed;
left: 12px;
top: 12px;
padding: 10px 12px;
font: 12px/1.4 ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
"Courier New", monospace;
color: rgba(255, 255, 255, 0.85);
background: rgba(0, 0, 0, 0.35);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 8px;
max-width: 520px;
user-select: none;
}
a {
color: rgba(170, 210, 255, 0.9);
}
</style>
</head>
<body>
<div id="overlay">
Loading <code>MeshoptCubeTest.gltf</code>…
<div style="opacity: 0.8; margin-top: 6px">
Note: open via a local web server (not <code>file://</code>).
</div>
</div>
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { MeshoptDecoder } from 'https://cdn.jsdelivr.net/npm/meshoptimizer@1.0.1/meshopt_decoder.mjs';
const overlay = document.getElementById('overlay');
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.outputColorSpace = THREE.SRGBColorSpace;
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0b0c10);
const camera = new THREE.PerspectiveCamera(55, window.innerWidth / window.innerHeight, 0.01, 200);
camera.position.set(20, 16, 28);
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(6, 6, 0);
controls.update();
const dir = new THREE.DirectionalLight(0xffffff, 6);
dir.position.set(5, 10, 8);
scene.add(dir);
const fill = new THREE.DirectionalLight(0xffffff, 2);
fill.position.set(-5, -2, -8);
scene.add(fill);
const loader = new GLTFLoader();
loader.setMeshoptDecoder(MeshoptDecoder);
loader.register((parser) => {
return {
name: 'KHR_meshopt_compression',
loadBufferView: (index) => {
const json = parser.json;
const bufferView = json.bufferViews[index];
const extensionDef = bufferView.extensions?.KHR_meshopt_compression;
if (!extensionDef) return null;
const decoder = parser.options.meshoptDecoder;
if (!decoder || !decoder.supported) {
if (json.extensionsRequired?.includes('KHR_meshopt_compression')) {
throw new Error(
'THREE.GLTFLoader: setMeshoptDecoder must be called before loading compressed files'
);
}
return null;
}
return parser.getDependency('buffer', extensionDef.buffer).then((res) => {
const byteOffset = extensionDef.byteOffset || 0;
const byteLength = extensionDef.byteLength || 0;
const count = extensionDef.count;
const stride = extensionDef.byteStride;
const source = new Uint8Array(res, byteOffset, byteLength);
if (decoder.decodeGltfBufferAsync) {
return decoder
.decodeGltfBufferAsync(count, stride, source, extensionDef.mode, extensionDef.filter)
.then((decoded) => decoded.buffer);
}
return decoder.ready.then(() => {
const result = new ArrayBuffer(count * stride);
decoder.decodeGltfBuffer(
new Uint8Array(result),
count,
stride,
source,
extensionDef.mode,
extensionDef.filter
);
return result;
});
});
},
};
});
const clock = new THREE.Clock();
let mixer = null;
function onResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
}
window.addEventListener('resize', onResize);
loader.load(
'MeshoptCubeTest.gltf',
(gltf) => {
scene.add(gltf.scene);
if (gltf.animations && gltf.animations.length) {
mixer = new THREE.AnimationMixer(gltf.scene);
for (const clip of gltf.animations) mixer.clipAction(clip).play();
}
overlay.textContent = 'Loaded. Drag to orbit, scroll to zoom.';
},
(event) => {
if (!event.total) return;
const percent = Math.round((event.loaded / event.total) * 100);
overlay.textContent = `Loading MeshoptCubeTest.gltf… ${percent}%`;
},
(error) => {
overlay.textContent =
'Failed to load MeshoptCubeTest.gltf. Check console, and ensure you are using a local web server.';
console.error(error);
}
);
renderer.setAnimationLoop(() => {
const dt = clock.getDelta();
if (mixer) mixer.update(dt);
controls.update();
renderer.render(scene, camera);
});
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment