diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 10058d9d3..6e61a57a2 100755 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -6,7 +6,7 @@ import { gzip } from 'node-gzip'; import { program } from '@caporal/core'; import { Logger, NodeIO, PropertyType, VertexLayout, vec2 } from '@gltf-transform/core'; import { ALL_EXTENSIONS } from '@gltf-transform/extensions'; -import { CenterOptions, InstanceOptions, PartitionOptions, PruneOptions, QUANTIZE_DEFAULTS, ResampleOptions, SequenceOptions, TEXTURE_RESIZE_DEFAULTS, TextureResizeFilter, UnweldOptions, WeldOptions, center, dedup, instance, metalRough, partition, prune, quantize, resample, sequence, tangents, unweld, weld, reorder, dequantize, unlit, meshopt, DRACO_DEFAULTS, draco, DracoOptions, simplify, SIMPLIFY_DEFAULTS, WELD_DEFAULTS, textureCompress, flatten, FlattenOptions } from '@gltf-transform/functions'; +import { CenterOptions, InstanceOptions, PartitionOptions, PruneOptions, QUANTIZE_DEFAULTS, ResampleOptions, SequenceOptions, TEXTURE_RESIZE_DEFAULTS, TextureResizeFilter, UnweldOptions, WeldOptions, center, dedup, instance, metalRough, partition, prune, quantize, resample, sequence, tangents, unweld, weld, reorder, dequantize, unlit, meshopt, DRACO_DEFAULTS, draco, DracoOptions, simplify, SIMPLIFY_DEFAULTS, WELD_DEFAULTS, textureCompress, flatten, FlattenOptions, sparse, SparseOptions } from '@gltf-transform/functions'; import { inspect } from './inspect'; import { ETC1S_DEFAULTS, Filter, Mode, UASTC_DEFAULTS, ktxfix, merge, toktx, XMPOptions, xmp } from './transforms'; import { formatBytes, MICROMATCH_OPTIONS, underline, TableFormat } from './util'; @@ -1174,6 +1174,23 @@ so this workflow is not a replacement for video playback. .transform(sequence({...options, pattern} as SequenceOptions)); }); +// SPARSE +program + .command('sparse', 'Reduces storage for zero-filled arrays') + .help(` +Scans all Accessors in the Document, detecting whether each Accessor would +benefit from sparse data storage. Currently, sparse data storage is used only +when many values (≥ 1/3) are zeroes. Particularly for assets using morph +target ("shape key") animation, sparse data storage may significantly reduce +file sizes. + `.trim()) + .argument('', INPUT_DESC) + .argument('', OUTPUT_DESC) + .action(({args, options, logger}) => + Session.create(io, logger, args.input, args.output) + .transform(sparse(options as unknown as SparseOptions)) + ); + program.option('--allow-http', 'Allows reads from HTTP requests.', { global: true, default: false, diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts index 36436fadd..aff1f8e78 100644 --- a/packages/core/src/constants.ts +++ b/packages/core/src/constants.ts @@ -136,6 +136,7 @@ export enum BufferViewUsage { ELEMENT_ARRAY_BUFFER = 'ELEMENT_ARRAY_BUFFER', INVERSE_BIND_MATRICES = 'INVERSE_BIND_MATRICES', OTHER = 'OTHER', + SPARSE = 'SPARSE', } /** Texture channels. */ @@ -150,3 +151,12 @@ export enum Format { GLTF = 'GLTF', GLB = 'GLB', } + +export const ComponentTypeToTypedArray = { + '5120': Int8Array, + '5121': Uint8Array, + '5122': Int16Array, + '5123': Uint16Array, + '5125': Uint32Array, + '5126': Float32Array, +}; diff --git a/packages/core/src/io/reader.ts b/packages/core/src/io/reader.ts index 4676e2516..366ad6951 100644 --- a/packages/core/src/io/reader.ts +++ b/packages/core/src/io/reader.ts @@ -1,4 +1,4 @@ -import { GLB_BUFFER, PropertyType, TypedArray, mat4, vec3, vec4 } from '../constants'; +import { GLB_BUFFER, PropertyType, TypedArray, mat4, vec3, vec4, ComponentTypeToTypedArray } from '../constants'; import { Document } from '../document'; import type { Extension } from '../extension'; import type { JSONDocument } from '../json-document'; @@ -7,15 +7,6 @@ import type { GLTF } from '../types/gltf'; import { BufferUtils, FileUtils, ILogger, ImageUtils, Logger, MathUtils } from '../utils'; import { ReaderContext } from './reader-context'; -const ComponentTypeToTypedArray = { - '5120': Int8Array, - '5121': Uint8Array, - '5122': Int16Array, - '5123': Uint16Array, - '5125': Uint32Array, - '5126': Float32Array, -}; - export interface ReaderOptions { logger?: ILogger; extensions: (typeof Extension)[]; @@ -116,18 +107,14 @@ export class GLTFReader { accessor.setNormalized(accessorDef.normalized); } - // KHR_draco_mesh_compression and EXT_meshopt_compression. - if (accessorDef.bufferView === undefined && !accessorDef.sparse) return accessor; - - let array: TypedArray; + // Sparse accessors, KHR_draco_mesh_compression, and EXT_meshopt_compression. + if (accessorDef.bufferView === undefined) return accessor; - if (accessorDef.sparse !== undefined) { - array = getSparseArray(accessorDef, context); - } else { - array = getAccessorArray(accessorDef, context); - } + // NOTICE: We mark sparse accessors at the end of the I/O reading process. Consider an + // accessor to be 'sparse' if it (A) includes sparse value overrides, or (B) does not + // define .bufferView _and_ no extension provides that data. - accessor.setArray(array); + accessor.setArray(getAccessorArray(accessorDef, context)); return accessor; }); @@ -497,6 +484,20 @@ export class GLTFReader { .listExtensionsUsed() .forEach((extension) => extension.read(context)); + /** Post-processing. */ + + // Consider an accessor to be 'sparse' if it (A) includes sparse value overrides, + // or (B) does not define .bufferView _and_ no extension provides that data. Case + // (B) represents a zero-filled accessor. + accessorDefs.forEach((accessorDef, index) => { + const accessor = context.accessors[index]; + const hasSparseValues = !!accessorDef.sparse; + const isZeroFilled = !accessorDef.bufferView && !accessor.getArray(); + if (hasSparseValues || isZeroFilled) { + accessor.setSparse(true).setArray(getSparseArray(accessorDef, context)); + } + }); + return doc; } @@ -618,7 +619,9 @@ function getSparseArray(accessorDef: GLTF.IAccessor, context: ReaderContext): Ty array = new TypedArray(accessorDef.count * elementSize); } - const sparseDef = accessorDef.sparse!; + const sparseDef = accessorDef.sparse; + if (!sparseDef) return array; // Zero-filled accessor. + const count = sparseDef.count; const indicesDef = { ...accessorDef, ...sparseDef.indices, count, type: 'SCALAR' }; const valuesDef = { ...accessorDef, ...sparseDef.values, count }; diff --git a/packages/core/src/io/writer-context.ts b/packages/core/src/io/writer-context.ts index 23f6d785e..6659b4841 100644 --- a/packages/core/src/io/writer-context.ts +++ b/packages/core/src/io/writer-context.ts @@ -198,18 +198,20 @@ export class WriterContext { const cachedUsage = this._accessorUsageMap.get(accessor); if (cachedUsage) return cachedUsage; + if (accessor.getSparse()) return BufferViewUsage.SPARSE; + for (const edge of this._doc.getGraph().listParentEdges(accessor)) { const { usage } = edge.getAttributes() as { usage: BufferViewUsage | undefined }; if (usage) return usage; if (edge.getParent().propertyType !== PropertyType.ROOT) { - this._doc.getLogger().warn(`Missing attribute ".usage" on edge, "${edge.getName()}".`); + this.logger.warn(`Missing attribute ".usage" on edge, "${edge.getName()}".`); } } // Group accessors with no specified usage into a miscellaneous buffer view. - return WriterContext.BufferViewUsage.OTHER; + return BufferViewUsage.OTHER; } /** diff --git a/packages/core/src/io/writer.ts b/packages/core/src/io/writer.ts index 14a0ea581..18f5d4c1e 100644 --- a/packages/core/src/io/writer.ts +++ b/packages/core/src/io/writer.ts @@ -1,4 +1,12 @@ -import { Format, GLB_BUFFER, PropertyType, VERSION, VertexLayout } from '../constants'; +import { + ComponentTypeToTypedArray, + Format, + GLB_BUFFER, + PropertyType, + TypedArray, + VERSION, + VertexLayout, +} from '../constants'; import type { Document } from '../document'; import type { Extension } from '../extension'; import type { GraphEdge } from 'property-graph'; @@ -9,6 +17,7 @@ import { BufferUtils, Logger, MathUtils } from '../utils'; import { WriterContext } from './writer-context'; const { BufferViewUsage } = WriterContext; +const { UNSIGNED_INT, UNSIGNED_SHORT, UNSIGNED_BYTE } = Accessor.ComponentType; export interface WriterOptions { format: Format; @@ -16,7 +25,7 @@ export interface WriterOptions { basename?: string; vertexLayout?: VertexLayout; dependencies?: { [key: string]: unknown }; - extensions?: typeof Extension[]; + extensions?: (typeof Extension)[]; } /** @internal */ @@ -200,6 +209,147 @@ export class GLTFWriter { return { byteLength, buffers: [new Uint8Array(buffer)] }; } + /** + * Pack a group of sparse accessors. Appends accessor and buffer view + * definitions to the root JSON lists. + * + * @param accessors Accessors to be included. + * @param bufferIndex Buffer to write to. + * @param bufferByteOffset Current offset into the buffer, accounting for other buffer views. + */ + function concatSparseAccessors( + accessors: Accessor[], + bufferIndex: number, + bufferByteOffset: number + ): BufferViewResult { + const buffers: Uint8Array[] = []; + let byteLength = 0; + + interface SparseData { + accessorDef: GLTF.IAccessor; + count: number; + indices?: number[]; + values?: TypedArray; + indicesByteOffset?: number; + valuesByteOffset?: number; + } + const sparseData = new Map(); + let maxIndex = -Infinity; + + // (1) Write accessor definitions, gathering indices and values. + + for (const accessor of accessors) { + const accessorDef = context.createAccessorDef(accessor); + json.accessors!.push(accessorDef); + context.accessorIndexMap.set(accessor, json.accessors!.length - 1); + + const indices = []; + const values = []; + + const el = [] as number[]; + const base = new Array(accessor.getElementSize()).fill(0); + + for (let i = 0, il = accessor.getCount(); i < il; i++) { + accessor.getElement(i, el); + if (MathUtils.eq(el, base, 0)) continue; + + maxIndex = Math.max(i, maxIndex); + indices.push(i); + for (let j = 0; j < el.length; j++) values.push(el[j]); + } + + const count = indices.length; + const data: SparseData = { accessorDef, count }; + sparseData.set(accessor, data); + + if (count === 0) continue; + + if (count > accessor.getCount() / 3) { + // Too late to write non-sparse values in the proper buffer views here. + const pct = ((100 * indices.length) / accessor.getCount()).toFixed(1); + logger.warn(`Sparse accessor with many non-zero elements (${pct}%) may increase file size.`); + } + + const ValueArray = ComponentTypeToTypedArray[accessor.getComponentType()]; + data.indices = indices; + data.values = new ValueArray(values); + } + + // (2) Early exit if all sparse accessors are just zero-filled arrays. + + if (!Number.isFinite(maxIndex)) { + return { buffers, byteLength }; + } + + // (3) Write index buffer view. + + const IndexArray = maxIndex < 255 ? Uint8Array : maxIndex < 65535 ? Uint16Array : Uint32Array; + const IndexComponentType = + maxIndex < 255 ? UNSIGNED_BYTE : maxIndex < 65535 ? UNSIGNED_SHORT : UNSIGNED_INT; + + const indicesBufferViewDef: GLTF.IBufferView = { + buffer: bufferIndex, + byteOffset: bufferByteOffset + byteLength, + byteLength: 0, + }; + for (const accessor of accessors) { + const data = sparseData.get(accessor)!; + if (data.count === 0) continue; + + data.indicesByteOffset = indicesBufferViewDef.byteLength; + + const buffer = BufferUtils.pad(BufferUtils.toView(new IndexArray(data.indices!))); + buffers.push(buffer); + byteLength += buffer.byteLength; + indicesBufferViewDef.byteLength += buffer.byteLength; + } + json.bufferViews!.push(indicesBufferViewDef); + const indicesBufferViewIndex = json.bufferViews!.length - 1; + + // (4) Write value buffer view. + + const valuesBufferViewDef: GLTF.IBufferView = { + buffer: bufferIndex, + byteOffset: bufferByteOffset + byteLength, + byteLength: 0, + }; + for (const accessor of accessors) { + const data = sparseData.get(accessor)!; + if (data.count === 0) continue; + + data.valuesByteOffset = valuesBufferViewDef.byteLength; + + const buffer = BufferUtils.pad(BufferUtils.toView(data.values!)); + buffers.push(buffer); + byteLength += buffer.byteLength; + valuesBufferViewDef.byteLength += buffer.byteLength; + } + json.bufferViews!.push(valuesBufferViewDef); + const valuesBufferViewIndex = json.bufferViews!.length - 1; + + // (5) Write accessor sparse entries. + + for (const accessor of accessors) { + const data = sparseData.get(accessor) as Required; + if (data.count === 0) continue; + + data.accessorDef.sparse = { + count: data.count, + indices: { + bufferView: indicesBufferViewIndex, + byteOffset: data.indicesByteOffset, + componentType: IndexComponentType, + }, + values: { + bufferView: valuesBufferViewIndex, + byteOffset: data.valuesByteOffset, + }, + }; + } + + return { buffers, byteLength }; + } + /* Data use pre-processing. */ const accessorRefs = new Map[]>(); @@ -310,8 +460,7 @@ export class GLTFWriter { for (const usage in usageGroups) { if (groupByParent.has(usage)) { - // Accessors grouped by (first) parent, including vertex and instance - // attributes. + // Accessors grouped by (first) parent, including vertex and instance attributes. for (const parentAccessors of Array.from(accessorParents.values())) { const accessors = Array.from(parentAccessors) .filter((a) => bufferAccessorsSet.has(a)) @@ -353,7 +502,10 @@ export class GLTFWriter { usage === BufferViewUsage.ELEMENT_ARRAY_BUFFER ? WriterContext.BufferViewTarget.ELEMENT_ARRAY_BUFFER : undefined; - const result = concatAccessors(accessors, bufferIndex, bufferByteLength, target); + const result = + usage === BufferViewUsage.SPARSE + ? concatSparseAccessors(accessors, bufferIndex, bufferByteLength) + : concatAccessors(accessors, bufferIndex, bufferByteLength, target); bufferByteLength += result.byteLength; buffers.push(...result.buffers); } diff --git a/packages/core/src/properties/accessor.ts b/packages/core/src/properties/accessor.ts index bc680d80d..1b3e5d5c4 100644 --- a/packages/core/src/properties/accessor.ts +++ b/packages/core/src/properties/accessor.ts @@ -10,6 +10,7 @@ interface IAccessor extends IExtensibleProperty { type: GLTF.AccessorType; componentType: GLTF.AccessorComponentType; normalized: boolean; + sparse: boolean; buffer: Buffer; } @@ -142,6 +143,7 @@ export class Accessor extends ExtensibleProperty { type: Accessor.Type.SCALAR, componentType: Accessor.ComponentType.FLOAT, normalized: false, + sparse: false, buffer: null, }); } @@ -422,6 +424,28 @@ export class Accessor extends ExtensibleProperty { * Raw data storage. */ + /** + * Specifies whether the accessor should be stored sparsely. When written to a glTF file, sparse + * accessors store only values that differ from base values. When loaded in glTF Transform (or most + * runtimes) a sparse accessor can be treated like any other accessor. Currently, glTF Transform always + * uses zeroes for the base values when writing files. + * @experimental + */ + public getSparse(): boolean { + return this.get('sparse'); + } + + /** + * Specifies whether the accessor should be stored sparsely. When written to a glTF file, sparse + * accessors store only values that differ from base values. When loaded in glTF Transform (or most + * runtimes) a sparse accessor can be treated like any other accessor. Currently, glTF Transform always + * uses zeroes for the base values when writing files. + * @experimental + */ + public setSparse(sparse: boolean): this { + return this.set('sparse', sparse); + } + /** Returns the {@link Buffer} into which this accessor will be organized. */ public getBuffer(): Buffer | null { return this.getRef('buffer'); diff --git a/packages/core/test/properties/accessor.test.ts b/packages/core/test/properties/accessor.test.ts index 3224d6e78..31dfe4437 100644 --- a/packages/core/test/properties/accessor.test.ts +++ b/packages/core/test/properties/accessor.test.ts @@ -2,6 +2,8 @@ import test from 'tape'; import { Accessor, Document, GLTF, TypedArray } from '@gltf-transform/core'; import { createPlatformIO } from '../../../test-utils'; +const { FLOAT, UNSIGNED_BYTE, UNSIGNED_SHORT, UNSIGNED_INT, BYTE, SHORT } = Accessor.ComponentType; + test('@gltf-transform/core::accessor | getScalar/setScalar', (t) => { const accessor = new Document() .createAccessor() @@ -43,12 +45,12 @@ test('@gltf-transform/core::accessor | normalized', (t) => { test('@gltf-transform/core::accessor | getComponentType', (t) => { const accessor = new Document().createAccessor(); - t.equal(accessor.setArray(new Float32Array()).getComponentType(), Accessor.ComponentType.FLOAT, 'float'); - t.equal(accessor.setArray(new Uint32Array()).getComponentType(), Accessor.ComponentType.UNSIGNED_INT, 'uint32'); - t.equal(accessor.setArray(new Uint16Array()).getComponentType(), Accessor.ComponentType.UNSIGNED_SHORT, 'uint16'); - t.equal(accessor.setArray(new Uint8Array()).getComponentType(), Accessor.ComponentType.UNSIGNED_BYTE, 'uint8'); - t.equal(accessor.setArray(new Int16Array()).getComponentType(), Accessor.ComponentType.SHORT, 'int16'); - t.equal(accessor.setArray(new Int8Array()).getComponentType(), Accessor.ComponentType.BYTE, 'int8'); + t.equal(accessor.setArray(new Float32Array()).getComponentType(), FLOAT, 'float'); + t.equal(accessor.setArray(new Uint32Array()).getComponentType(), UNSIGNED_INT, 'uint32'); + t.equal(accessor.setArray(new Uint16Array()).getComponentType(), UNSIGNED_SHORT, 'uint16'); + t.equal(accessor.setArray(new Uint8Array()).getComponentType(), UNSIGNED_BYTE, 'uint8'); + t.equal(accessor.setArray(new Int16Array()).getComponentType(), SHORT, 'int16'); + t.equal(accessor.setArray(new Int8Array()).getComponentType(), BYTE, 'int8'); t.throws(() => accessor.setArray(new Int32Array() as unknown as TypedArray).getComponentType(), 'int32 (throws)'); t.end(); }); @@ -116,21 +118,21 @@ test('@gltf-transform/core::accessor | interleaved', async (t) => { bufferView: 0, byteOffset: 0, type: Accessor.Type.VEC3, - componentType: Accessor.ComponentType.UNSIGNED_SHORT, + componentType: UNSIGNED_SHORT, }, { count: 2, bufferView: 0, byteOffset: 6, type: Accessor.Type.VEC2, - componentType: Accessor.ComponentType.UNSIGNED_SHORT, + componentType: UNSIGNED_SHORT, }, { count: 2, bufferView: 0, byteOffset: 10, type: Accessor.Type.VEC2, - componentType: Accessor.ComponentType.UNSIGNED_SHORT, + componentType: UNSIGNED_SHORT, }, ], bufferViews: [ @@ -162,7 +164,7 @@ test('@gltf-transform/core::accessor | interleaved', async (t) => { t.end(); }); -test('@gltf-transform/core::accessor | sparse', async (t) => { +test('@gltf-transform/core::accessor | read sparse', async (t) => { const resources = { 'indices.bin': new Uint8Array(new Uint16Array([10, 50, 51]).buffer), 'values.bin': new Uint8Array(new Float32Array([1, 2, 3, 10, 12, 14, 25, 50, 75]).buffer), @@ -174,12 +176,12 @@ test('@gltf-transform/core::accessor | sparse', async (t) => { { count: 100, type: Accessor.Type.VEC3, - componentType: Accessor.ComponentType.FLOAT, + componentType: FLOAT, sparse: { count: 3, indices: { bufferView: 0, - componentType: Accessor.ComponentType.UNSIGNED_SHORT, + componentType: UNSIGNED_SHORT, }, values: { bufferView: 1, @@ -224,6 +226,53 @@ test('@gltf-transform/core::accessor | sparse', async (t) => { t.end(); }); +test('@gltf-transform/core::accessor | write sparse', async (t) => { + const document = new Document(); + const buffer = document.createBuffer(); + const emptyArray = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; + const sparseArray = [0, 0, 0, 0, 0, 25, 0, 0, 15, 0, 0, 0, 0, 0]; + document.createAccessor('Empty').setArray(new Uint8Array(emptyArray)).setSparse(true).setBuffer(buffer); + document.createAccessor('Sparse').setArray(new Uint8Array(sparseArray)).setSparse(true).setBuffer(buffer); + + const io = await createPlatformIO(); + const { json, resources } = await io.writeJSON(document); + + const emptyDef = json.accessors[0]!; + const sparseDef = json.accessors[1]!; + + t.equals(emptyDef.count, 14, 'emptyAccessor.count'); + t.equals(sparseDef.count, 14, 'sparseAccessor.count'); + t.notOk(emptyDef.sparse, 'emptyAccessor json'); + t.deepEquals( + sparseDef.sparse, + { + count: 2, + indices: { + bufferView: 0, + byteOffset: 0, + componentType: UNSIGNED_BYTE, + }, + values: { + bufferView: 1, + byteOffset: 0, + }, + }, + 'sparseAccessor json' + ); + + const rtDocument = await io.readJSON({ json, resources }); + const rtEmptyAccessor = rtDocument.getRoot().listAccessors()[0]; + const rtSparseAccessor = rtDocument.getRoot().listAccessors()[1]; + + t.equals(rtEmptyAccessor.getSparse(), true, 'emptyAccessor.sparse (round trip)'); + t.equals(rtSparseAccessor.getSparse(), true, 'sparseAccessor.sparse (round trip)'); + + t.deepEquals(Array.from(rtEmptyAccessor.getArray()), emptyArray, 'emptyAccessor.array (round trip)'); + t.deepEquals(Array.from(rtSparseAccessor.getArray()), sparseArray, 'emptyAccessor.array (round trip)'); + + t.end(); +}); + test('@gltf-transform/core::accessor | minmax', (t) => { const doc = new Document(); const accessor = doc diff --git a/packages/extensions/src/ext-meshopt-compression/meshopt-compression.ts b/packages/extensions/src/ext-meshopt-compression/meshopt-compression.ts index cc1b38983..b0802d7ca 100644 --- a/packages/extensions/src/ext-meshopt-compression/meshopt-compression.ts +++ b/packages/extensions/src/ext-meshopt-compression/meshopt-compression.ts @@ -296,6 +296,9 @@ export class EXTMeshoptCompression extends Extension { // 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 mode = getMeshoptMode(accessor, usage); const filter = diff --git a/packages/extensions/src/khr-draco-mesh-compression/draco-mesh-compression.ts b/packages/extensions/src/khr-draco-mesh-compression/draco-mesh-compression.ts index 0e6e37a54..065645ba2 100644 --- a/packages/extensions/src/khr-draco-mesh-compression/draco-mesh-compression.ts +++ b/packages/extensions/src/khr-draco-mesh-compression/draco-mesh-compression.ts @@ -284,6 +284,8 @@ export class KHRDracoMeshCompression extends Extension { // Create attribute definitions, update count. for (const semantic of prim.listSemantics()) { const attribute = prim.getAttribute(semantic)!; + if (encodedPrim.attributeIDs[semantic] === undefined) continue; // sparse + const attributeDef = context.createAccessorDef(attribute); attributeDef.count = encodedPrim.numVertices; context.accessorIndexMap.set(attribute, accessorDefs.length); diff --git a/packages/extensions/src/khr-draco-mesh-compression/encoder.ts b/packages/extensions/src/khr-draco-mesh-compression/encoder.ts index 73f8b0ca1..c2af8ae90 100644 --- a/packages/extensions/src/khr-draco-mesh-compression/encoder.ts +++ b/packages/extensions/src/khr-draco-mesh-compression/encoder.ts @@ -67,8 +67,17 @@ export function encodeGeometry(prim: Primitive, _options: EncoderOptions = DEFAU const attributeIDs: { [key: string]: number } = {}; const dracoBuffer = new encoderModule.DracoInt8Array(); + const hasMorphTargets = prim.listTargets().length > 0; + let hasSparseAttributes = false; + for (const semantic of prim.listSemantics()) { const attribute = prim.getAttribute(semantic)!; + + if (attribute.getSparse()) { + hasSparseAttributes = true; + continue; + } + const attributeEnum = getAttributeEnum(semantic); const attributeID: number = addAttribute( builder, @@ -113,7 +122,7 @@ export function encodeGeometry(prim: Primitive, _options: EncoderOptions = DEFAU encoder.SetTrackEncodedProperties(true); // Preserve vertex order for primitives with morph targets. - if (options.method === EncoderMethod.SEQUENTIAL || prim.listTargets().length > 0) { + if (options.method === EncoderMethod.SEQUENTIAL || hasMorphTargets || hasSparseAttributes) { encoder.SetEncodingMethod(encoderModule.MESH_SEQUENTIAL_ENCODING); } else { encoder.SetEncodingMethod(encoderModule.MESH_EDGEBREAKER_ENCODING); @@ -131,10 +140,11 @@ export function encodeGeometry(prim: Primitive, _options: EncoderOptions = DEFAU const numVertices = encoder.GetNumberOfEncodedPoints(); const numIndices = encoder.GetNumberOfEncodedFaces() * 3; - if (prim.listTargets().length > 0 && numVertices !== prevNumVertices) { + if ((hasMorphTargets || hasSparseAttributes) && numVertices !== prevNumVertices) { throw new EncodingError( - 'Compression reduced vertex count unexpectedly, corrupting morph targets.' + - ' Applying the "weld" function before compression may resolve the issue.' + 'Compression reduced vertex count unexpectedly, corrupting mesh data.' + + ' Applying the "weld" function before compression may resolve the issue.' + + ' See: https://github.com/google/draco/issues/929' ); } diff --git a/packages/extensions/test/draco-mesh-compression.test.ts b/packages/extensions/test/draco-mesh-compression.test.ts index 00877247e..78f7b936c 100644 --- a/packages/extensions/test/draco-mesh-compression.test.ts +++ b/packages/extensions/test/draco-mesh-compression.test.ts @@ -179,6 +179,46 @@ test('@gltf-transform/extensions::draco-mesh-compression | encoding skipped', as t.end(); }); +test('@gltf-transform/extensions::draco-mesh-compression | encoding sparse', async (t) => { + const doc = new Document(); + doc.createExtension(KHRDracoMeshCompression).setRequired(true); + + const buffer = doc.createBuffer(); + const sparseAccessor = doc + .createAccessor() + .setArray(new Uint32Array([0, 0, 0, 0, 25, 0])) + .setSparse(true); + const prim = createMeshPrimitive(doc, buffer).setAttribute('_SPARSE', sparseAccessor); + const mesh = doc.createMesh().addPrimitive(prim); + + const io = await createEncoderIO(); + const jsonDoc = await io.writeJSON(doc, { format: Format.GLB }); + const primitiveDefs = jsonDoc.json.meshes[0].primitives; + const accessorDefs = jsonDoc.json.accessors; + + t.equals(primitiveDefs.length, mesh.listPrimitives().length, 'writes all primitives'); + t.deepEquals( + primitiveDefs[0], + { + mode: Primitive.Mode.TRIANGLES, + indices: 0, + attributes: { POSITION: 1, _SPARSE: 2 }, + extensions: { + KHR_draco_mesh_compression: { + bufferView: 2, + attributes: { POSITION: 0 }, + }, + }, + }, + 'primitiveDef' + ); + t.equals(accessorDefs[1].count, 6, 'POSITION count'); + t.equals(accessorDefs[2].count, 6, '_SPARSE count'); + t.equals(accessorDefs[1].sparse, undefined, 'POSITION not sparse'); + t.equals(accessorDefs[2].sparse.count, 1, '_SPARSE sparse'); + t.end(); +}); + test('@gltf-transform/extensions::draco-mesh-compression | mixed indices', async (t) => { const doc = new Document(); doc.createExtension(KHRDracoMeshCompression).setRequired(true); diff --git a/packages/extensions/test/meshopt-compression.test.ts b/packages/extensions/test/meshopt-compression.test.ts index e129cbbf8..a200c2915 100644 --- a/packages/extensions/test/meshopt-compression.test.ts +++ b/packages/extensions/test/meshopt-compression.test.ts @@ -2,18 +2,14 @@ require('source-map-support').install(); import path from 'path'; import test from 'tape'; -import { NodeIO, getBounds } from '@gltf-transform/core'; +import { Document, NodeIO, getBounds, Format, Primitive } from '@gltf-transform/core'; import { EXTMeshoptCompression, KHRMeshQuantization } from '../'; import { MeshoptDecoder, MeshoptEncoder } from 'meshoptimizer'; const INPUTS = ['BoxMeshopt.glb', 'BoxMeshopt.gltf']; -test('@gltf-transform/extensions::draco-mesh-compression | decoding', async (t) => { - await MeshoptDecoder.ready; - - const io = new NodeIO() - .registerExtensions([EXTMeshoptCompression, KHRMeshQuantization]) - .registerDependencies({ 'meshopt.decoder': MeshoptDecoder }); +test('@gltf-transform/extensions::meshopt-compression | decoding', async (t) => { + const io = await createEncoderIO(); for (const input of INPUTS) { const doc = await io.read(path.join(__dirname, 'in', input)); @@ -33,13 +29,8 @@ test('@gltf-transform/extensions::draco-mesh-compression | decoding', async (t) t.end(); }); -test('@gltf-transform/extensions::draco-mesh-compression | encoding', async (t) => { - await Promise.all([MeshoptDecoder.ready, MeshoptEncoder.ready]); - - const io = new NodeIO().registerExtensions([EXTMeshoptCompression, KHRMeshQuantization]).registerDependencies({ - 'meshopt.decoder': MeshoptDecoder, - 'meshopt.encoder': MeshoptEncoder, - }); +test('@gltf-transform/extensions::meshopt-compression | encoding', async (t) => { + const io = await createEncoderIO(); const doc = await io.read(path.join(__dirname, 'in', 'BoxMeshopt.glb')); const glb = await io.writeBinary(doc); @@ -64,3 +55,64 @@ test('@gltf-transform/extensions::draco-mesh-compression | encoding', async (t) ); t.end(); }); + +test('@gltf-transform/extensions::meshopt-compression | encoding sparse', async (t) => { + const io = await createEncoderIO(); + + const doc = new Document(); + doc.createExtension(EXTMeshoptCompression).setRequired(true); + + // prettier-ignore + const positionArray = [ + 0, 0, 1, + 0, 1, 0, + 0, 1, 1, + 0, 1, 0, + 0, 0, 1, + 0, 0, 0, + ]; + const sparseArray = [0, 0, 0, 0, 25, 0]; + + const buffer = doc.createBuffer(); + const position = doc.createAccessor().setType('VEC3').setBuffer(buffer).setArray(new Float32Array(positionArray)); + const marker = doc.createAccessor().setBuffer(buffer).setArray(new Uint32Array(sparseArray)).setSparse(true); + const prim = doc.createPrimitive().setAttribute('POSITION', position).setAttribute('_SPARSE', marker); + const mesh = doc.createMesh().addPrimitive(prim); + + const { json, resources } = await io.writeJSON(doc, { format: Format.GLB }); + const primitiveDefs = json.meshes[0].primitives; + const accessorDefs = json.accessors; + + t.equals(primitiveDefs.length, mesh.listPrimitives().length, 'writes all primitives'); + t.deepEquals( + primitiveDefs[0], + { + mode: Primitive.Mode.TRIANGLES, + attributes: { POSITION: 0, _SPARSE: 1 }, + }, + 'primitiveDef' + ); + t.equals(accessorDefs[0].count, 6, 'POSITION count'); + t.equals(accessorDefs[1].count, 6, '_SPARSE count'); + t.equals(accessorDefs[0].sparse, undefined, 'POSITION not sparse'); + t.equals(accessorDefs[1].sparse.count, 1, '_SPARSE sparse'); + + const rtDocument = await io.readJSON({ json, resources }); + const rtPosition = rtDocument.getRoot().listAccessors()[0]; + const rtMarker = rtDocument.getRoot().listAccessors()[1]; + + t.equals(rtPosition.getSparse(), false, 'POSITION not sparse (round trip)'); + t.equals(rtMarker.getSparse(), true, '_SPARSE sparse (round trip)'); + t.deepEquals(Array.from(rtPosition.getArray()), positionArray, 'POSITION array'); + t.deepEquals(Array.from(rtMarker.getArray()), sparseArray, '_SPARSE array'); + + t.end(); +}); + +async function createEncoderIO(): Promise { + await Promise.all([MeshoptDecoder.ready, MeshoptEncoder.ready]); + return new NodeIO().registerExtensions([EXTMeshoptCompression, KHRMeshQuantization]).registerDependencies({ + 'meshopt.decoder': MeshoptDecoder, + 'meshopt.encoder': MeshoptEncoder, + }); +} diff --git a/packages/functions/src/index.ts b/packages/functions/src/index.ts index 0c9c09189..250923465 100644 --- a/packages/functions/src/index.ts +++ b/packages/functions/src/index.ts @@ -72,6 +72,7 @@ export * from './reorder'; export * from './sequence'; export * from './simplify'; export * from './sort-primitive-weights'; +export * from './sparse'; export * from './texture-compress'; export * from './tangents'; export * from './texture-resize'; diff --git a/packages/functions/src/quantize.ts b/packages/functions/src/quantize.ts index 410416530..0c27f76e1 100644 --- a/packages/functions/src/quantize.ts +++ b/packages/functions/src/quantize.ts @@ -298,7 +298,8 @@ function quantizeAttribute(attribute: Accessor, ctor: TypedArrayConstructor, bit } } - attribute.setArray(dstArray).setNormalized(true); + // TODO(feat): Support sparse accessors, https://github.com/donmccurdy/glTF-Transform/issues/795 + attribute.setArray(dstArray).setNormalized(true).setSparse(false); } function getQuantizationSettings( diff --git a/packages/functions/src/resample.ts b/packages/functions/src/resample.ts index 4c2edabaf..9e49efcee 100644 --- a/packages/functions/src/resample.ts +++ b/packages/functions/src/resample.ts @@ -79,8 +79,8 @@ export const resample = (_options: ResampleOptions = RESAMPLE_DEFAULTS): Transfo }; function optimize(sampler: AnimationSampler, path: GLTF.AnimationChannelTargetPath, options: ResampleOptions): void { - const input = sampler.getInput()!.clone(); - const output = sampler.getOutput()!.clone(); + const input = sampler.getInput()!.clone().setSparse(false); + const output = sampler.getOutput()!.clone().setSparse(false); const tolerance = options.tolerance as number; const interpolation = sampler.getInterpolation(); diff --git a/packages/functions/src/sparse.ts b/packages/functions/src/sparse.ts new file mode 100644 index 000000000..fc14053d8 --- /dev/null +++ b/packages/functions/src/sparse.ts @@ -0,0 +1,76 @@ +import { Document, MathUtils, Transform } from '@gltf-transform/core'; +import { createTransform } from './utils'; + +const NAME = 'sparse'; + +/** Options for the {@link sparse} function. */ +export interface SparseOptions { + /** + * Threshold ratio used to determine when an accessor should be sparse. + * Default: 1 / 3. + */ + ratio: number; +} + +const SPARSE_DEFAULTS: Required = { + ratio: 1 / 3, +}; + +/** + * Scans all {@link Accessor Accessors} in the Document, detecting whether each Accessor + * would benefit from sparse data storage. Currently, sparse data storage is used only + * when many values (≥ ratio) are zeroes. Particularly for assets using morph target + * ("shape key") animation, sparse data storage may significantly reduce file sizes. + * + * Example: + * + * ```ts + * import { sparse } from '@gltf-transform/functions'; + * + * accessor.getArray(); // → [ 0, 0, 0, 0, 0, 25.0, 0, 0, ... ] + * accessor.getSparse(); // → false + * + * await document.transform(sparse({ratio: 1 / 10})); + * + * accessor.getSparse(); // → true + * ``` + * + * @experimental + */ +export function sparse(_options: SparseOptions = SPARSE_DEFAULTS): Transform { + const options = { ...SPARSE_DEFAULTS, ..._options } as Required; + + const ratio = options.ratio; + if (ratio < 0 || ratio > 1) { + throw new Error(`${NAME}: Ratio must be between 0 and 1.`); + } + + return createTransform(NAME, (document: Document): void => { + const root = document.getRoot(); + const logger = document.getLogger(); + + let modifiedCount = 0; + + for (const accessor of root.listAccessors()) { + const count = accessor.getCount(); + const base = Array(accessor.getElementSize()).fill(0); + const el = Array(accessor.getElementSize()).fill(0); + + let nonZeroCount = 0; + for (let i = 0; i < count; i++) { + accessor.getElement(i, el); + if (!MathUtils.eq(el, base, 0)) nonZeroCount++; + if (nonZeroCount / count >= ratio) break; + } + + const sparse = nonZeroCount / count < ratio; + if (sparse !== accessor.getSparse()) { + accessor.setSparse(sparse); + modifiedCount++; + } + } + + logger.debug(`${NAME}: Updated ${modifiedCount} accessors.`); + logger.debug(`${NAME}: Complete.`); + }); +} diff --git a/packages/functions/test/sparse.test.ts b/packages/functions/test/sparse.test.ts new file mode 100644 index 000000000..d32aec662 --- /dev/null +++ b/packages/functions/test/sparse.test.ts @@ -0,0 +1,15 @@ +require('source-map-support').install(); + +import test from 'tape'; +import { Document, Logger } from '@gltf-transform/core'; +import { sparse } from '../'; + +test('@gltf-transform/functions::sparse', async (t) => { + const document = new Document().setLogger(new Logger(Logger.Verbosity.SILENT)); + const denseAccessor = document.createAccessor().setArray(new Float32Array([1, 2, 3, 4, 5, 6, 7, 8])); + const sparseAccessor = document.createAccessor().setArray(new Float32Array([0, 0, 0, 0, 1, 0, 0, 0])); + await document.transform(sparse()); + t.equals(denseAccessor.getSparse(), false, 'denseAccessor.sparse = false'); + t.equals(sparseAccessor.getSparse(), true, 'sparseAccessor.sparse = true'); + t.end(); +});