From 7c6186aab46c9dee85675e2c56fa50438b0f09a9 Mon Sep 17 00:00:00 2001 From: Don McCurdy Date: Thu, 19 Jan 2023 23:10:31 -0500 Subject: [PATCH 1/9] [core] Add accessor.getSparse(), accessor.setSparse(bool) --- packages/core/src/constants.ts | 10 ++ packages/core/src/io/reader.ts | 12 +- packages/core/src/io/writer-context.ts | 6 +- packages/core/src/io/writer.ts | 145 ++++++++++++++++++++++- packages/core/src/properties/accessor.ts | 22 ++++ 5 files changed, 178 insertions(+), 17 deletions(-) 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..5ba67d2ef 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)[]; @@ -123,6 +114,7 @@ export class GLTFReader { if (accessorDef.sparse !== undefined) { array = getSparseArray(accessorDef, context); + accessor.setSparse(true); } else { array = getAccessorArray(accessorDef, context); } 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..192fb9074 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,130 @@ 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; + + const sparseIndices = new Map(); + const sparseValues = new Map(); + const sparseIndicesByteOffsets = new Map(); + const sparseValuesByteOffsets = new Map(); + let maxIndex = -Infinity; + + // (1) For each accessor, gather indices and values. + + for (const accessor of accessors) { + const elementSize = accessor.getElementSize(); + + const indices = []; + const values = []; + + const el = [] as number[]; + const base = new Array(elementSize).fill(0); + + for (let i = 0, il = accessor.getCount(); i < il; i++) { + accessor.getElement(i, el); + if (MathUtils.eq(el, base)) continue; + + maxIndex = Math.max(i, maxIndex); + indices.push(i); + for (let j = 0; j < el.length; j++) values.push(el[j]); + } + + if (indices.length > 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.`); + } else if (indices.length === 0) { + // TODO(test): How does the input asset pass validation? + logger.error(`Sparse accessor containing only zero-filled elements is invalid.`); + } + + const ValueArray = ComponentTypeToTypedArray[accessor.getComponentType()]; + sparseIndices.set(accessor, indices); + sparseValues.set(accessor, new ValueArray(values)); + } + + // (2) 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) { + sparseIndicesByteOffsets.set(accessor, indicesBufferViewDef.byteLength); + + const indices = sparseIndices.get(accessor)!; + const buffer = BufferUtils.pad(BufferUtils.toView(new IndexArray(indices))); + buffers.push(buffer); + byteLength += buffer.byteLength; + indicesBufferViewDef.byteLength += buffer.byteLength; + } + json.bufferViews!.push(indicesBufferViewDef); + const indicesBufferViewIndex = json.bufferViews!.length - 1; + + // (3) Write value buffer view. + + const valuesBufferViewDef: GLTF.IBufferView = { + buffer: bufferIndex, + byteOffset: bufferByteOffset + byteLength, + byteLength: 0, + }; + for (const accessor of accessors) { + sparseValuesByteOffsets.set(accessor, valuesBufferViewDef.byteLength); + + const values = sparseValues.get(accessor)!; + const buffer = BufferUtils.pad(BufferUtils.toView(values)); + buffers.push(buffer); + byteLength += buffer.byteLength; + valuesBufferViewDef.byteLength += buffer.byteLength; + } + json.bufferViews!.push(valuesBufferViewDef); + const valuesBufferViewIndex = json.bufferViews!.length - 1; + + // (4) Write accessors. + + for (const accessor of accessors) { + const accessorDef = context.createAccessorDef(accessor); + const indices = sparseIndices.get(accessor)!; + const indicesByteOffset = sparseIndicesByteOffsets.get(accessor)!; + const valuesByteOffset = sparseValuesByteOffsets.get(accessor)!; + accessorDef.sparse = { + count: indices.length, + indices: { + bufferView: indicesBufferViewIndex, + byteOffset: indicesByteOffset, + componentType: IndexComponentType, + }, + values: { + bufferView: valuesBufferViewIndex, + byteOffset: valuesByteOffset, + }, + }; + json.accessors!.push(accessorDef); + context.accessorIndexMap.set(accessor, json.accessors!.length - 1); + } + + return { buffers, byteLength }; + } + /* Data use pre-processing. */ const accessorRefs = new Map[]>(); @@ -310,8 +443,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 +485,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..ac409c77b 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,26 @@ 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. + */ + 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. + */ + 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'); From ed9265ed1a334e9aa8638d5092e3a85be1246dec Mon Sep 17 00:00:00 2001 From: Don McCurdy Date: Thu, 19 Jan 2023 23:49:13 -0500 Subject: [PATCH 2/9] Fix zero-filled sparse accessors. --- packages/core/src/io/writer.ts | 81 ++++++++++++++++++++-------------- 1 file changed, 49 insertions(+), 32 deletions(-) diff --git a/packages/core/src/io/writer.ts b/packages/core/src/io/writer.ts index 192fb9074..d6c32eaa4 100644 --- a/packages/core/src/io/writer.ts +++ b/packages/core/src/io/writer.ts @@ -225,22 +225,29 @@ export class GLTFWriter { const buffers: Uint8Array[] = []; let byteLength = 0; - const sparseIndices = new Map(); - const sparseValues = new Map(); - const sparseIndicesByteOffsets = new Map(); - const sparseValuesByteOffsets = new Map(); + interface SparseData { + accessorDef: GLTF.IAccessor; + count: number; + indices?: number[]; + values?: TypedArray; + indicesByteOffset?: number; + valuesByteOffset?: number; + } + const sparseData = new Map(); let maxIndex = -Infinity; - // (1) For each accessor, gather indices and values. + // (1) Write accessor definitions, gathering indices and values. for (const accessor of accessors) { - const elementSize = accessor.getElementSize(); + 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(elementSize).fill(0); + const base = new Array(accessor.getElementSize()).fill(0); for (let i = 0, il = accessor.getCount(); i < il; i++) { accessor.getElement(i, el); @@ -251,21 +258,30 @@ export class GLTFWriter { for (let j = 0; j < el.length; j++) values.push(el[j]); } - if (indices.length > accessor.getCount() / 3) { + 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.`); - } else if (indices.length === 0) { - // TODO(test): How does the input asset pass validation? - logger.error(`Sparse accessor containing only zero-filled elements is invalid.`); } const ValueArray = ComponentTypeToTypedArray[accessor.getComponentType()]; - sparseIndices.set(accessor, indices); - sparseValues.set(accessor, new ValueArray(values)); + data.indices = indices; + data.values = new ValueArray(values); } - // (2) Write index buffer view. + // (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 = @@ -277,10 +293,12 @@ export class GLTFWriter { byteLength: 0, }; for (const accessor of accessors) { - sparseIndicesByteOffsets.set(accessor, indicesBufferViewDef.byteLength); + const data = sparseData.get(accessor)!; + if (data.count === 0) continue; + + data.indicesByteOffset = indicesBufferViewDef.byteLength; - const indices = sparseIndices.get(accessor)!; - const buffer = BufferUtils.pad(BufferUtils.toView(new IndexArray(indices))); + const buffer = BufferUtils.pad(BufferUtils.toView(new IndexArray(data.indices!))); buffers.push(buffer); byteLength += buffer.byteLength; indicesBufferViewDef.byteLength += buffer.byteLength; @@ -288,7 +306,7 @@ export class GLTFWriter { json.bufferViews!.push(indicesBufferViewDef); const indicesBufferViewIndex = json.bufferViews!.length - 1; - // (3) Write value buffer view. + // (4) Write value buffer view. const valuesBufferViewDef: GLTF.IBufferView = { buffer: bufferIndex, @@ -296,10 +314,12 @@ export class GLTFWriter { byteLength: 0, }; for (const accessor of accessors) { - sparseValuesByteOffsets.set(accessor, valuesBufferViewDef.byteLength); + const data = sparseData.get(accessor)!; + if (data.count === 0) continue; - const values = sparseValues.get(accessor)!; - const buffer = BufferUtils.pad(BufferUtils.toView(values)); + data.valuesByteOffset = valuesBufferViewDef.byteLength; + + const buffer = BufferUtils.pad(BufferUtils.toView(data.values!)); buffers.push(buffer); byteLength += buffer.byteLength; valuesBufferViewDef.byteLength += buffer.byteLength; @@ -307,27 +327,24 @@ export class GLTFWriter { json.bufferViews!.push(valuesBufferViewDef); const valuesBufferViewIndex = json.bufferViews!.length - 1; - // (4) Write accessors. + // (5) Write accessor sparse entries. for (const accessor of accessors) { - const accessorDef = context.createAccessorDef(accessor); - const indices = sparseIndices.get(accessor)!; - const indicesByteOffset = sparseIndicesByteOffsets.get(accessor)!; - const valuesByteOffset = sparseValuesByteOffsets.get(accessor)!; - accessorDef.sparse = { - count: indices.length, + const data = sparseData.get(accessor) as Required; + if (data.count === 0) continue; + + data.accessorDef.sparse = { + count: data.count, indices: { bufferView: indicesBufferViewIndex, - byteOffset: indicesByteOffset, + byteOffset: data.indicesByteOffset, componentType: IndexComponentType, }, values: { bufferView: valuesBufferViewIndex, - byteOffset: valuesByteOffset, + byteOffset: data.valuesByteOffset, }, }; - json.accessors!.push(accessorDef); - context.accessorIndexMap.set(accessor, json.accessors!.length - 1); } return { buffers, byteLength }; From 6f2116a6f5470141f8f2b5e9402c7b6bd84d44d5 Mon Sep 17 00:00:00 2001 From: Don McCurdy Date: Mon, 23 Jan 2023 12:22:54 -0500 Subject: [PATCH 3/9] Add unit tests, fix support for zero-filled accessors. --- packages/core/src/io/reader.ts | 35 ++++++--- .../core/test/properties/accessor.test.ts | 73 ++++++++++++++++--- 2 files changed, 84 insertions(+), 24 deletions(-) diff --git a/packages/core/src/io/reader.ts b/packages/core/src/io/reader.ts index 5ba67d2ef..366ad6951 100644 --- a/packages/core/src/io/reader.ts +++ b/packages/core/src/io/reader.ts @@ -107,19 +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; + // Sparse accessors, KHR_draco_mesh_compression, and EXT_meshopt_compression. + if (accessorDef.bufferView === undefined) return accessor; - let array: TypedArray; + // 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. - if (accessorDef.sparse !== undefined) { - array = getSparseArray(accessorDef, context); - accessor.setSparse(true); - } else { - array = getAccessorArray(accessorDef, context); - } - - accessor.setArray(array); + accessor.setArray(getAccessorArray(accessorDef, context)); return accessor; }); @@ -489,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; } @@ -610,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/test/properties/accessor.test.ts b/packages/core/test/properties/accessor.test.ts index 3224d6e78..67916fcfc 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, INT } = 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 From f4e9ef82252c147dadba06745106752a60c5235d Mon Sep 17 00:00:00 2001 From: Don McCurdy Date: Mon, 23 Jan 2023 13:37:17 -0500 Subject: [PATCH 4/9] Add sparse() transform --- packages/core/src/io/writer.ts | 2 +- packages/functions/src/index.ts | 1 + packages/functions/src/sparse.ts | 74 ++++++++++++++++++++++++++ packages/functions/test/sparse.test.ts | 15 ++++++ 4 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 packages/functions/src/sparse.ts create mode 100644 packages/functions/test/sparse.test.ts diff --git a/packages/core/src/io/writer.ts b/packages/core/src/io/writer.ts index d6c32eaa4..18f5d4c1e 100644 --- a/packages/core/src/io/writer.ts +++ b/packages/core/src/io/writer.ts @@ -251,7 +251,7 @@ export class GLTFWriter { for (let i = 0, il = accessor.getCount(); i < il; i++) { accessor.getElement(i, el); - if (MathUtils.eq(el, base)) continue; + if (MathUtils.eq(el, base, 0)) continue; maxIndex = Math.max(i, maxIndex); indices.push(i); 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/sparse.ts b/packages/functions/src/sparse.ts new file mode 100644 index 000000000..cd1751fa5 --- /dev/null +++ b/packages/functions/src/sparse.ts @@ -0,0 +1,74 @@ +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 + * ``` + */ +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(); +}); From 7bcc405528ad61f78c42823d347520722470fa6d Mon Sep 17 00:00:00 2001 From: Don McCurdy Date: Mon, 23 Jan 2023 14:10:09 -0500 Subject: [PATCH 5/9] Add support for Draco compression on prims containing sparse vertex attributes Note that the sparse accessors will _not_ be compressed, only fully-specified attributes are included in draco compression. --- .../core/test/properties/accessor.test.ts | 2 +- .../draco-mesh-compression.ts | 2 + .../src/khr-draco-mesh-compression/encoder.ts | 18 +++++++-- .../test/draco-mesh-compression.test.ts | 40 +++++++++++++++++++ 4 files changed, 57 insertions(+), 5 deletions(-) diff --git a/packages/core/test/properties/accessor.test.ts b/packages/core/test/properties/accessor.test.ts index 67916fcfc..31dfe4437 100644 --- a/packages/core/test/properties/accessor.test.ts +++ b/packages/core/test/properties/accessor.test.ts @@ -2,7 +2,7 @@ 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, INT } = Accessor.ComponentType; +const { FLOAT, UNSIGNED_BYTE, UNSIGNED_SHORT, UNSIGNED_INT, BYTE, SHORT } = Accessor.ComponentType; test('@gltf-transform/core::accessor | getScalar/setScalar', (t) => { const accessor = new Document() 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..3b697a080 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('_MARKER', 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, _MARKER: 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, '_MARKER count'); + t.equals(accessorDefs[1].sparse, undefined, 'POSITION not sparse'); + t.equals(accessorDefs[2].sparse.count, 1, '_MARKER sparse'); + t.end(); +}); + test('@gltf-transform/extensions::draco-mesh-compression | mixed indices', async (t) => { const doc = new Document(); doc.createExtension(KHRDracoMeshCompression).setRequired(true); From 73a9e153f93551d33f50cae2d17159987ac0afa7 Mon Sep 17 00:00:00 2001 From: Don McCurdy Date: Mon, 23 Jan 2023 14:32:34 -0500 Subject: [PATCH 6/9] Add support for Meshopt compression on prims containing sparse vertex attributes --- .../meshopt-compression.ts | 3 + .../test/draco-mesh-compression.test.ts | 8 +- .../test/meshopt-compression.test.ts | 80 +++++++++++++++---- 3 files changed, 73 insertions(+), 18 deletions(-) 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/test/draco-mesh-compression.test.ts b/packages/extensions/test/draco-mesh-compression.test.ts index 3b697a080..78f7b936c 100644 --- a/packages/extensions/test/draco-mesh-compression.test.ts +++ b/packages/extensions/test/draco-mesh-compression.test.ts @@ -188,7 +188,7 @@ test('@gltf-transform/extensions::draco-mesh-compression | encoding sparse', asy .createAccessor() .setArray(new Uint32Array([0, 0, 0, 0, 25, 0])) .setSparse(true); - const prim = createMeshPrimitive(doc, buffer).setAttribute('_MARKER', sparseAccessor); + const prim = createMeshPrimitive(doc, buffer).setAttribute('_SPARSE', sparseAccessor); const mesh = doc.createMesh().addPrimitive(prim); const io = await createEncoderIO(); @@ -202,7 +202,7 @@ test('@gltf-transform/extensions::draco-mesh-compression | encoding sparse', asy { mode: Primitive.Mode.TRIANGLES, indices: 0, - attributes: { POSITION: 1, _MARKER: 2 }, + attributes: { POSITION: 1, _SPARSE: 2 }, extensions: { KHR_draco_mesh_compression: { bufferView: 2, @@ -213,9 +213,9 @@ test('@gltf-transform/extensions::draco-mesh-compression | encoding sparse', asy 'primitiveDef' ); t.equals(accessorDefs[1].count, 6, 'POSITION count'); - t.equals(accessorDefs[2].count, 6, '_MARKER 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, '_MARKER sparse'); + t.equals(accessorDefs[2].sparse.count, 1, '_SPARSE sparse'); t.end(); }); diff --git a/packages/extensions/test/meshopt-compression.test.ts b/packages/extensions/test/meshopt-compression.test.ts index e129cbbf8..ea45fbd77 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.only('@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, + }); +} From 23761a604a933b3bfcb67e2e2b46b7bc93d054ee Mon Sep 17 00:00:00 2001 From: Don McCurdy Date: Mon, 23 Jan 2023 14:44:29 -0500 Subject: [PATCH 7/9] Unset sparse accessors during quantize() and resample() - resample() optimizes channels such that it's unlikely to be useful - quantize() is currently incompatible, see https://github.com/donmccurdy/glTF-Transform/issues/795 --- packages/extensions/test/meshopt-compression.test.ts | 2 +- packages/functions/src/quantize.ts | 3 ++- packages/functions/src/resample.ts | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/extensions/test/meshopt-compression.test.ts b/packages/extensions/test/meshopt-compression.test.ts index ea45fbd77..a200c2915 100644 --- a/packages/extensions/test/meshopt-compression.test.ts +++ b/packages/extensions/test/meshopt-compression.test.ts @@ -56,7 +56,7 @@ test('@gltf-transform/extensions::meshopt-compression | encoding', async (t) => t.end(); }); -test.only('@gltf-transform/extensions::meshopt-compression | encoding sparse', async (t) => { +test('@gltf-transform/extensions::meshopt-compression | encoding sparse', async (t) => { const io = await createEncoderIO(); const doc = new Document(); 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(); From de6339ea6b913e6ea5d6ea61480034173628a3a0 Mon Sep 17 00:00:00 2001 From: Don McCurdy Date: Mon, 23 Jan 2023 14:51:58 -0500 Subject: [PATCH 8/9] Mark sparse accessor features as experimental. --- packages/core/src/properties/accessor.ts | 2 ++ packages/functions/src/sparse.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/packages/core/src/properties/accessor.ts b/packages/core/src/properties/accessor.ts index ac409c77b..1b3e5d5c4 100644 --- a/packages/core/src/properties/accessor.ts +++ b/packages/core/src/properties/accessor.ts @@ -429,6 +429,7 @@ export class Accessor extends ExtensibleProperty { * 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'); @@ -439,6 +440,7 @@ export class Accessor extends ExtensibleProperty { * 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); diff --git a/packages/functions/src/sparse.ts b/packages/functions/src/sparse.ts index cd1751fa5..fc14053d8 100644 --- a/packages/functions/src/sparse.ts +++ b/packages/functions/src/sparse.ts @@ -34,6 +34,8 @@ const SPARSE_DEFAULTS: Required = { * * accessor.getSparse(); // → true * ``` + * + * @experimental */ export function sparse(_options: SparseOptions = SPARSE_DEFAULTS): Transform { const options = { ...SPARSE_DEFAULTS, ..._options } as Required; From 68a924e13b3af736af8517fba28dd98556f555e2 Mon Sep 17 00:00:00 2001 From: Don McCurdy Date: Mon, 23 Jan 2023 15:00:55 -0500 Subject: [PATCH 9/9] [cli] Add sparse command --- packages/cli/src/cli.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) 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,