@gltf-transform/extensions
Version:
Adds extension support to @gltf-transform/core
463 lines (409 loc) • 18.4 kB
text/typescript
import {
Accessor,
type Buffer,
BufferUtils,
Extension,
GLB_BUFFER,
type GLTF,
type Property,
PropertyType,
type ReaderContext,
WriterContext,
} from '@gltf-transform/core';
import type { MeshoptDecoder, MeshoptEncoder } from 'meshoptimizer';
import { EXT_MESHOPT_COMPRESSION } from '../constants.js';
import { EncoderMethod, type MeshoptBufferViewExtension, MeshoptFilter } from './constants.js';
import { isFallbackBuffer } from './decoder.js';
import { getMeshoptFilter, getMeshoptMode, getTargetPath, prepareAccessor } from './encoder.js';
interface EncoderOptions {
method?: EncoderMethod;
}
const DEFAULT_ENCODER_OPTIONS: Required<EncoderOptions> = {
method: EncoderMethod.QUANTIZE,
};
type MeshoptBufferView = { extensions: { [EXT_MESHOPT_COMPRESSION]: MeshoptBufferViewExtension } };
type EncodedBufferView = GLTF.IBufferView & MeshoptBufferView;
/**
* [`EXT_meshopt_compression`](https://github.com/KhronosGroup/gltf/blob/main/extensions/2.0/Vendor/EXT_meshopt_compression/)
* provides compression and fast decoding for geometry, morph targets, and animations.
*
* Meshopt compression (based on the [meshoptimizer](https://github.com/zeux/meshoptimizer)
* library) offers a lightweight decoder with very fast runtime decompression, and is
* appropriate for models of any size. Meshopt can reduce the transmission sizes of geometry,
* morph targets, animation, and other numeric data stored in buffer views. When textures are
* large, other complementary compression methods should be used as well.
*
* For the full benefits of meshopt compression, **apply gzip, brotli, or another lossless
* compression method** to the resulting .glb, .gltf, or .bin files. Meshopt specifically
* pre-optimizes assets for this purpose — without this secondary compression, the size
* reduction is considerably less.
*
* Be aware that decompression happens before uploading to the GPU. While Meshopt decoding is
* considerably faster than Draco decoding, neither compression method will improve runtime
* performance directly. To improve framerate, you'll need to simplify the geometry by reducing
* vertex count or draw calls — not just compress it. Finally, be aware that Meshopt compression is
* lossy: repeatedly compressing and decompressing a model in a pipeline will lose precision, so
* compression should generally be the last stage of an art workflow, and uncompressed original
* files should be kept.
*
* The meshoptimizer library ([github](https://github.com/zeux/meshoptimizer/tree/master/js),
* [npm](https://www.npmjs.com/package/meshoptimizer)) is a required dependency for reading or
* writing files, and must be provided by the application. Compression may alternatively be applied
* with the [gltfpack](https://github.com/zeux/meshoptimizer/tree/master/gltf) tool.
*
* ### Example — Read
*
* To read glTF files using Meshopt compression, ensure that the extension
* and a decoder are registered. Geometry and other data are decompressed
* while reading the file.
*
* ```typescript
* import { NodeIO } from '@gltf-transform/core';
* import { EXTMeshoptCompression } from '@gltf-transform/extensions';
* import { MeshoptDecoder } from 'meshoptimizer';
*
* await MeshoptDecoder.ready;
*
* const io = new NodeIO()
* .registerExtensions([EXTMeshoptCompression])
* .registerDependencies({ 'meshopt.decoder': MeshoptDecoder });
*
* // Read and decode.
* const document = await io.read('compressed.glb');
* ```
*
* ### Example — Write
*
* The simplest way to apply Meshopt compression is with the {@link meshopt}
* transform. The extension and an encoder must be registered.
*
* ```typescript
* import { NodeIO } from '@gltf-transform/core';
* import { EXTMeshoptCompression } from '@gltf-transform/extensions';
* import { meshopt } from '@gltf-transform/functions';
* import { MeshoptEncoder } from 'meshoptimizer';
*
* await MeshoptEncoder.ready;
*
* const io = new NodeIO()
* .registerExtensions([EXTMeshoptCompression])
* .registerDependencies({ 'meshopt.encoder': MeshoptEncoder });
*
* await document.transform(
* meshopt({encoder: MeshoptEncoder, level: 'medium'})
* );
*
* await io.write('compressed-medium.glb', document);
* ```
*
* ### Example — Advanced
*
* Internally, the {@link meshopt} transform reorders and quantizes vertex data
* to preparate for compression. If you prefer different pre-processing, the
* EXTMeshoptCompression extension can be added to the document manually:
*
* ```typescript
* import { reorder, quantize } from '@gltf-transform/functions';
* import { EXTMeshoptCompression } from '@gltf-transform/extensions';
* import { MeshoptEncoder } from 'meshoptimizer';
*
* await document.transform(
* reorder({encoder: MeshoptEncoder}),
* quantize()
* );
*
* document.createExtension(EXTMeshoptCompression)
* .setRequired(true)
* .setEncoderOptions({ method: EXTMeshoptCompression.EncoderMethod.QUANTIZE });
* ```
*
* In either case, compression is deferred until generating output with an I/O
* class.
*/
export class EXTMeshoptCompression extends Extension {
public readonly extensionName: typeof EXT_MESHOPT_COMPRESSION = EXT_MESHOPT_COMPRESSION;
/** @hidden */
public readonly prereadTypes: PropertyType[] = [PropertyType.BUFFER, PropertyType.PRIMITIVE];
/** @hidden */
public readonly prewriteTypes: PropertyType[] = [PropertyType.BUFFER, PropertyType.ACCESSOR];
/** @hidden */
public readonly readDependencies: string[] = ['meshopt.decoder'];
/** @hidden */
public readonly writeDependencies: string[] = ['meshopt.encoder'];
public static readonly EXTENSION_NAME: typeof EXT_MESHOPT_COMPRESSION = EXT_MESHOPT_COMPRESSION;
public static readonly EncoderMethod: typeof EncoderMethod = EncoderMethod;
private _decoder: typeof MeshoptDecoder | null = null;
private _decoderFallbackBufferMap = new Map<Buffer, Buffer>();
private _encoder: typeof MeshoptEncoder | null = null;
private _encoderOptions: Required<EncoderOptions> = DEFAULT_ENCODER_OPTIONS;
private _encoderFallbackBuffer: Buffer | null = null;
private _encoderBufferViews: { [key: string]: EncodedBufferView } = {};
private _encoderBufferViewData: { [key: string]: Uint8Array[] } = {};
private _encoderBufferViewAccessors: { [key: string]: GLTF.IAccessor[] } = {};
/** @hidden */
public install(key: string, dependency: unknown): this {
if (key === 'meshopt.decoder') {
this._decoder = dependency as typeof MeshoptDecoder;
}
if (key === 'meshopt.encoder') {
this._encoder = dependency as typeof MeshoptEncoder;
}
return this;
}
/**
* Configures Meshopt options for quality/compression tuning. The two methods rely on different
* pre-processing before compression, and should be compared on the basis of (a) quality/loss
* and (b) final asset size after _also_ applying a lossless compression such as gzip or brotli.
*
* - QUANTIZE: Default. Pre-process with {@link quantize quantize()} (lossy to specified
* precision) before applying lossless Meshopt compression. Offers a considerable compression
* ratio with or without further supercompression. Equivalent to `gltfpack -c`.
* - FILTER: Pre-process with lossy filters to improve compression, before applying lossless
* Meshopt compression. While output may initially be larger than with the QUANTIZE method,
* this method will benefit more from supercompression (e.g. gzip or brotli). Equivalent to
* `gltfpack -cc`.
*
* Output with the FILTER method will generally be smaller after supercompression (e.g. gzip or
* brotli) is applied, but may be larger than QUANTIZE output without it. Decoding is very fast
* with both methods.
*
* Example:
*
* ```ts
* import { EXTMeshoptCompression } from '@gltf-transform/extensions';
*
* doc.createExtension(EXTMeshoptCompression)
* .setRequired(true)
* .setEncoderOptions({
* method: EXTMeshoptCompression.EncoderMethod.QUANTIZE
* });
* ```
*/
public setEncoderOptions(options: EncoderOptions): this {
this._encoderOptions = { ...DEFAULT_ENCODER_OPTIONS, ...options };
return this;
}
/**********************************************************************************************
* Decoding.
*/
/** @internal Checks preconditions, decodes buffer views, and creates decoded primitives. */
public preread(context: ReaderContext, propertyType: PropertyType): this {
if (!this._decoder) {
if (!this.isRequired()) return this;
throw new Error(`[${EXT_MESHOPT_COMPRESSION}] Please install extension dependency, "meshopt.decoder".`);
}
if (!this._decoder.supported) {
if (!this.isRequired()) return this;
throw new Error(`[${EXT_MESHOPT_COMPRESSION}]: Missing WASM support.`);
}
if (propertyType === PropertyType.BUFFER) {
this._prereadBuffers(context);
} else if (propertyType === PropertyType.PRIMITIVE) {
this._prereadPrimitives(context);
}
return this;
}
/** @internal Decode buffer views. */
private _prereadBuffers(context: ReaderContext): void {
const jsonDoc = context.jsonDoc;
const viewDefs = jsonDoc.json.bufferViews || [];
viewDefs.forEach((viewDef, index) => {
if (!viewDef.extensions || !viewDef.extensions[EXT_MESHOPT_COMPRESSION]) return;
const meshoptDef = viewDef.extensions[EXT_MESHOPT_COMPRESSION] as MeshoptBufferViewExtension;
const byteOffset = meshoptDef.byteOffset || 0;
const byteLength = meshoptDef.byteLength || 0;
const count = meshoptDef.count;
const stride = meshoptDef.byteStride;
const result = new Uint8Array(count * stride);
const bufferDef = jsonDoc.json.buffers![meshoptDef.buffer];
// TODO(cleanup): Should be encapsulated in writer-context.ts.
const resource = bufferDef.uri ? jsonDoc.resources[bufferDef.uri] : jsonDoc.resources[GLB_BUFFER];
const source = BufferUtils.toView(resource, byteOffset, byteLength);
this._decoder!.decodeGltfBuffer(result, count, stride, source, meshoptDef.mode, meshoptDef.filter);
context.bufferViews[index] = result;
});
}
/**
* Mark fallback buffers and replacements.
*
* Note: Alignment with primitives is arbitrary; this just needs to happen
* after Buffers have been parsed.
* @internal
*/
private _prereadPrimitives(context: ReaderContext): void {
const jsonDoc = context.jsonDoc;
const viewDefs = jsonDoc.json.bufferViews || [];
//
viewDefs.forEach((viewDef) => {
if (!viewDef.extensions || !viewDef.extensions[EXT_MESHOPT_COMPRESSION]) return;
const meshoptDef = viewDef.extensions[EXT_MESHOPT_COMPRESSION] as MeshoptBufferViewExtension;
const buffer = context.buffers[meshoptDef.buffer];
const fallbackBuffer = context.buffers[viewDef.buffer];
const fallbackBufferDef = jsonDoc.json.buffers![viewDef.buffer];
if (isFallbackBuffer(fallbackBufferDef)) {
this._decoderFallbackBufferMap.set(fallbackBuffer, buffer);
}
});
}
/** @hidden Removes Fallback buffers, if extension is required. */
public read(_context: ReaderContext): this {
if (!this.isRequired()) return this;
// Replace fallback buffers.
for (const [fallbackBuffer, buffer] of this._decoderFallbackBufferMap) {
for (const parent of fallbackBuffer.listParents()) {
if (parent instanceof Accessor) {
parent.swap(fallbackBuffer, buffer);
}
}
fallbackBuffer.dispose();
}
return this;
}
/**********************************************************************************************
* Encoding.
*/
/** @internal Claims accessors that can be compressed and writes compressed buffer views. */
public prewrite(context: WriterContext, propertyType: PropertyType): this {
if (propertyType === PropertyType.ACCESSOR) {
this._prewriteAccessors(context);
} else if (propertyType === PropertyType.BUFFER) {
this._prewriteBuffers(context);
}
return this;
}
/** @internal Claims accessors that can be compressed. */
private _prewriteAccessors(context: WriterContext): void {
const json = context.jsonDoc.json;
const encoder = this._encoder!;
const options = this._encoderOptions;
const graph = this.document.getGraph();
const fallbackBuffer = this.document.createBuffer(); // Disposed on write.
const fallbackBufferIndex = this.document.getRoot().listBuffers().indexOf(fallbackBuffer);
let nextID = 1;
const parentToID = new Map<Property, number>();
const getParentID = (property: Property): number => {
for (const parent of graph.listParents(property)) {
if (parent.propertyType === PropertyType.ROOT) continue;
let id = parentToID.get(property);
if (id === undefined) parentToID.set(property, (id = nextID++));
return id;
}
return -1;
};
this._encoderFallbackBuffer = fallbackBuffer;
this._encoderBufferViews = {};
this._encoderBufferViewData = {};
this._encoderBufferViewAccessors = {};
for (const accessor of this.document.getRoot().listAccessors()) {
// See: https://github.com/donmccurdy/glTF-Transform/pull/323#issuecomment-898791251
// Example: https://skfb.ly/6qAD8
if (getTargetPath(accessor) === 'weights') continue;
// See: https://github.com/donmccurdy/glTF-Transform/issues/289
if (accessor.getSparse()) continue;
const usage = context.getAccessorUsage(accessor);
const parentID = context.accessorUsageGroupedByParent.has(usage) ? getParentID(accessor) : null;
const mode = getMeshoptMode(accessor, usage);
const filter =
options.method === EncoderMethod.FILTER
? getMeshoptFilter(accessor, this.document)
: { filter: MeshoptFilter.NONE };
const preparedAccessor = prepareAccessor(accessor, encoder, mode, filter);
const { array, byteStride } = preparedAccessor;
const buffer = accessor.getBuffer();
if (!buffer) throw new Error(`${EXT_MESHOPT_COMPRESSION}: Missing buffer for accessor.`);
const bufferIndex = this.document.getRoot().listBuffers().indexOf(buffer);
// Buffer view grouping key.
const key = [usage, parentID, mode, filter.filter, byteStride, bufferIndex].join(':');
let bufferView = this._encoderBufferViews[key];
let bufferViewData = this._encoderBufferViewData[key];
let bufferViewAccessors = this._encoderBufferViewAccessors[key];
// Write new buffer view, if needed.
if (!bufferView || !bufferViewData) {
bufferViewAccessors = this._encoderBufferViewAccessors[key] = [];
bufferViewData = this._encoderBufferViewData[key] = [];
bufferView = this._encoderBufferViews[key] = {
buffer: fallbackBufferIndex,
target: WriterContext.USAGE_TO_TARGET[usage],
byteOffset: 0,
byteLength: 0,
byteStride: usage === WriterContext.BufferViewUsage.ARRAY_BUFFER ? byteStride : undefined,
extensions: {
[EXT_MESHOPT_COMPRESSION]: {
buffer: bufferIndex,
byteOffset: 0,
byteLength: 0,
mode: mode,
filter: filter.filter !== MeshoptFilter.NONE ? filter.filter : undefined,
byteStride: byteStride,
count: 0,
},
},
};
}
// Write accessor.
const accessorDef = context.createAccessorDef(accessor);
accessorDef.componentType = preparedAccessor.componentType;
accessorDef.normalized = preparedAccessor.normalized;
accessorDef.byteOffset = bufferView.byteLength;
if (accessorDef.min && preparedAccessor.min) accessorDef.min = preparedAccessor.min;
if (accessorDef.max && preparedAccessor.max) accessorDef.max = preparedAccessor.max;
context.accessorIndexMap.set(accessor, json.accessors!.length);
json.accessors!.push(accessorDef);
bufferViewAccessors.push(accessorDef);
// Update buffer view.
bufferViewData.push(new Uint8Array(array.buffer, array.byteOffset, array.byteLength));
bufferView.byteLength += array.byteLength;
bufferView.extensions.EXT_meshopt_compression.count += accessor.getCount();
}
}
/** @internal Writes compressed buffer views. */
private _prewriteBuffers(context: WriterContext): void {
const encoder = this._encoder!;
for (const key in this._encoderBufferViews) {
const bufferView = this._encoderBufferViews[key];
const bufferViewData = this._encoderBufferViewData[key];
const buffer = this.document.getRoot().listBuffers()[bufferView.extensions[EXT_MESHOPT_COMPRESSION].buffer];
const otherBufferViews = context.otherBufferViews.get(buffer) || [];
const { count, byteStride, mode } = bufferView.extensions[EXT_MESHOPT_COMPRESSION];
const srcArray = BufferUtils.concat(bufferViewData);
const dstArray = encoder.encodeGltfBuffer(srcArray, count, byteStride, mode);
const compressedData = BufferUtils.pad(dstArray);
bufferView.extensions[EXT_MESHOPT_COMPRESSION].byteLength = dstArray.byteLength;
bufferViewData.length = 0;
bufferViewData.push(compressedData);
otherBufferViews.push(compressedData);
context.otherBufferViews.set(buffer, otherBufferViews);
}
}
/** @hidden Puts encoded data into glTF output. */
public write(context: WriterContext): this {
let fallbackBufferByteOffset = 0;
// Write final encoded buffer view properties.
for (const key in this._encoderBufferViews) {
const bufferView = this._encoderBufferViews[key];
const bufferViewData = this._encoderBufferViewData[key][0];
const bufferViewIndex = context.otherBufferViewsIndexMap.get(bufferViewData)!;
const bufferViewAccessors = this._encoderBufferViewAccessors[key];
for (const accessorDef of bufferViewAccessors) {
accessorDef.bufferView = bufferViewIndex;
}
const finalBufferViewDef = context.jsonDoc.json.bufferViews![bufferViewIndex];
const compressedByteOffset = finalBufferViewDef.byteOffset || 0;
Object.assign(finalBufferViewDef, bufferView);
finalBufferViewDef.byteOffset = fallbackBufferByteOffset;
const bufferViewExtensionDef = finalBufferViewDef.extensions![
EXT_MESHOPT_COMPRESSION
] as MeshoptBufferViewExtension;
bufferViewExtensionDef.byteOffset = compressedByteOffset;
fallbackBufferByteOffset += BufferUtils.padNumber(bufferView.byteLength);
}
// Write final fallback buffer.
const fallbackBuffer = this._encoderFallbackBuffer!;
const fallbackBufferIndex = context.bufferIndexMap.get(fallbackBuffer)!;
const fallbackBufferDef = context.jsonDoc.json.buffers![fallbackBufferIndex];
fallbackBufferDef.byteLength = fallbackBufferByteOffset;
fallbackBufferDef.extensions = { [EXT_MESHOPT_COMPRESSION]: { fallback: true } };
fallbackBuffer.dispose();
return this;
}
}