|
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(); |