Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for sparse accessors #793

Merged
merged 9 commits into from
Jan 23, 2023
19 changes: 18 additions & 1 deletion packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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>', INPUT_DESC)
.argument('<output>', 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,
Expand Down
10 changes: 10 additions & 0 deletions packages/core/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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,
};
45 changes: 24 additions & 21 deletions packages/core/src/io/reader.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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)[];
Expand Down Expand Up @@ -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;
});

Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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 };
Expand Down
6 changes: 4 additions & 2 deletions packages/core/src/io/writer-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
162 changes: 157 additions & 5 deletions packages/core/src/io/writer.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -9,14 +17,15 @@ 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;
logger?: Logger;
basename?: string;
vertexLayout?: VertexLayout;
dependencies?: { [key: string]: unknown };
extensions?: typeof Extension[];
extensions?: (typeof Extension)[];
}

/** @internal */
Expand Down Expand Up @@ -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<Accessor, SparseData>();
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<SparseData>;
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<Accessor, GraphEdge<Property, Accessor>[]>();
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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);
}
Expand Down
Loading