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

Per-attribute tolerance in weld() #661

Merged
merged 9 commits into from
Aug 17, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions 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, textureResize, unweld, weld, reorder, dequantize, oxipng, mozjpeg, webp, unlit, meshopt, DRACO_DEFAULTS, draco, DracoOptions, simplify, SimplifyOptions, SIMPLIFY_DEFAULTS } 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, textureResize, unweld, weld, reorder, dequantize, oxipng, mozjpeg, webp, unlit, meshopt, DRACO_DEFAULTS, draco, DracoOptions, simplify, SimplifyOptions, SIMPLIFY_DEFAULTS, WELD_DEFAULTS } from '@gltf-transform/functions';
import { InspectFormat, inspect } from './inspect';
import { ETC1S_DEFAULTS, Filter, Mode, UASTC_DEFAULTS, ktxfix, merge, toktx, XMPOptions, xmp } from './transforms';
import { formatBytes, MICROMATCH_OPTIONS, underline } from './util';
Expand Down Expand Up @@ -543,14 +543,20 @@ program
.help(`
Index geometry and optionally merge similar vertices. When merged and indexed,
data is shared more efficiently between vertices. File size can be reduced, and
the GPU can sometimes use the vertex cache more efficiently. With --tolerance=0,
geometry is indexed in place, without merging.
the GPU can sometimes use the vertex cache more efficiently.

When welding, the --tolerance threshold determines which vertices qualify for
welding based on distance between the vertices as a fraction of the primitive's
bounding box (AABB). For example, --tolerance=0.01 welds vertices within +/-1%
of the AABB's longest dimension. Other vertex attributes are also compared
during welding, with attribute-specific thresholds. For --tolerance=0, geometry
is indexed in place, without merging.
`.trim())
.argument('<input>', INPUT_DESC)
.argument('<output>', OUTPUT_DESC)
.option('--tolerance', 'Per-attribute tolerance to merge similar vertices', {
.option('--tolerance', 'Tolerance for vertex welding', {
validator: program.NUMBER,
default: 1e-4,
default: WELD_DEFAULTS.tolerance,
})
.action(({args, options, logger}) =>
Session.create(io, logger, args.input, args.output)
Expand Down
6 changes: 6 additions & 0 deletions packages/functions/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,3 +157,9 @@ export function remapAttribute(attribute: Accessor, remap: Uint32Array, dstCount

attribute.setArray(dstArray);
}

export function createIndices(count: number, maxIndex = count): Uint16Array | Uint32Array {
const array = maxIndex <= 65534 ? new Uint16Array(count) : new Uint32Array(count);
for (let i = 0; i < array.length; i++) array[i] = i;
return array;
}
250 changes: 176 additions & 74 deletions packages/functions/src/weld.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,28 +7,64 @@ import {
Transform,
TransformContext,
TypedArray,
vec3,
} from '@gltf-transform/core';
import { dedup } from './dedup';
import { createTransform, formatDeltaOp, isTransformPending } from './utils';
import { createIndices, createTransform, formatDeltaOp, isTransformPending } from './utils';

const NAME = 'weld';

const Tolerance = {
DEFAULT: 0.0001,
TEXCOORD: 0.0001, // [0, 1]
COLOR: 0.01, // [0, 1]
NORMAL: 0.01, // [-1, 1]
JOINTS: 0.0, // [0, ∞]
WEIGHTS: 0.01, // [0, ∞]
};

/** Options for the {@link weld} function. */
export interface WeldOptions {
/** Per-attribute tolerance used when merging similar vertices. */
/** Tolerance, as a fraction of primitive AABB, used when merging similar vertices. */
tolerance?: number;
/** Whether to overwrite existing indices. */
overwrite?: boolean;
}

const WELD_DEFAULTS: Required<WeldOptions> = { tolerance: 1e-4, overwrite: true };
export const WELD_DEFAULTS: Required<WeldOptions> = {
tolerance: Tolerance.DEFAULT,
overwrite: true,
};

/**
* Index {@link Primitive}s and (optionally) merge similar vertices.
* Index {@link Primitive}s and (optionally) merge similar vertices. When merged
* and indexed, data is shared more efficiently between vertices. File size can
* be reduced, and the GPU can sometimes use the vertex cache more efficiently.
*
* When welding, the 'tolerance' threshold determines which vertices qualify for
* welding based on distance between the vertices as a fraction of the primitive's
* bounding box (AABB). For example, tolerance=0.01 welds vertices within +/-1%
* of the AABB's longest dimension. Other vertex attributes are also compared
* during welding, with attribute-specific thresholds. For --tolerance=0, geometry
* is indexed in place, without merging.
*
* Example:
*
* ```js
* import { weld } from '@gltf-transform/functions';
*
* await document.transform(
* weld({ tolerance: 0.001 })
* );
* ```
*/
export function weld(_options: WeldOptions = WELD_DEFAULTS): Transform {
const options = { ...WELD_DEFAULTS, ..._options } as Required<WeldOptions>;

if (options.tolerance > 0.1 || options.tolerance < 0) {
throw new Error(`${NAME}: Requires 0 ≤ tolerance ≤ 0.1`);
}

return createTransform(NAME, async (doc: Document, context?: TransformContext): Promise<void> => {
const logger = doc.getLogger();

Expand Down Expand Up @@ -68,93 +104,110 @@ function weldOnly(doc: Document, prim: Primitive): void {
prim.setIndices(indices);
}

/**
* Weld and merge, combining vertices that are similar on all vertex attributes. Morph target
* attributes are not considered when scoring vertex similarity, but are retained when merging.
*/
/** Weld and merge, combining vertices that are similar on all vertex attributes. */
function weldAndMerge(doc: Document, prim: Primitive, options: Required<WeldOptions>): void {
const logger = doc.getLogger();

const srcPosition = prim.getAttribute('POSITION')!;
const srcIndices = prim.getIndices() || doc.createAccessor().setArray(createIndices(srcPosition.getCount()));
const uniqueIndices = new Uint32Array(new Set(srcIndices.getArray()!));

// (1) Compute per-attribute tolerances, pre-sort vertices.

const tolerance = Math.max(options.tolerance, Number.EPSILON);
const decimalShift = Math.log10(1 / tolerance);
const shiftFactor = Math.pow(10, decimalShift);

const hashToIndex: { [key: string]: number } = {};
const srcIndices = prim.getIndices();
const vertexCount = srcIndices ? srcIndices.getCount() : prim.listAttributes()[0].getCount();

// Prepare storage for new elements of each attribute.
const dstAttributes = new Map<Accessor, number[][]>();
prim.listAttributes().forEach((attr) => dstAttributes.set(attr, []));
prim.listTargets().forEach((target) => {
target.listAttributes().forEach((attr) => dstAttributes.set(attr, []));
const attributeTolerance: Record<string, number> = {};
for (const semantic of prim.listSemantics()) {
const attribute = prim.getAttribute(semantic)!;
attributeTolerance[semantic] = getAttributeTolerance(semantic, attribute, tolerance);
}

logger.debug(`${NAME}: Tolerance thresholds: ${formatKV(attributeTolerance)}`);

const posA: vec3 = [0, 0, 0];
const posB: vec3 = [0, 0, 0];

uniqueIndices.sort((a, b) => {
srcPosition.getElement(a, posA);
srcPosition.getElement(b, posB);
return posA[0] > posB[0] ? 1 : -1;
});

const dstIndicesArray = [];
let nextIndex = 0;
// (2) Compare and identify vertices to weld. Use sort to keep iterations below O(n²),

// For each vertex, compute a hash based on its tolerance and merge with any sufficiently
// similar vertices.
for (let i = 0; i < vertexCount; i++) {
const index = srcIndices ? srcIndices.getScalar(i) : i;
const weldMap = createIndices(uniqueIndices.length); // oldIndex → oldCommonIndex
const writeMap = createIndices(uniqueIndices.length); // oldIndex → newIndex

const hashElements: number[] = [];
const el: number[] = [];
for (const attribute of prim.listAttributes()) {
for (let j = 0; j < attribute.getElementSize(); j++) {
hashElements.push(~~(attribute.getElement(index, el)[j] * shiftFactor));
}
}
const srcVertexCount = srcPosition.getCount();
let dstVertexCount = 0;
let backIters = 0;

const hash = hashElements.join('|');
if (hash in hashToIndex) {
dstIndicesArray.push(hashToIndex[hash]);
} else {
for (const attr of prim.listAttributes()) {
dstAttributes.get(attr)!.push(attr.getElement(index, []));
for (let i = 0; i < uniqueIndices.length; i++) {
const a = uniqueIndices[i];

for (let j = i - 1; j >= 0; j--) {
const b = weldMap[uniqueIndices[j]];

srcPosition.getElement(a, posA);
srcPosition.getElement(b, posB);

// Sort order allows early exit on X-axis distance.
if (Math.abs(posA[0] - posB[0]) > attributeTolerance['POSITION']) {
break;
}
for (const target of prim.listTargets()) {
for (const attr of target.listAttributes()) {
dstAttributes.get(attr)!.push(attr.getElement(index, []));
}

backIters++;

// Weld if base attributes and morph target attributes match.
const isBaseMatch = prim.listSemantics().every((semantic) => {
const attribute = prim.getAttribute(semantic)!;
const tolerance = attributeTolerance[semantic];
return compareAttributes(attribute, a, b, tolerance, semantic);
});
const isTargetMatch = prim.listTargets().every((target) => {
return target.listSemantics().every((semantic) => {
const attribute = prim.getAttribute(semantic)!;
const tolerance = attributeTolerance[semantic];
return compareAttributes(attribute, a, b, tolerance, semantic);
});
});

if (isBaseMatch && isTargetMatch) {
weldMap[a] = b;
break;
}
}

hashToIndex[hash] = nextIndex;
dstIndicesArray.push(nextIndex);
nextIndex++;
// Output the vertex if we didn't find a match, else record the index of the match.
if (weldMap[a] === a) {
writeMap[a] = dstVertexCount++; // note: reorders the primitive on x-axis sort.
} else {
writeMap[a] = writeMap[weldMap[a]];
}
}

const srcVertexCount = prim.listAttributes()[0].getCount();
const dstVertexCount = dstAttributes.get(prim.getAttribute('POSITION')!)!.length;
logger.debug(`${NAME}: Iterations per vertex: ${Math.round(backIters / uniqueIndices.length)} (avg)`);
logger.debug(`${NAME}: ${formatDeltaOp(srcVertexCount, dstVertexCount)} vertices.`);

// Update the primitive.
for (const srcAttr of prim.listAttributes()) {
swapAttributes(prim, srcAttr, dstAttributes.get(srcAttr)!);
// (3) Update indices.

// Clean up.
if (srcAttr.listParents().length === 1) srcAttr.dispose();
const dstIndicesCount = srcIndices.getCount(); // # primitives does not change.
const dstIndicesArray = createIndices(dstIndicesCount, uniqueIndices.length);
for (let i = 0; i < dstIndicesCount; i++) {
dstIndicesArray[i] = writeMap[srcIndices.getScalar(i)];
}
prim.setIndices(srcIndices.clone().setArray(dstIndicesArray));
if (srcIndices.listParents().length === 1) srcIndices.dispose();

// (4) Update vertex attributes.

for (const srcAttr of prim.listAttributes()) {
swapAttributes(prim, srcAttr, writeMap, dstVertexCount);
}
for (const target of prim.listTargets()) {
for (const srcAttr of target.listAttributes()) {
swapAttributes(target, srcAttr, dstAttributes.get(srcAttr)!);

// Clean up.
if (srcAttr.listParents().length === 1) srcAttr.dispose();
swapAttributes(target, srcAttr, writeMap, dstVertexCount);
}
}
if (srcIndices) {
const dstIndicesTypedArray = createArrayOfType(srcIndices.getArray()!, dstIndicesArray.length);
dstIndicesTypedArray.set(dstIndicesArray);
prim.setIndices(srcIndices.clone().setArray(dstIndicesTypedArray));

// Clean up.
if (srcIndices.listParents().length === 1) srcIndices.dispose();
} else {
const indicesArray =
srcVertexCount <= 65534 ? new Uint16Array(dstIndicesArray) : new Uint32Array(dstIndicesArray);
prim.setIndices(doc.createAccessor().setArray(indicesArray));
}
}

/** Creates a new TypedArray of the same type as an original, with a new length. */
Expand All @@ -164,14 +217,63 @@ function createArrayOfType<T extends TypedArray>(array: T, length: number): T {
}

/** Replaces an {@link Attribute}, creating a new one with the given elements. */
function swapAttributes(parent: Primitive | PrimitiveTarget, srcAttr: Accessor, dstAttrElements: number[][]): void {
const dstAttrArrayLength = dstAttrElements.length * srcAttr.getElementSize();
const dstAttrArray = createArrayOfType(srcAttr.getArray()!, dstAttrArrayLength);
function swapAttributes(
parent: Primitive | PrimitiveTarget,
srcAttr: Accessor,
reorder: Uint32Array | Uint16Array,
dstCount: number
): void {
const dstAttrArray = createArrayOfType(srcAttr.getArray()!, dstCount * srcAttr.getElementSize());
const dstAttr = srcAttr.clone().setArray(dstAttrArray);
const done = new Uint8Array(dstCount);

for (let i = 0; i < dstAttrElements.length; i++) {
dstAttr.setElement(i, dstAttrElements[i]);
for (let i = 0, el = [] as number[]; i < reorder.length; i++) {
if (!done[reorder[i]]) {
dstAttr.setElement(reorder[i], srcAttr.getElement(i, el));
done[reorder[i]] = 1;
}
}

parent.swap(srcAttr, dstAttr);

// Clean up.
if (srcAttr.listParents().length === 1) srcAttr.dispose();
}

const _a = [] as number[];
const _b = [] as number[];

/** Computes a per-attribute tolerance, based on domain and usage of the attribute. */
function getAttributeTolerance(semantic: string, attribute: Accessor, tolerance: number): number {
// Attributes like NORMAL and COLOR_# do not vary in range like POSITION,
// so do not apply the given tolerance factor to these attributes.
if (semantic === 'NORMAL' || semantic === 'TANGENT') return Tolerance.NORMAL;
if (semantic.startsWith('COLOR_')) return Tolerance.COLOR;
if (semantic.startsWith('TEXCOORD_')) return Tolerance.TEXCOORD;
if (semantic.startsWith('JOINTS_')) return Tolerance.JOINTS;
if (semantic.startsWith('WEIGHTS_')) return Tolerance.WEIGHTS;

_a.length = _b.length = 0;
attribute.getMinNormalized(_a);
attribute.getMaxNormalized(_b);
const range = Math.max(..._b) - Math.min(..._a) || 1;
return tolerance * range;
}

/** Compares two vertex attributes against a tolerance threshold. */
function compareAttributes(attribute: Accessor, a: number, b: number, tolerance: number, _semantic: string): boolean {
attribute.getElement(a, _a);
attribute.getElement(b, _b);
for (let i = 0, il = attribute.getElementSize(); i < il; i++) {
if (Math.abs(_a[i] - _b[i]) > tolerance) {
return false;
}
}
return true;
}

function formatKV(kv: Record<string, unknown>): string {
return Object.entries(kv)
.map(([k, v]) => `${k}=${v}`)
.join(', ');
}
Loading